12

我有一个并行化的自动化脚本,它需要调用许多其他脚本,其中一些会挂起,因为它们(错误地)等待标准输入或等待各种其他不会发生的事情。这没什么大不了的,因为我抓住了那些惊慌失措的人。诀窍是在子进程关闭时关闭那些挂起的孙进程。我认为 , waiting 和 process 组的各种咒语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;
    }

POSIX相同的事情setsid也不起作用,我认为这实际上以不同的方式破坏了事情,因为我并没有真正将其守护。

奇怪的是,Parallel::ForkManager对于相同的run_on_finish清理代码来说发生得太晚了:孙子显然已经与子进程解除了关联。

4

3 回答 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;

然后我们需要编写一个函数来启动孩子。父代产生的东西称为子代,子代产生的东西称为作业。

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 开始计数,这样如果有 0 个作业,我们的进程仍然会退出。)

现在我们需要为每个作业分叉,并运行该作业。在父级中,我们做一些事情。我们增加 condvar。如果孩子太慢,我们会设置一个计时器来杀死孩子。我们设置了一个子观察者,所以我们可以知道作业的退出状态。

    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
            }

如果您愿意,也可以在此处关闭标准输入。

现在,在所有进程都生成之后,我们通过等待 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

好的,这就是孩子和孙子/工作。现在我们只需要编写父级,这要容易得多。

像孩子一样,我们将使用计数 condvar 来等待我们的孩子。

# 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);

其中两个将超时,两个将成功。但是,如果您在它们运行时按 enter,那么它们可能都会成功。

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

$all_done->recv;

say "...done";

exit 0;

这就是程序。

我们没有做 Parallel::ForkManager 所做的一件事是“限制”我们的分叉,以便一次只有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 中运行子进程并为其提供标准输入(如使用 Expect),您可以捕获其标准输入、标准输出和标准错误,或者您可以忽略那些东西,或者别的什么。你可以决定,而不是一些试图让事情变得“简单”的模块作者。

无论如何,希望这会有所帮助。

于 2010-05-16T00:37:03.847 回答
1

Brian - 这有点粗略且不习惯,但我见过的一种方法是:每当你分叉时,你:

  1. 给子进程一个程序的第一个“-id”虚拟参数,具有一些独特的(每个 PID)值 - 一个好的候选可能是高达毫秒的时间戳 + 父的 PID。

  2. 父级将子 PID 和 -id 值连同所需的超时/终止时间一起记录到(理想情况下是持久的)注册表中。

然后让一个观察者进程(最终祖父进程或具有相同 UID 的单独进程)简单地定期循环通过注册表,并检查哪些进程需要被杀死(根据 to-kill-time)仍然挂起(通过匹配注册表中的 PID 和“-id”参数值以及进程表中的 PID 和命令行);并将信号 9 发送到这样的进程(或者通过尝试发送信号 2 来尝试先轻轻地杀死)。

唯一的“-id”参数显然是为了防止杀死一些偶然重用先前进程的PID的无辜进程,这很可能是因为你提到的规模。

注册表的想法有助于解决“已经分离”的孙子问题,因为您不再依赖系统为您保持父/子关联。

这是一种蛮力,但由于还没有人回答,我想我会按照你的方式来考虑我的 3 美分想法。

于 2010-05-16T00:20:33.407 回答
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是唯一的解决方法吗?

于 2010-05-17T20:52:41.263 回答