当Perl中的警报触发时,我应该如何清理挂起的孙进程?

12
我有一个并行自动化脚本,需要调用许多其他脚本,其中一些会挂起,因为它们(不正确地)等待标准输入或等待其他各种不会发生的事情。这并不是大问题,因为我可以使用alarm来捕捉它们。关键是在子进程关闭时关闭那些挂起的孙子进程。我认为各种SIGCHLD、等待和进程组的咒语都可以解决问题,但它们都会阻塞,并且孙子进程没有被回收。
我的解决方案可行,但似乎不是正确的解决方案。我现在尚不特别关心Windows解决方案,但最终我也需要它。我的解决方案仅适用于Unix,现在还可以。
我编写了一个小脚本,它需要运行的同时并行子进程的数量和总分叉数:
 $ fork_bomb <parallel jobs> <number of forks>

 $ fork_bomb 8 500

这个操作很可能会在几分钟内达到每个用户进程的限制。我找到的许多解决方案都只是告诉你增加每个用户进程限制,但我需要运行大约300,000次,所以这种方法不可行。同样,建议重新执行等来清除进程表并不是我需要的。我想真正解决问题,而不是临时修补它。
我遍历进程表查找子进程,并在SIGALRM处理程序中单独关闭挂起的进程,该处理程序需要死掉,因为其余的真实代码在那之后没有成功的希望。在性能方面,笨拙的遍历进程表并不困扰我,但我也不介意不这样做:
use Parallel::ForkManager;
use Proc::ProcessTable;

my $pm = Parallel::ForkManager->new( $ARGV[0] );

my $alarm_sub = sub {
        kill 9,
            map  { $_->{pid} }
            grep { $_->{ppid} == $$ }
            @{ Proc::ProcessTable->new->table }; 

        die "Alarm rang for $$!\n";
        };

foreach ( 0 .. $ARGV[1] ) 
    {
    print ".";
    print "\n" unless $count++ % 50;

    my $pid = $pm->start and next; 

    local $SIG{ALRM} = $alarm_sub;

    eval {
        alarm( 2 );
        system "$^X -le '<STDIN>'"; # this will hang
        alarm( 0 );
        };

    $pm->finish;
    }

如果你想结束进程,可以使用kill命令。我曾尝试过设置进程组以便一次性结束所有进程,但这会阻塞:
my $alarm_sub = sub {
        kill 9, -$$;    # blocks here
        die "Alarm rang for $$!\n";
        };

foreach ( 0 .. $ARGV[1] ) 
    {
    print ".";
    print "\n" unless $count++ % 50;

    my $pid = $pm->start and next; 
    setpgrp(0, 0);

    local $SIG{ALRM} = $alarm_sub;

    eval {
        alarm( 2 );
        system "$^X -le '<STDIN>'"; # this will hang
        alarm( 0 );
        };

    $pm->finish;
    }

同样的事情在POSIXsetsid上也没有起作用,我认为这实际上以不同的方式破坏了事情,因为我并没有真正地将其变成守护进程。

有趣的是,Parallel::ForkManagerrun_on_finish对于相同的清理代码来说太晚了:孙子进程在那个时候已经与子进程分离了。


你为什么不关闭子进程中的 STDIN? - jrockway
那也许是个好主意。我得考虑一下,尽管我正在考虑使用类似Expect的东西来处理一些糟糕的情况,以便让它们完成需要做的事情。 - brian d foy
基本上,我认为你需要比Parallel::ForkManager更多的控制权,这意味着你必须自己编写代码。 - jrockway
哦,现在我已经解决了大部分问题,但是孙子进程也成为了一个问题。它们是千分之一的部分,但是现在我一次运行数万个作业,它们开始累积了。 - brian d foy
你真的可以让 system "perl -le '<STDIN>'" 超时吗?我发现它会立即挂起生成它的进程,直到进程唤醒后 SIGALRM 才会被发送。 - mob
闹钟对我来说运行良好。调用过程继续执行,关闭并离开分离的孙子进程。这就是它如何填充进程表。 - brian d foy
3个回答

