【问题标题】:How should I clean up hung grandchild processes when an alarm trips in Perl?当 Perl 中的警报触发时,我应该如何清理挂起的孙子进程?
【发布时间】:2010-05-15 10:54:35
【问题描述】:

我有一个并行化的自动化脚本,它需要调用许多其他脚本,其中一些脚本挂起,因为它们(错误地)等待标准输入或等待各种其他不会发生的事情。这没什么大不了的,因为我用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?
  • 这也可能是个好主意。我必须考虑这一点,尽管我正在考虑用 Expect 之类的东西来处理一些不好的情况,让他们做他们需要做的事情。
  • 见下面的长答案。但基本上,我认为你需要比 Parallel::ForkManager 更多地控制情况,这意味着你必须自己动手。
  • 呵呵,既然我已经解决了大部分问题,那么曾孙进程也是一个问题。这些是千分之几,但现在我一次运行数万个作业,它们开始积累。
  • 你真的可以让system "perl -le '&lt;STDIN&gt;'" 超时吗?我看到它立即挂起产生它的进程,并且 SIGALRM 在进程唤醒之前不会熄灭。

标签: perl unix kill alarm grandchild


【解决方案1】:

我已经读了几次这个问题,我想我有点明白你的意思 正在努力做。你有一个控制脚本。这个脚本产生 孩子做一些事情,这些孩子产生了孙子 实际做这项工作。问题是孙子们可以 太慢(等待 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;

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

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

在孩子中,我们实际上运行作业。这涉及到一点点 不过设置。

首先,我们忘记了父母的孩子观察者,因为它不会使 感觉孩子被告知其兄弟姐妹退出。 (叉是 很有趣,因为你继承了父级的所有状态,即使那样 完全没有意义。)

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

其中两个将超时,两个将成功。如果按回车 不过,当它们在运行时,它们可能都会成功。

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

$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 一样),您可以捕获它的标准输入和标准输出 和stderr,或者你可以忽略这些东西,或者其他什么。你得到 决定,而不是一些试图让事情变得“简单”的模块作者。

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

【讨论】:

  • 另外,您可以将代码剪切并粘贴到脚本中并运行它。删除文本即可。
【解决方案2】:

Brian - 这有点粗俗且不习惯,但我见过的一种方法是:任何时候你分叉,你:

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

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

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

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

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

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

【讨论】:

  • 这是一个创可贴,而不是解决方案。我知道我可以做的蛮力的事情,但我实际上是在尝试解决真正的问题,而不是在程序中创建各种奇怪的耦合。
【解决方案3】:

我必须在模块I've been working on 中解决同样的问题。我对我的所有解决方案也不完全满意,但通常在 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 '&lt;STDIN&gt;'" 时,此解决方案无法处理(无论如何对我而言)这种情况。对我来说,这会立即暂停进程,并阻止 SIGALRM 触发和 SIGALRM 处理程序运行。关闭STDIN 是唯一的解决方法吗?

【讨论】:

  • 这也不适用于我的特殊情况。我必须处理那种 情况,这是我的应用程序中进程阻塞的最常见原因。我目前的想法是打开一个双向管道,然后立即关闭输入(子到孙)。
猜你喜欢
  • 2011-12-12
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2022-10-14
  • 1970-01-01
  • 1970-01-01
  • 2012-05-02
相关资源
最近更新 更多