perlthrtut - Perl 线程教程
本教程介绍了 Perl 解释器线程(有时称为ithreads)的使用。在这个模型中,每个线程都在自己的 Perl 解释器中运行,线程之间任何数据共享都必须是显式的。ithreads 的用户级接口使用 threads 类。
注意:以前还有一个名为 5.005 模型的旧 Perl 线程风格,它使用 threads 类。这个旧模型已知存在问题,已弃用,并在 5.10 版本中被移除。强烈建议您尽快将任何现有的 5.005 线程代码迁移到新模型。
您可以通过运行 perl -V
并查看 Platform
部分来查看您使用的是哪种线程风格(或两者都不使用)。如果您有 useithreads=define
,则您有 ithreads,如果您有 use5005threads=define
,则您有 5.005 线程。如果您两者都没有,则您没有内置任何线程支持。如果您两者都有,那么您就麻烦了。
threads 和 threads::shared 模块包含在 Perl 核心发行版中。此外,它们作为独立的模块在 CPAN 上维护,因此您可以查看是否有任何更新。
线程是程序中具有单个执行点的控制流。
听起来很像进程,不是吗?嗯,确实如此。线程是进程的一部分。每个进程至少有一个线程,到目前为止,每个运行 Perl 的进程只有一个线程。但是,有了 5.8,您可以创建额外的线程。我们将向您展示如何、何时以及为什么要这样做。
您可以通过三种基本方式构建线程程序。您选择哪种模型取决于您需要程序做什么。对于许多非平凡的线程程序,您需要为程序的不同部分选择不同的模型。
老板/工人模型通常包含一个老板线程和一个或多个工人线程。老板线程收集或生成需要完成的任务,然后将这些任务分配给相应的工人线程。
这种模型在 GUI 和服务器程序中很常见,其中主线程等待某个事件,然后将该事件传递给相应的工人线程进行处理。事件传递后,老板线程返回等待下一个事件。
老板线程的工作量相对较少。虽然任务的执行速度不一定比其他方法快,但它往往具有最佳的用户响应时间。
在工作组模型中,创建了多个线程,它们对不同数据执行基本相同的操作。它与经典的并行处理和向量处理器非常相似,其中大量处理器对大量数据执行完全相同的操作。
如果运行程序的系统将多个线程分布在不同的处理器上,这种模型特别有用。它在光线追踪或渲染引擎中也很有用,其中各个线程可以传递中间结果,以便为用户提供视觉反馈。
流水线模型将任务划分为一系列步骤,并将一个步骤的结果传递给处理下一个步骤的线程。每个线程对每段数据执行一项操作,并将结果传递给下一级线程。
如果您有多个处理器,那么这种模型最有意义,因为两个或多个线程将并行执行,尽管它在其他情况下也经常有意义。它倾向于使各个任务保持较小和简单,并允许流水线的某些部分阻塞(例如,在 I/O 或系统调用上),而其他部分继续运行。如果您在不同的处理器上运行流水线的不同部分,您还可以利用每个处理器的缓存。
这种模型对于一种递归编程形式也很有用,在这种形式中,它不是让子程序调用自身,而是创建另一个线程。素数和斐波那契数生成器都很好地映射到这种管道模型形式。(后面将介绍素数生成器的版本。)
如果您有其他线程实现的经验,您可能会发现情况并非您所期望的那样。在处理 Perl 线程时,务必牢记Perl 线程不是 X 线程,对于所有 X 的值都是如此。它们不是 POSIX 线程,也不是 DecThreads、Java 的绿色线程或 Win32 线程。它们之间存在相似之处,总体概念也是相同的,但如果您开始寻找实现细节,您可能会感到失望或困惑。可能两者都有。
这并不是说 Perl 线程与之前出现的所有东西完全不同。它们不是。Perl 的线程模型在很大程度上借鉴了其他线程模型,尤其是 POSIX。但是,正如 Perl 不是 C 一样,Perl 线程也不是 POSIX 线程。因此,如果您发现自己正在寻找互斥锁或线程优先级,那么是时候退一步,思考一下您想做什么以及 Perl 如何做到这一点。
但是,必须记住,除非您的操作系统的线程允许,否则 Perl 线程无法神奇地执行操作。因此,如果您的系统在sleep()
上阻塞整个进程,Perl 通常也会这样做。
Perl 线程是不同的。
线程的添加实质性地改变了 Perl 的内部结构。这对使用 XS 代码或外部库编写模块的人员有影响。但是,由于 Perl 数据默认情况下不会在线程之间共享,因此 Perl 模块很有可能线程安全,或者可以轻松地使其线程安全。未标记为线程安全的模块应在生产代码中使用之前进行测试或代码审查。
并非所有您可能使用的模块都是线程安全的,您应该始终假设一个模块是不安全的,除非文档中另有说明。这包括作为核心的一部分分发的模块。线程是一个相对较新的功能,即使一些标准模块也不是线程安全的。
即使一个模块是线程安全的,也不意味着该模块针对线程进行了优化。可以对模块进行重写以利用线程化 Perl 中的新功能,以提高线程化环境中的性能。
如果您正在使用一个出于某种原因不是线程安全的模块,您可以通过在一个线程中使用它,并且只在一个线程中使用它来保护自己。如果您需要多个线程访问此类模块,您可以使用信号量和大量的编程纪律来控制对它的访问。信号量在"基本信号量"中介绍。
另请参阅"系统库的线程安全性"。
该线程模块提供了编写线程程序所需的必要基本函数。在接下来的部分中,我们将介绍基础知识,向您展示创建线程程序需要做什么。之后,我们将介绍线程模块的一些使线程编程更容易的功能。
线程支持是 Perl 编译时选项。它是您在站点构建 Perl 时打开或关闭的选项,而不是在编译程序时打开或关闭的选项。如果您的 Perl 没有启用线程支持进行编译,那么任何尝试使用线程的操作都将失败。
您的程序可以使用 Config 模块来检查是否启用了线程。如果您的程序无法在没有线程的情况下运行,您可以说类似的话
use Config;
$Config{useithreads} or
die('Recompile Perl with threads to run this program.');
一个可能使用线程的程序,使用一个可能使用线程的模块,可能会有这样的代码
use Config;
use MyMod;
BEGIN {
if ($Config{useithreads}) {
# We have threads
require MyMod_threaded;
import MyMod_threaded;
} else {
require MyMod_unthreaded;
import MyMod_unthreaded;
}
}
由于既可以在有线程的情况下运行,又可以在没有线程的情况下运行的代码通常非常混乱,因此最好将特定于线程的代码隔离到它自己的模块中。在我们上面的示例中,这就是MyMod_threaded
,并且只有在我们使用线程化的 Perl 运行时才会导入它。
在实际情况下,应注意在程序退出之前所有线程都已完成执行。为了简单起见,这些示例中没有采取这种措施。按原样运行这些示例将产生错误消息,通常是由于程序退出时仍有线程在运行。您不必为此感到惊慌。
该线程模块提供了创建新线程所需的工具。与任何其他模块一样,您需要告诉 Perl 您要使用它;use threads;
导入创建基本线程所需的所有部分。
创建线程最简单、最直接的方法是使用create()
use threads;
my $thr = threads->create(\&sub1);
sub sub1 {
print("In the thread\n");
}
该create()
方法接受对子例程的引用,并创建一个新的线程,该线程从引用的子例程开始执行。然后,控制权同时传递给子例程和调用者。
如果需要,您的程序可以将参数作为线程启动的一部分传递给子例程。只需将参数列表作为threads->create()
调用的一部分包含在内,如下所示
use threads;
my $Param3 = 'foo';
my $thr1 = threads->create(\&sub1, 'Param 1', 'Param 2', $Param3);
my @ParamList = (42, 'Hello', 3.14);
my $thr2 = threads->create(\&sub1, @ParamList);
my $thr3 = threads->create(\&sub1, qw(Param1 Param2 Param3));
sub sub1 {
my @InboundParameters = @_;
print("In the thread\n");
print('Got parameters >', join('<>',@InboundParameters), "<\n");
}
最后一个例子说明了线程的另一个特性。您可以使用相同的子例程生成多个线程。每个线程都执行相同的子例程,但在一个独立的线程中,具有独立的环境和可能不同的参数。
new()
是 create()
的同义词。
由于线程也是子例程,因此它们可以返回值。要等待线程退出并提取它可能返回的任何值,可以使用 join()
方法
use threads;
my ($thr) = threads->create(\&sub1);
my @ReturnData = $thr->join();
print('Thread returned ', join(', ', @ReturnData), "\n");
sub sub1 { return ('Fifty-six', 'foo', 2); }
在上面的示例中,join()
方法在线程结束时立即返回。除了等待线程完成并收集线程可能返回的任何值之外,join()
还执行线程所需的任何操作系统清理。这种清理可能很重要,尤其是对于生成大量线程的长时间运行的程序。如果您不想要返回值,也不想等待线程完成,则应改为调用 detach()
方法,如下所述。
注意:在上面的示例中,线程返回一个列表,因此需要在列表上下文中进行线程创建调用(即 my ($thr)
)。有关线程上下文和返回值的更多详细信息,请参阅 "$thr->join()" in threads 和 "THREAD CONTEXT" in threads。
join()
做三件事:它等待线程退出,清理它,并返回线程可能产生的任何数据。但是,如果您对线程的返回值不感兴趣,并且您并不关心线程何时完成呢?您所要做的就是让线程在完成后进行清理。
在这种情况下,您使用 detach()
方法。一旦线程分离,它将一直运行到完成;然后 Perl 将自动清理它。
use threads;
my $thr = threads->create(\&sub1); # Spawn the thread
$thr->detach(); # Now we officially don't care any more
sleep(15); # Let thread run for awhile
sub sub1 {
my $count = 0;
while (1) {
$count++;
print("\$count is $count\n");
sleep(1);
}
}
一旦线程分离,它就不能再加入,并且它可能产生的任何返回值(如果它已完成并正在等待加入)都会丢失。
detach()
也可以作为类方法调用,以允许线程分离自身
use threads;
my $thr = threads->create(\&sub1);
sub sub1 {
threads->detach();
# Do more work
}
使用线程时,必须小心确保所有线程都有机会运行到完成,假设这是您想要的。
终止进程的操作将终止所有正在运行的线程。die()
和 exit()
具有此属性,当主线程退出时,perl 会执行退出,也许是隐式地从您的代码末尾掉下来,即使这不是您想要的。
作为这种情况的一个例子,此代码打印消息“Perl 退出,有活动线程:2 个正在运行且未加入”。
use threads;
my $thr1 = threads->new(\&thrsub, "test1");
my $thr2 = threads->new(\&thrsub, "test2");
sub thrsub {
my ($message) = @_;
sleep 1;
print "thread $message\n";
}
但是,当在末尾添加以下行时
$thr1->join();
$thr2->join();
它会打印两行输出,这可能是一个更有用的结果。
现在我们已经涵盖了线程的基础知识,是时候进入我们的下一个主题:数据。线程引入了数据访问的几个复杂情况,非线程程序永远不必担心这些情况。
Perl ithreads 与旧的 5.005 风格线程(或者更确切地说,与大多数其他线程系统)之间最大的区别在于,默认情况下,没有数据共享。当创建一个新的 Perl 线程时,与当前线程关联的所有数据都会复制到新线程,并且随后对该新线程私有!这与 Unix 进程分叉时发生的情况类似,只是在这种情况下,数据只是复制到同一进程内存中的不同部分,而不是发生真正的分叉。
但是,为了利用线程,通常希望线程至少在它们之间共享一些数据。这是使用 threads::shared 模块和 :shared
属性完成的
use threads;
use threads::shared;
my $foo :shared = 1;
my $bar = 1;
threads->create(sub { $foo++; $bar++; })->join();
print("$foo\n"); # Prints 2 since $foo is shared
print("$bar\n"); # Prints 1 since $bar is not shared
对于共享数组,所有数组元素都是共享的,对于共享哈希,所有键和值都是共享的。这限制了可以分配给共享数组和哈希元素的内容:只允许简单值或对共享变量的引用 - 这是为了防止私有变量意外地变为共享。错误的分配会导致线程死亡。例如
use threads;
use threads::shared;
my $var = 1;
my $svar :shared = 2;
my %hash :shared;
... create some threads ...
$hash{a} = 1; # All threads see exists($hash{a})
# and $hash{a} == 1
$hash{a} = $var; # okay - copy-by-value: same effect as previous
$hash{a} = $svar; # okay - copy-by-value: same effect as previous
$hash{a} = \$svar; # okay - a reference to a shared variable
$hash{a} = \$var; # This will die
delete($hash{a}); # okay - all threads will see !exists($hash{a})
请注意,共享变量保证如果两个或多个线程试图同时修改它,变量的内部状态不会被破坏。但是,除了这一点之外,没有其他保证,如下一节所述。
虽然线程带来了一套新的有用工具,但它们也带来了一些陷阱。一个陷阱是竞态条件
use threads;
use threads::shared;
my $x :shared = 1;
my $thr1 = threads->create(\&sub1);
my $thr2 = threads->create(\&sub2);
$thr1->join();
$thr2->join();
print("$x\n");
sub sub1 { my $foo = $x; $x = $foo + 1; }
sub sub2 { my $bar = $x; $x = $bar + 1; }
你认为$x
的值会是多少?不幸的是,答案是取决于情况。sub1()
和sub2()
都访问了全局变量$x
,一次读取,一次写入。根据从线程实现的调度算法到月相等因素,$x
的值可能是2或3。
竞争条件是由对共享数据的非同步访问引起的。如果没有显式同步,无法保证在访问共享数据和更新共享数据之间的时间段内,共享数据不会发生任何变化。即使是这个简单的代码片段也可能出现错误。
use threads;
my $x :shared = 2;
my $y :shared;
my $z :shared;
my $thr1 = threads->create(sub { $y = $x; $x = $y + 1; });
my $thr2 = threads->create(sub { $z = $x; $x = $z + 1; });
$thr1->join();
$thr2->join();
两个线程都访问$x
。每个线程都可能在任何时刻被中断,或者以任何顺序执行。最后,$x
的值可能是3或4,$y
和$z
的值也可能是2或3。
即使是$x += 5
或$x++
也不能保证是原子的。
无论何时你的程序访问可以被其他线程访问的数据或资源,你必须采取措施协调访问,否则可能会导致数据不一致和竞争条件。请注意,Perl会保护其内部机制免受你的竞争条件的影响,但它不会保护你免受你自己造成的伤害。
Perl提供了一些机制来协调线程之间的交互及其数据,以避免竞争条件等问题。其中一些机制旨在类似于线程库(如pthreads
)中常用的技术;另一些则是 Perl 特有的。通常,标准技术笨拙且难以正确使用(例如条件等待)。在可能的情况下,使用 Perl 式技术(如队列)通常更容易,这可以消除一些繁重的工作。
lock()
函数接受一个共享变量,并对其进行加锁。在持有锁的线程解锁变量之前,其他线程不能锁定该变量。解锁在锁定线程退出包含对lock()
函数调用的代码块时自动发生。使用lock()
很简单:此示例中有多个线程并行进行一些计算,并偶尔更新运行总数。
use threads;
use threads::shared;
my $total :shared = 0;
sub calc {
while (1) {
my $result;
# (... do some calculations and set $result ...)
{
lock($total); # Block until we obtain the lock
$total += $result;
} # Lock implicitly released at end of scope
last if $result == 0;
}
}
my $thr1 = threads->create(\&calc);
my $thr2 = threads->create(\&calc);
my $thr3 = threads->create(\&calc);
$thr1->join();
$thr2->join();
$thr3->join();
print("total=$total\n");
lock()
会阻塞线程,直到要锁定的变量可用。当 lock()
返回时,您的线程可以确定在包含锁定的代码块退出之前,没有其他线程可以锁定该变量。
需要注意的是,锁不会阻止访问所讨论的变量,只会阻止锁定尝试。这符合 Perl 长期以来对礼貌编程的传统,以及 flock()
提供的建议文件锁定。
您可以锁定数组和哈希,以及标量。但是,锁定数组不会阻止随后对数组元素的锁定,只会阻止对数组本身的锁定尝试。
锁是递归的,这意味着线程可以多次锁定一个变量。锁将持续到对该变量的最外层 lock()
退出作用域为止。例如
my $x :shared;
doit();
sub doit {
{
{
lock($x); # Wait for lock
lock($x); # NOOP - we already have the lock
{
lock($x); # NOOP
{
lock($x); # NOOP
lockit_some_more();
}
}
} # *** Implicit unlock here ***
}
}
sub lockit_some_more {
lock($x); # NOOP
} # Nothing happens here
请注意,没有 unlock()
函数 - 解锁变量的唯一方法是让它退出作用域。
锁可以用来保护被锁定的变量中包含的数据,也可以用来保护其他东西,比如一段代码。在后一种情况下,所讨论的变量不包含任何有用的数据,而仅仅是为了被锁定而存在。在这方面,该变量的行为类似于传统线程库中的互斥锁和基本信号量。
锁是同步数据访问的便捷工具,正确使用它们是安全共享数据的关键。不幸的是,锁并非没有危险,尤其是在涉及多个锁时。考虑以下代码
use threads;
my $x :shared = 4;
my $y :shared = 'foo';
my $thr1 = threads->create(sub {
lock($x);
sleep(20);
lock($y);
});
my $thr2 = threads->create(sub {
lock($y);
sleep(20);
lock($x);
});
这个程序可能会一直挂起,直到你把它杀死。它不会挂起的唯一方法是两个线程中的一个先获取两个锁。一个保证会挂起的版本更复杂,但原理是一样的。
第一个线程将获取对 $x
的锁,然后,在第二个线程可能已经完成了一些工作后暂停,尝试获取对 $y
的锁。与此同时,第二个线程获取对 $y
的锁,然后稍后尝试获取对 $x
的锁。两个线程的第二次锁定尝试都会被阻塞,每个线程都等待另一个线程释放其锁。
这种情况称为死锁,它发生在两个或多个线程试图获取对方拥有的资源的锁时。每个线程都会被阻塞,等待另一个线程释放对资源的锁。然而,这种情况永远不会发生,因为拥有资源的线程本身也在等待一个锁被释放。
处理这类问题的方法有很多。最好的方法是始终让所有线程以完全相同的顺序获取锁。例如,如果您锁定变量$x
、$y
和$z
,始终先锁定$x
,然后锁定$y
,最后锁定$z
。最好尽可能短地持有锁,以最大程度地降低死锁风险。
下面描述的其他同步原语也会遇到类似的问题。
队列是一种特殊的线程安全对象,它允许您将数据放入一端,并从另一端取出数据,而无需担心同步问题。它们非常简单,看起来像这样
use threads;
use Thread::Queue;
my $DataQueue = Thread::Queue->new();
my $thr = threads->create(sub {
while (my $DataElement = $DataQueue->dequeue()) {
print("Popped $DataElement off the queue\n");
}
});
$DataQueue->enqueue(12);
$DataQueue->enqueue("A", "B", "C");
sleep(10);
$DataQueue->enqueue(undef);
$thr->join();
您可以使用Thread::Queue->new()
创建队列。然后,您可以使用enqueue()
将标量列表添加到末尾,并使用dequeue()
从其开头弹出标量。队列没有固定大小,可以根据需要增长以容纳所有推送到其中的内容。
如果队列为空,dequeue()
将阻塞,直到另一个线程将某些内容排队。这使得队列非常适合事件循环和其他线程之间的通信。
信号量是一种通用的锁定机制。在最基本的形式中,它们的行为非常类似于可锁定的标量,只是它们不能保存数据,并且必须显式解锁。在高级形式中,它们充当一种计数器,并且可以允许多个线程在任何时候都拥有锁。
信号量有两种方法,down()
和up()
:down()
递减资源计数,而up()
递增它。如果信号量的当前计数将递减到零以下,则对down()
的调用将阻塞。该程序提供了一个快速演示
use threads;
use Thread::Semaphore;
my $semaphore = Thread::Semaphore->new();
my $GlobalVariable :shared = 0;
$thr1 = threads->create(\&sample_sub, 1);
$thr2 = threads->create(\&sample_sub, 2);
$thr3 = threads->create(\&sample_sub, 3);
sub sample_sub {
my $SubNumber = shift(@_);
my $TryCount = 10;
my $LocalCopy;
sleep(1);
while ($TryCount--) {
$semaphore->down();
$LocalCopy = $GlobalVariable;
print("$TryCount tries left for sub $SubNumber "
."(\$GlobalVariable is $GlobalVariable)\n");
sleep(2);
$LocalCopy++;
$GlobalVariable = $LocalCopy;
$semaphore->up();
}
}
$thr1->join();
$thr2->join();
$thr3->join();
子例程的三个调用都是同步执行的。但是,信号量确保一次只有一个线程访问全局变量。
默认情况下,信号量表现得像锁一样,一次只允许一个线程down()
它们。但是,信号量还有其他用途。
每个信号量都附带一个计数器。默认情况下,信号量以计数器设置为 1 创建,down()
将计数器递减 1,up()
递增 1。但是,我们可以通过传入不同的值来覆盖任何或所有这些默认值
use threads;
use Thread::Semaphore;
my $semaphore = Thread::Semaphore->new(5);
# Creates a semaphore with the counter set to five
my $thr1 = threads->create(\&sub1);
my $thr2 = threads->create(\&sub1);
sub sub1 {
$semaphore->down(5); # Decrements the counter by five
# Do stuff here
$semaphore->up(5); # Increment the counter by five
}
$thr1->detach();
$thr2->detach();
如果down()
尝试将计数器递减到零以下,它将阻塞,直到计数器足够大。请注意,虽然信号量可以以初始计数为零创建,但任何up()
或down()
始终将计数器至少更改 1,因此$semaphore->down(0)
与$semaphore->down(1)
相同。
当然,问题是为什么要这样做?为什么要创建一个初始计数不为 1 的信号量,或者为什么要将其递减或递增超过 1?答案是资源可用性。许多您想要管理访问权限的资源可以安全地被多个线程同时使用。
例如,让我们以一个 GUI 驱动的程序为例。它有一个信号量,用于同步对显示器的访问,因此一次只有一个线程在绘制。很方便,但当然您不希望任何线程在一切设置好之前开始绘制。在这种情况下,您可以创建一个计数器设置为零的信号量,并在一切准备就绪后将其提升。
计数器大于 1 的信号量也适用于建立配额。例如,假设您有一些可以同时执行 I/O 的线程。但是,您不希望所有线程同时读写,因为这可能会淹没您的 I/O 通道,或耗尽进程的文件句柄配额。您可以使用一个初始化为您希望在任何时间点进行的并发 I/O 请求(或打开的文件)数量的信号量,并让您的线程安静地阻塞和解除阻塞。
在那些线程需要一次检出或返回多个资源的情况下,更大的增量或减量非常方便。
cond_wait()
和 cond_signal()
函数可以与锁一起使用,以通知协作线程资源已变得可用。它们在使用上与 pthreads
中的函数非常相似。但是,对于大多数目的,队列更易于使用且更直观。有关更多详细信息,请参阅 threads::shared。
有时您可能会发现让线程显式地将 CPU 让给另一个线程很有用。您可能正在做一些处理器密集型的事情,并且想要确保用户界面线程经常被调用。无论如何,有时您可能希望线程放弃处理器。
Perl 的线程包提供了 yield()
函数来实现这一点。yield()
非常简单,工作原理如下
use threads;
sub loop {
my $thread = shift;
my $foo = 50;
while($foo--) { print("In thread $thread\n"); }
threads->yield();
$foo = 50;
while($foo--) { print("In thread $thread\n"); }
}
my $thr1 = threads->create(\&loop, 'first');
my $thr2 = threads->create(\&loop, 'second');
my $thr3 = threads->create(\&loop, 'third');
重要的是要记住,yield()
只是放弃 CPU 的提示,它取决于您的硬件、操作系统和线程库,实际发生的事情。在许多操作系统上,yield() 是一个空操作。 因此,重要的是要注意,不应该围绕 yield()
调用构建线程的调度。它可能在您的平台上有效,但在另一个平台上无效。
我们已经涵盖了 Perl 线程包的核心部分,有了这些工具,您应该可以轻松地编写线程代码和包。还有一些有用的细节,它们并不适合放在其他任何地方。
threads->self()
类方法为您的程序提供了一种方法,可以获取一个表示当前线程的对象。您可以像使用从线程创建返回的对象一样使用此对象。
tid()
是一个线程对象方法,它返回对象所代表的线程的线程 ID。线程 ID 是整数,程序中的主线程为 0。目前,Perl 为程序中创建的每个线程分配一个唯一的 TID,将第一个创建的线程分配为 TID 1,并为每个新创建的线程将 TID 增加 1。当用作类方法时,threads->tid()
可以被线程用来获取自己的 TID。
equal()
方法接受两个线程对象,如果对象代表同一个线程,则返回 true,否则返回 false。
线程对象还具有重载的 ==
比较,因此您可以像对普通对象一样对它们进行比较。
threads->list()
返回一个线程对象列表,每个对象代表一个当前正在运行且未分离的线程。这对许多事情都很方便,包括在程序结束时清理(当然是从主 Perl 线程)。
# Loop through all the threads
foreach my $thr (threads->list()) {
$thr->join();
}
如果一些线程在主 Perl 线程结束时尚未完成运行,Perl 会警告您并退出,因为 Perl 无法在其他线程运行时清理自身。
注意:主 Perl 线程(线程 0)处于分离状态,因此不会出现在 threads->list()
返回的列表中。
困惑了吗?现在是时候用一个示例程序来展示我们已经涵盖的一些内容了。这个程序使用线程查找素数。
1 #!/usr/bin/perl
2 # prime-pthread, courtesy of Tom Christiansen
3
4 use v5.36;
5
6 use threads;
7 use Thread::Queue;
8
9 sub check_num ($upstream, $cur_prime) {
10 my $kid;
11 my $downstream = Thread::Queue->new();
12 while (my $num = $upstream->dequeue()) {
13 next unless ($num % $cur_prime);
14 if ($kid) {
15 $downstream->enqueue($num);
16 } else {
17 print("Found prime: $num\n");
18 $kid = threads->create(\&check_num, $downstream, $num);
19 if (! $kid) {
20 warn("Sorry. Ran out of threads.\n");
21 last;
22 }
23 }
24 }
25 if ($kid) {
26 $downstream->enqueue(undef);
27 $kid->join();
28 }
29 }
30
31 my $stream = Thread::Queue->new(3..1000, undef);
32 check_num($stream, 2);
该程序使用管道模型生成素数。管道中的每个线程都有一个输入队列,用于提供要检查的数字,一个它负责的素数,以及一个输出队列,用于将未通过检查的数字传递到下一个线程。如果线程有一个未通过检查的数字,并且没有子线程,那么该线程必须找到一个新的素数。在这种情况下,将为该素数创建一个新的子线程,并将其添加到管道末端。
这可能听起来比实际情况更令人困惑,所以让我们逐段分析这个程序,看看它做了什么。(对于那些试图回忆什么是素数的人来说,素数是指只能被 1 和自身整除的数。)
大部分工作由 check_num()
子例程完成,该子例程接收一个指向其输入队列的引用以及一个它负责的素数。我们创建一个新的队列(第 11 行)并为我们可能稍后创建的线程保留一个标量(第 10 行)。
从第 12 行到第 24 行的 while 循环从输入队列中获取一个标量,并根据该线程负责的素数进行检查。第 13 行检查当我们将要检查的数字除以我们的素数时是否有余数。如果有余数,则该数字不能被我们的素数整除,因此我们需要将其传递给下一个线程(如果我们已经创建了下一个线程)(第 15 行),或者如果我们还没有创建下一个线程,则创建一个新的线程。
第 18 行创建新的线程。我们将指向我们创建的队列的引用和我们找到的素数传递给它。在第 19 行到第 22 行,我们检查以确保我们的新线程已创建,如果没有创建,我们停止检查队列中剩余的任何数字。
最后,一旦循环终止(因为我们在队列中获得了 0 或 undef
,这表示终止的通知),我们将通知传递给我们的子线程,并在我们创建了子线程的情况下等待它退出(第 25 行和第 28 行)。
同时,回到主线程,我们首先创建一个队列(第 31 行),并将从 3 到 1000 的所有数字以及一个终止通知排队。然后,我们所要做的就是将队列和第一个素数传递给 check_num()
子例程(第 32 行)。
这就是它的工作原理。它非常简单;与许多 Perl 程序一样,解释比程序本身要长得多。
从操作系统的角度来看,线程实现有三种基本类型:用户级线程、内核级线程和多处理器内核级线程。
用户级线程完全存在于程序及其库中。在这种模型中,操作系统不知道线程的存在。从操作系统的角度来看,你的进程只是一个进程。
这是实现线程最简单的方法,也是大多数操作系统最初采用的方式。最大的缺点是,由于操作系统不知道线程,如果一个线程阻塞,所有线程都会阻塞。典型的阻塞活动包括大多数系统调用、大多数 I/O 以及诸如 `sleep()` 之类的操作。
内核级线程是线程演化的下一步。操作系统知道内核级线程,并为它们提供支持。内核级线程和用户级线程之间的主要区别在于阻塞。对于内核级线程,阻塞单个线程不会阻塞其他线程。而对于用户级线程,内核在进程级别阻塞,而不是线程级别。
这是一个巨大的进步,可以使线程程序比非线程程序获得更高的性能提升。例如,执行 I/O 操作而阻塞的线程不会阻塞执行其他操作的线程。但是,每个进程仍然一次只能运行一个线程,无论系统有多少个 CPU。
由于内核线程可以在任何时候中断线程,因此它们会暴露你可能在程序中做出的某些隐式锁定假设。例如,像 `$x = $x + 2` 这样简单的操作在内核线程中可能会表现出不可预测的行为,如果 `$x` 对其他线程可见,因为另一个线程可能在从右侧获取 `$x` 的值和存储新值之间改变了 `$x` 的值。
多处理器内核线程是线程支持的最后一步。在具有多个 CPU 的机器上,使用多处理器内核线程,操作系统可以将两个或多个线程同时调度到不同的 CPU 上运行。
这可以使你的线程程序获得显著的性能提升,因为多个线程将同时执行。但是,作为权衡,任何可能在基本内核线程中没有出现的令人讨厌的同步问题都会以更强烈的形式出现。
除了操作系统对线程的不同参与程度之外,不同的操作系统(以及特定操作系统上不同的线程实现)以不同的方式为线程分配 CPU 周期。
在协作式多任务系统中,运行的线程会在以下两种情况下放弃控制权:如果线程调用了 yield 函数,它会放弃控制权;如果线程执行了会导致它阻塞的操作,例如执行 I/O 操作,它也会放弃控制权。在协作式多任务实现中,如果一个线程愿意,它可以独占 CPU 时间,让其他所有线程都无法获得 CPU 时间。
抢占式多任务系统会定期中断线程,并由系统决定接下来应该运行哪个线程。在抢占式多任务系统中,一个线程通常不会独占 CPU。
在某些系统中,可以同时运行协作式线程和抢占式线程。(例如,以实时优先级运行的线程通常表现为协作式,而以普通优先级运行的线程则表现为抢占式。)
如今,大多数现代操作系统都支持抢占式多任务。
在比较 Perl 的 ithreads 与其他线程模型时,需要牢记的一点是,对于每个新创建的线程,都需要对父线程的所有变量和数据进行完整复制。因此,线程创建的成本可能很高,包括内存使用和创建时间。减少这些成本的理想方法是创建相对较少的长生命周期线程,并且在早期(在基础线程积累过多数据之前)创建所有线程。当然,这可能并不总是可行,因此需要做出妥协。但是,在创建线程后,其性能和额外的内存使用应该与普通代码没有什么区别。
还要注意,在当前实现中,共享变量比普通变量占用更多内存,并且速度稍慢。
请注意,虽然线程本身是独立的执行线程,并且 Perl 数据是线程私有的,除非显式共享,但线程可以影响进程范围的状态,从而影响所有线程。
最常见的例子是使用 chdir()
更改当前工作目录。一个线程调用 chdir()
,所有线程的工作目录都会发生改变。
更极端的进程范围更改示例是 chroot()
:所有线程的根目录都会发生改变,并且任何线程都无法撤销此更改(与 chdir()
相反)。
进程范围更改的其他示例包括 umask()
以及更改 uid 和 gid。
正在考虑混合使用fork()
和线程?请躺下,等感觉过去再起来。请注意,fork()
的语义在不同平台之间有所不同。例如,一些 Unix 系统会将所有当前线程复制到子进程中,而另一些系统只复制调用fork()
的线程。我已经警告过你了!
类似地,混合使用信号和线程也可能存在问题。实现方式依赖于平台,甚至 POSIX 语义也可能与您的预期不符(而且 Perl 甚至没有提供完整的 POSIX API)。例如,无法保证发送到多线程 Perl 应用程序的信号会被任何特定线程拦截。(但是,最近添加的功能确实提供了在线程之间发送信号的能力。有关更多详细信息,请参阅"线程中的线程信号"。)
各种库调用是否线程安全,不在 Perl 的控制范围内。通常,调用不线程安全,包括:localtime()
、gmtime()
、获取用户、组和网络信息的函数(如getgrent()
、gethostent()
、getnetent()
等)、readdir()
、rand()
和srand()
。一般来说,依赖于某些全局外部状态的调用。
如果系统 Perl 在编译时包含了这些调用的线程安全变体,则将使用它们。除此之外,Perl 只能依赖于这些调用的线程安全或不安全。请查阅您的 C 库调用文档。
在某些平台上,如果结果缓冲区太小,线程安全的库接口可能会失败(例如,用户组数据库可能相当大,并且可重入接口可能需要携带这些数据库的完整快照)。Perl 将从一个小的缓冲区开始,但会不断重试并增大结果缓冲区,直到结果适合为止。如果这种无限增长的行为对安全或内存消耗造成问题,您可以重新编译 Perl,并将PERL_REENTRANT_MAXSIZE
定义为允许的最大字节数。
一个完整的线程教程可以写成一本书(而且已经写过很多次了),但通过我们在这篇介绍中涵盖的内容,您应该已经踏上了成为线程化 Perl 专家之路。
threads 的注释 POD:https://web.archive.org/web/20171028020148/http://annocpan.org/?mode=search&field=Module&name=threads
CPAN 上的最新版threads:https://metacpan.org/pod/threads
threads::shared 的注释 POD:https://web.archive.org/web/20171028020148/http://annocpan.org/?mode=search&field=Module&name=threads%3A%3Ashared
CPAN 上最新的 threads::shared 版本:https://metacpan.org/pod/threads::shared
Perl 线程邮件列表:https://lists.perl.org/list/ithreads.html
以下是 Jürgen Christoffel 提供的简短参考文献
Birrell, Andrew D. 线程编程入门。数字设备公司,1989 年,DEC-SRC 研究报告第 35 号,在线版为 https://www.hpl.hp.com/techreports/Compaq-DEC/SRC-RR-35.pdf(强烈推荐)
Robbins, Kay. A. 和 Steven Robbins。实用 Unix 编程:并发、通信和多线程指南。普伦蒂斯·霍尔,1996 年。
Lewis, Bill 和 Daniel J. Berg。使用 Pthreads 的多线程编程。普伦蒂斯·霍尔,1997 年,ISBN 0-13-443698-9(一篇写得很好的线程入门介绍)
Nelson, Greg(编辑)。使用 Modula-3 的系统编程。普伦蒂斯·霍尔,1991 年,ISBN 0-13-590464-1。
Nichols, Bradford、Dick Buttlar 和 Jacqueline Proulx Farrell。Pthreads 编程。O'Reilly & Associates,1996 年,ISBN 156592-115-1(涵盖 POSIX 线程)
Boykin, Joseph、David Kirschen、Alan Langerman 和 Susan LoVerso。在 Mach 下编程。Addison-Wesley,1994 年,ISBN 0-201-52739-1。
Tanenbaum, Andrew S. 分布式操作系统。普伦蒂斯·霍尔,1995 年,ISBN 0-13-219908-4(很棒的教科书)
Silberschatz, Abraham 和 Peter B. Galvin。操作系统概念,第 4 版。Addison-Wesley,1995 年,ISBN 0-201-59292-4
Arnold, Ken 和 James Gosling。Java 编程语言,第 2 版。Addison-Wesley,1998 年,ISBN 0-201-31006-6。
comp.programming.threads 常见问题解答,http://www.serpentine.com/~bos/threads-faq/
Le Sergent, T. 和 B. Berthomieu。“在虚拟共享内存架构上的增量多线程垃圾收集” 摘自 内存管理:国际研讨会 IWMM 92 论文集,法国圣马洛,1992 年 9 月,Yves Bekkers 和 Jacques Cohen 主编。施普林格,1992 年,ISBN 3540-55940-X(现实生活中的线程应用)
Artur Bergman,“巫师不敢踏足的地方”,2002 年 6 月 11 日,https://perldotcom.perl5.cn/pub/a/2002/06/11/threads.html
感谢以下人员(排名不分先后)对本文的校对和润色工作提供的帮助:Chaim Frenkel、Steve Fink、Gurusamy Sarathy、Ilya Zakharevich、Benjamin Sugars、Jürgen Christoffel、Joshua Pritikin 和 Alan Burlison。特别感谢 Tom Christiansen 重写了素数生成器。
Dan Sugalski <[email protected]>
Arthur Bergman 对其进行了轻微修改,以适应新的线程模型/模块。
Jörg Walter <[email protected]> 对其进行了重新整理,使其更简洁地描述了 Perl 代码的线程安全性。
Elizabeth Mattijsen <[email protected]> 对其进行了重新排列,以减少对 yield() 的强调。
本文的原始版本最初发表在《Perl 杂志》第 10 期,版权归 1998 年《Perl 杂志》所有。它是在 Jon Orwant 和《Perl 杂志》的许可下发布的。本文件可以根据与 Perl 本身相同的条款进行分发。