8
我已经读了几遍这个问题,我认为我有点明白您想做什么。您有一个控制脚本。这个脚本生成子进程来做一些事情,这些子进程生成孙子进程来实际完成工作。问题是孙子进程可能太慢(等待STDIN之类的),您想杀死它们。此外,如果有一个缓慢的孙子进程,您希望整个子进程都死掉(如果可能的话杀死其他孙子进程)。
因此,我尝试用两种方法实现这个功能。第一种方法是让父进程在一个新的UNIX会话中生成一个子进程,设置一个几秒钟的定时器,当定时器响起时杀死整个子进程。这使得父进程对子进程和孙子进程都负责。但这并没有正常工作。
下一个策略是让父进程生成子进程,然后让子进程负责管理孙子进程。它将为每个孙子进程设置一个定时器,并在过期时间内如果该进程未退出则杀死它。这个方法非常好,所以这里是代码。
我们将使用EV来管理子进程和定时器,使用AnyEvent来进行API调用。(您可以尝试另一个AnyEvent事件循环,如Event或POE。但我知道EV正确处理了子进程在您告诉循环监视它之前退出的情况,这消除了其他循环容易受到的烦人的竞态条件。)
#!/usr/bin/env perl

use strict;
use warnings;
use feature ':5.10';

use AnyEvent;
use EV; # you need EV for the best child-handling abilities

我们需要跟踪子监视器:
# active child watchers
my %children;

接下来我们需要编写一个函数来启动子进程。父进程创建的子进程被称为“children”,而子进程创建的任务被称为“jobs”。

sub start_child($$@) {
    my ($on_success, $on_error, @jobs) = @_;

这些参数包括:一个回调函数,用于在子进程成功完成时被调用(这意味着它的工作也是成功的),一个回调函数,用于在子进程未能成功完成时被调用,以及要运行的coderef任务列表。

在此函数中,我们需要进行分叉。在父进程中,我们设置了一个子进程监视器来监视子进程:

    if(my $pid = fork){ # parent
        # monitor the child process, inform our callback of error or success
        say "$$: Starting child process $pid";
        $children{$pid} = AnyEvent->child( pid => $pid, cb => sub {
            my ($pid, $status) = @_;
            delete $children{$pid};

            say "$$: Child $pid exited with status $status";
            if($status == 0){
                $on_success->($pid);
            }
            else {
                $on_error->($pid);
            }
        });
    }

在子进程中,我们实际上运行任务。但这需要一些设置。
首先,我们忽略父进程的子进程监视器,因为让子进程知道其兄弟进程退出是没有意义的。(fork很有趣,因为即使这完全没有意义,你也会继承父进程的所有状态。)
    else { # child
        # kill the inherited child watchers
        %children = ();
        my %timers;

我们还需知道所有任务何时完成,以及它们是否全部成功。我们使用计数条件变量来确定何时所有任务都已退出。我们在启动时增加计数,在退出时减少计数,当计数为0时,我们就知道所有任务都已完成。
我还保留了一个布尔值来指示错误状态。如果进程以非零状态退出,则错误状态为1。否则,它保持为0。您可能希望保留比这更多的状态 :)
        # then start the kids
        my $done = AnyEvent->condvar;
        my $error = 0;

        $done->begin;

我们从1开始计数,这样如果没有作业,我们的进程仍然会退出。

现在我们需要为每个作业分叉并运行该作业。在父进程中,我们做了一些事情。我们增加条件变量。我们设置一个定时器来杀死孩子,如果它太慢了。并且我们设置了一个子监视器,以便我们可以被告知作业的退出状态。

    for my $job (@jobs) {
            if(my $pid = fork){
                say "[c] $$: starting job $job in $pid";
                $done->begin;

                # this is the timer that will kill the slow children
                $timers{$pid} = AnyEvent->timer( after => 3, interval => 0, cb => sub {
                    delete $timers{$pid};

                    say "[c] $$: Killing $pid: too slow";
                    kill 9, $pid;
                });

                # this monitors the children and cancels the timer if
                # it exits soon enough
                $children{$pid} = AnyEvent->child( pid => $pid, cb => sub {
                    my ($pid, $status) = @_;
                    delete $timers{$pid};
                    delete $children{$pid};

                    say "[c] [j] $$: job $pid exited with status $status";
                    $error ||= ($status != 0);
                    $done->end;
                });
            }

使用计时器比闹钟稍微容易一些,因为它带有状态。每个计时器都知道要杀死哪个进程,当进程成功退出时取消计时器也很容易——我们只需从哈希表中删除它。

这是父进程(的子进程)。子进程(或作业)非常简单:

            else {
                # run kid
                $job->();
                exit 0; # just in case
            }

如果你想关闭stdin,你也可以在这里关闭。

现在,当所有进程都被生成后,我们通过等待condvar来等待它们全部退出。事件循环将监视子进程和定时器,并为我们执行正确的操作:

        } # this is the end of the for @jobs loop
        $done->end;

        # block until all children have exited
        $done->recv;

然后,当所有孩子都离开时,我们可以做任何我们想要的清理工作,比如:

        if($error){
            say "[c] $$: One of your children died.";
            exit 1;
        }
        else {
            say "[c] $$: All jobs completed successfully.";
            exit 0;
        }
    } # end of "else { # child"
} # end of start_child

