分支,是的!

最近我在工作中需要加速处理文件的Perl脚本。Perl可以使用fork
函数来生成多个进程,但如果不正确管理子进程,事情可能会出错。我给脚本添加了分支功能,并能够将脚本的吞吐量提高近10倍,但正确实现它花费了我一些时间。在这篇文章中,我将向您展示如何安全地使用fork
并避免一些常见的错误。
注意。Windows用户:由于Windows上不可用fork
系统调用,以下示例可能无法按描述工作,因为行为被Perl模拟。
一个简单的示例
#!/usr/bin/perl
my $pid = fork;
# now two processes are executing
if ($pid == 0) {
sleep 1;
exit;
}
waitpid $pid, 0;
此脚本使用fork
创建一个子进程,它将子进程的进程ID返回给父进程,并将0返回给(新创建的)子进程。此时,有两个进程正在执行代码的其余部分,父进程和子进程。只有子进程才会为if ($pid == 0)
条件返回true,从而执行if块。if块只是简单地休眠1秒,然后调用exit
函数使子进程终止。同时,父进程跳过了if
块并调用waitpid
,它将不会在子进程退出之前返回。
注意。我可以用任何我想要的任意处理来替换sleep
调用,但sleep是一个很好的替代品,因为它使得分析程序更容易。
这是一个如此简单的示例,它可能出错的地方在哪里?好吧,首先,如果机器没有足够的空闲内存,fork
调用可能会失败。因此,我们需要检查这种条件。
#!/usr/bin/perl
my $pid = fork;
die "failed to fork: $!" unless defined $pid;
# now two processes are executing
if ($pid == 0) {
sleep 1;
exit;
}
waitpid $pid, 0;
我在其中插入了一个条件die
语句,如果fork
失败,则抛出。但这里有没有更深层次的问题?如果我们不是休眠一秒钟,而是子进程调用一个立即返回的函数,会发生什么?我们可能会在父进程和子进程之间出现竞争——如果子进程在父进程调用waitpid
之前退出,会发生什么?
认为操作系统可能会为不同的程序重用子进程的进程ID并不合理,我们的父进程可能会突然等待任意进程退出。这绝对不是我们想要的!
幸运的是,这不是一个风险:当子进程退出时,操作系统不允许在父进程调用wait
(或waitpid
)之前回收其资源,这将“收获”子进程。其次,waitpid
只适用于调用进程的子进程:如果我将一个完全分离的进程的PID传递给它,waitpid
将立即返回-1。
多个工作者
从并发角度来看,简单的示例并不是很好。它只生成一个子进程,我们无法通过重新编写代码来使用额外的进程进行扩展。这是我的新版本
#!/usr/bin/perl
my $max_workers = shift || 1;
for (1..$max_workers) {
my $pid = fork;
die "failed to fork: $!" unless defined $pid;
next if $pid;
sleep 1;
exit;
}
my $kid;
do {
$kid = waitpid -1, 0;
} while ($kid > 0);
此脚本读取工作者的数量参数,或默认为1。然后它使用$max_workers
数量生成子进程。注意,next if $pid
导致父进程跳转到下一个循环迭代,在那里它反复生成工作者,直到退出循环。同时,子进程休眠1秒并退出。
因此,当子进程处于休眠状态时,父进程必须等待它们。不幸的是,现在我们不仅要监控一个子进程的 $pid
,所以应该将哪个值传递给 waitpid
?幸运的是,waitpid 提供了一个快捷方式,我可以将 -1
作为进程 ID 传递,它将阻塞直到 任何 子进程退出,并返回退出子进程的 PID。因此,我将这个操作封装在一个 do..while
循环中,该循环将反复调用 waitpid
,直到它返回 -1 或零,这两个值都表示没有更多子进程需要回收。
与简单的示例相比,这段代码更好,因为它可以扩展到任意数量的工作子进程。但它包含(至少)两个问题。
想象一下,我们用 5 个工作进程运行这个脚本,可能 fork
调用会失败,因为机器内存不足。然后父进程将调用 die
打印错误并退出,但这会留下几个仍在运行的子进程,没有父进程。这些变成了僵尸进程,父进程 ID 为 1(init),它会对它们调用 wait 并清理。
第二个问题与使用 waitpid -1, 0
捕获任何退出子进程有关。想象一下这个脚本由一个包装程序运行,该程序捕获其输出并将其流式传输到另一个进程。包装程序会孵化一个子进程,该子进程将流式传输脚本的输出,然后它将在自己的父进程中执行脚本,这实际上是在脚本中注入了一个子进程。这将导致我的脚本永久挂起,因为注入的子进程不会在脚本完成之前退出。
多个工作进程,再次
#!/usr/bin/perl
use strict;
use warnings;
$SIG{INT} = $SIG{TERM} = sub { exit };
my $max_workers = shift || 1;
my $parent_pid = "$$";
my @children;
for (1..$max_workers) {
my $pid = fork;
if (!defined $pid) {
warn "failed to fork: $!";
kill 'TERM', @children;
exit;
}
elsif ($pid) {
push @children, $pid;
next;
}
sleep 1;
exit;
}
wait_children();
sub wait_children {
while (scalar @children) {
my $pid = $children[0];
my $kid = waitpid $pid, 0;
warn "Reaped $pid ($kid)\n";
shift @children;
}
}
END {
if ($parent_pid == $$) {
wait_children();
}
}
这是我的多个工作进程脚本的改进版本。我添加了对 INT(按键盘上的 Ctrl-C)和 TERM 的信号处理程序,这将导致 Perl 清洁退出。如果 fork
失败,父进程将向所有子进程发送 TERM 信号,然后自己退出。我认为如果 fork
失败,机器可能内存不足,OOM 杀手可能不远了,所以有序关闭比让进程遭到(进程)收割者的不测之死要好。
wait_children
子程序对父进程孵化出的 PID 执行阻塞等待调用。这避免了等待非脚本本身创建的子进程的问题。请注意,它不会在回收成功之前从 @children
中删除任何元素。这避免了以下错误:脚本开始运行,父进程孵化子进程并移动 @children
,启动阻塞的 waitpid
调用,然后接收 INT/TERM 信号,这会导致 wait_children
立即返回,然后再次在 END
块中调用,但 @children
中将缺少一个 PID,并成为僵尸进程。
END
块在所有进程退出时触发。如果退出的进程是父进程,它将再次调用 wait_children
来清理任何驻留的子进程。在真实世界™ 脚本中,如果工作进程执行的操作不仅仅是 sleep
,这可能是在子进程中添加任何其他清理操作的好地方;例如删除创建的任何临时文件。
总结
Perl 使得编写并发代码变得容易,同时也容易出错。如果你不担心 fork
失败,我建议使用 Parallel::ForkManager,它有一个很好的接口,为你跟踪创建的 PID,并为子进程提供数据共享机制。
如果你在编写并发 Perl 时遇到困难,请在运行代码时使用
$ strace -e process,signal /path/to/your/program
这样你就可以确切地看到子进程何时退出以及正在发送哪些信号。
标签
反馈
这篇文章有什么问题吗?请通过在GitHub上打开一个issue或pull request来帮助我们。
- More commenting... maybe?
github.polettix.it - Perl Weekly Challenge 121: Invert Bit
blogs.perl.org - Web nostalgia: MojoX::Mechanize
github.polettix.it - On the eve of CPAN Testers
blogs.perl.org - PWC121 - The Travelling Salesman
github.polettix.it - PWC121 - Invert Bit
github.polettix.it - Floyd-Warshall algorithm implementations
github.polettix.it - Perl Weekly Challenge 120: Swap Odd/Even Bits and Clock Angle
blogs.perl.org - How I Uploaded a CPAN Module
blogs.perl.org - App::Easer released on CPAN
github.polettix.it