分支,是的!

最近我在工作中需要加速处理文件的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

这样你就可以确切地看到子进程何时退出以及正在发送哪些信号。

标签

David Farrell

David是一位专业的程序员,他经常在推特博客上分享关于代码和编程艺术的见解。

浏览他们的文章

反馈

这篇文章有什么问题吗?请通过在GitHub上打开一个issue或pull request来帮助我们。