好的,现在我们需要翻译的是关于IT技术方面的内容。这段文字讲述了如何编写父进程,相较于子进程和孙子进程/任务,编写父进程要容易得多。

和子进程一样,我们将使用计数条件变量来等待子进程完成。

# main program
my $all_done = AnyEvent->condvar;

我们需要完成一些工作。这里有一个任务总是成功的,还有一个任务如果您按回车键将会成功,但如果您让它被定时器杀死,那么它将失败。
my $good_grandchild = sub {
    exit 0;
};

my $bad_grandchild = sub {
    my $line = <STDIN>;
    exit 0;
};

那么我们只需要启动子作业。如果你还记得在 start_child 顶部的位置,它需要两个回调函数,一个错误回调和一个成功回调。我们将设置这些回调函数; 错误回调将打印“not ok”并将 condvar 减去,而成功回调将打印“ok”并执行相同的操作。非常简单。

my $ok  = sub { $all_done->end; say "$$: $_[0] ok" };
my $nok = sub { $all_done->end; say "$$: $_[0] not ok" };

然后我们可以启动一系列的工作,包括更多的子任务和孙任务:

say "starting...";

$all_done->begin for 1..4;
start_child $ok, $nok, ($good_grandchild, $good_grandchild, $good_grandchild);
start_child $ok, $nok, ($good_grandchild, $good_grandchild, $bad_grandchild);
start_child $ok, $nok, ($bad_grandchild, $bad_grandchild, $bad_grandchild);
start_child $ok, $nok, ($good_grandchild, $good_grandchild, $good_grandchild, $good_grandchild);

其中两个将超时,两个将成功。如果您在它们运行时按下回车键,则它们可能全部成功。

无论如何,一旦它们开始,我们只需要等待它们完成:

$all_done->recv;

say "...done";

exit 0;

这就是整个程序的内容。

我们没有像Parallel::ForkManager那样对fork进行“速率限制”,以使得每次只有n个子进程在运行。不过,手动实现这一点也很容易:

 use Coro;
 use AnyEvent::Subprocess; # better abstraction than manually
                           # forking and making watchers
 use Coro::Semaphore;

 my $job = AnyEvent::Subprocess->new(
    on_completion => sub {}, # replace later
    code          => sub { the child process };
 )

 my $rate_limit = Coro::Semaphore->new(3); # 3 procs at a time

 my @coros = map { async {
     my $guard = $rate_limit->guard;
     $job->clone( on_completion => Coro::rouse_cb )->run($_);
     Coro::rouse_wait;
 }} ({ args => 'for first job' }, { args => 'for second job' }, ... );

 # this waits for all jobs to complete
 my @results = map { $_->join } @coros;

