Parallel::ForkManagerの罠

CPANに並列実行を賢く制御してくれるParallel::ForkManagerというモジュールがある. そこそこ使われている有名モジュールっぽいのだけど,軽く嵌まったのでメモ.


Parallel::ForkManagerの使い方

Parallel::ForkManager は,最大同時実行数を制限した並列実行をとっても簡単に実現してくれる.

use Parallel::ForkManager;
    
$pm->Parallel::ForkManager( 4 );  # 子プロセスの最大数を 4 に設定
foreach my $piece ( @list ) {
    $pid = $pm->start and next;   # start は fork と同じく,親なら子のPID, 子なら0を返す
    
    some_processing( $piece );    # 子プロセスが行なう処理を記述
    
    $pm->finish;                  # 子プロセスの終了
}
$pm->wait_all_children;           # 全ての子プロセスの終了を待つ

他にも,子プロセスの開始時・終了時に親が行なう処理を指定できたり(run_on_start / run_on_finish),プロセス数制限のために子プロセスの起動待ちをする際に実行する処理を指定したり (run_on_wait) できる.

嵌まっていた罠

お気楽に書いたコードでも嵌まるときは嵌まる. 以下のような問題に立ちあたってしまった.

Parallel::ForkManagerNet::SCP::Expect を一緒に使うと, $pm->start あるいは $pm->wait_all_children のタイミングでプログラムが無限ループ

因みにParallel::ForkManagerのバージョンは 0.7.5 だ.

なにが原因なのか

Parallel::ForkManagerは内部的に子プロセスのリストを自分で管理している. 簡単に述べると,次のような処理を行っている.

  1. 子プロセスの開始 ($pm->start) 時に子プロセス PID を覚える
  2. 子プロセスの終了を waitpid(-1, 0) で回収し,終了したプロセスの PID を忘れる

自分で子プロセスのリストを管理しているということは, 管理できない方法で子プロセスがスタートしたり,預り知らぬ間に 終了してwaitで回収されたりすると困るということだ.

しかし,実はNet::SCP::Expectforkwaitを使うモジュールであり,子プロセスが無駄にゾンビ化しないように気を配ってくれている. 具体的には,以下のような SIGCHLDハンドラをBEGINブロックで設定している.

$SIG{CHLD} = \&reapChild;
sub reapChild {
    do {} while waitpid(-1, WNOHANG) > 0;
}

SIGCHLDは子プロセスの終了時に親プロセスに届くシグナルであり,ハンドラreapChildで終了したプロセスを看取るループを回している. これは子プロセスを回収するための非常に一般的なやり方であり,Net::SCP::Expect 独自というわけではないが,なんにせよコレが子プロセスが死ぬと真っ先に回収してくれるわけだ.

つまり,この「子プロセスを素早く回収する」シグナルハンドラが設定されることにより, Parallel::ForkManager よりも先にシグナルハンドラが子プロセスを看取ってしまう. よって Parallel::ForkManager の子プロセスリストは正常に管理されなくなり, 既に死んでいる子をいつまでも待ち続けた,というのが今回の問題の原因.

とりあえず今回の問題は以下のコードで乗り切れる.

$sig_chld_handler_backup = $SIG{CHLD};     # SIGCHLD ハンドラを記憶
$SIG{CHLD} = sub {};                       # ForkManager のために SIGCHLD ハンドラを削除
    
foreach my $piece ( @list ) {
    $pid = $pm->start and next;
    
    $SIG{CHLD} = $sig_chld_handler_backup; # 子プロセスにのみ SIGCHLD ハンドラを復帰
    some_processing( $piece );             # 子プロセスが行なう処理を記述
    
    $pm->finish;
}
$pm->wait_all_children;

補足だが,Parallel::ForkManager のマニュアルには「コードの他の部分でfork()/wait()するような環境では使用しないほうが良い」と書かれている. しかし使っているモジュールが内部で何しているかなんてなかなかわからない.