这里的优势是您可以在子进程运行时做其他事情 - 只需要使用async生成更多的线程,然后进行阻塞连接。使用AnyEvent::Subprocess,您可以对子进程进行更多控制 - 您可以在Pty中运行子进程并提供stdin(如Expect),并且可以捕获其stdin、stdout和stderr,或者可以忽略这些内容,或者任何其他想法。您可以自行决定,而不是一些试图使事情 "简单化 "的模块作者。希望这可以帮到您。

另外,您可以将代码复制并粘贴到脚本中运行。只需删除文本即可。 - jrockway

1
Brian - 这种方法有点粗糙和不符惯用语,但我见过的一种方法是:每次你分叉时,你需要:
  1. 给子进程一个第一个“-id”虚拟参数,其中包含一个相对唯一(每个PID)的值 - 一个好的选择可能是毫秒级时间戳+父进程的PID。

  2. 父进程将子PID和-id值记录到(理想情况下是持久的)注册表中,并设置所需的超时/终止时间。

然后,有一个监视进程(可以是最终祖先或具有相同UID的单独进程),定期循环遍历注册表,并检查哪些需要被杀死的进程(根据终止时间)仍然存在(通过在进程表中匹配PID和“-id”参数值以及PID和命令行),并向这些进程发送信号9(或者试图先尝试发送信号2来轻松地杀死它们)。

显然,唯一的“-id”参数旨在防止意外杀死某些无辜的进程,因为它们恰好重用了之前某个进程的PID,考虑到你提到的规模,这种情况可能很常见。

注册表的概念有助于解决“已经取消关联”的孙子问题,因为您不再依赖系统为您保留父/子关联。

这有点蛮力,但由于还没有人回答,我想我会把我的三分钱的想法传达给你。


这只是一个临时措施,而不是解决方案。我知道有一些蛮力方法可以做到,但我实际上正在尝试找到真正的问题,而不会在程序中创建各种奇怪的耦合。 - brian d foy

0

我必须在一个我一直在工作的模块中解决这个相同的问题。我对我的所有解决方案都不完全满意,但通常在Unix上可以使用以下方法:

  1. 更改子进程组
  2. 根据需要生成孙进程
  3. 再次更改子进程组(例如,返回到其原始值)
  4. 向孙进程组发送信号以杀死孙进程

类似于:

use Time::HiRes qw(sleep);

sub be_sleepy { sleep 2 ** (5 * rand()) }
$SIGINT = 2;

for (0 .. $ARGV[1]) {
    print ".";
    print "\n" unless ++$count % 50;
    if (fork() == 0) {   
        # a child process
        # $ORIGINAL_PGRP and $NEW_PGRP should be global or package or object level vars
        $ORIGINAL_PGRP = getpgrp(0);
        setpgrp(0, $$);
        $NEW_PGRP = getpgrp(0);

        local $SIG{ALRM} = sub {
            kill_grandchildren();
            die "$$ timed out\n";
        };

        eval {
            alarm 2;
            while (rand() < 0.5) {
                if (fork() == 0) {
                    be_sleepy();
                }
            }
            be_sleepy();
            alarm 0;
            kill_grandchildren();
        };

        exit 0;
    }
}

sub kill_grandchildren {
    setpgrp(0, $ORIGINAL_PGRP);
    kill -$SIGINT, $NEW_PGRP;   # or  kill $SIGINT, -$NEW_PGRP
}

这并不是完全可靠的。孙子进程可能会更改它们的进程组或捕获信号。

当然,所有这些都不适用于Windows,但让我们说TASKKILL /F /T是你的好朋友。


更新: 这个解决方案(对我来说)无法处理子进程调用 system "perl -le '<STDIN>'" 的情况。对我而言,这会立即挂起进程,并防止 SIGALRM 触发和 SIGALRM 处理程序运行。关闭 STDIN 是唯一的解决方法吗?


这对我的特殊情况也不起作用。我必须处理< STDIN >案例,这是我应用程序中进程阻塞的最常见原因。我目前对此的想法是打开一个双向管道,然后立即关闭输入(从子进程到孙进程)。 - brian d foy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接