proc_open と stream_select

あいかわらずphpでやんなくていいじゃんな話、なのかな。そうでもないかも。

php.net の proc_open の例にあるように、 proc_open() で子プロセスを起動してstream_get_contents() すると、場合によっては固まったまま動かなくなったりします。「デッドロックを避けるため…」という件がコメントに入ってますが、こういう話が得意でない人にとってはそんなとこ以上にハマりどころがありました。

stdoutとstderrに交互に出力

たとえば次のようなシェルスクリプト test.sh を考えます。

#!/bin/sh
for i in `seq 1 10000`; do
    echo "stdout: $i" >&1
    echo "stderr: $i" >&2
done

単に標準出力と標準エラーに交互に数字を吐き出すだけです。

これをphpから実行するときに、php.netの例を参考に適当に拡張すると、こんな感じ。

<?php
$desc = array(
    1 => array('pipe', 'w'),
    2 => array('pipe', 'w'),
);
$proc = proc_open('sh test.sh', $desc, $pipes);

// stdout
$stdout = stream_get_contents($pipes[1]);
fclose($pipes[1]);

// stderr
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[2]);

proc_close($proc);

echo "-- STDOUT --\n";
echo $stdout;
echo "-- STDERR --\n";
echo $stderr;
?>

ところが、これをそのまま実行すると固まってしまうんですね。

何してるのかとstraceしてみると、

ichii386@host% strace php test.php
read(3, "stdout: 347\nstdout: 348\nstdout: "..., 8192) = 60
read(3, 

というかんじで止まってることが分かります。

これは、 stdout のほうの stream_get_contents() が EOF (というか end of stream) に達する前に stderr のバッファが一杯になって、test.sh の "echo ... >&2" がブロックされてしまうからのようです。じゃあどうするかというと、ちまちま stdout と stderr から交互に読み込みをしなきゃいけない。

この例ではかならず交互に書き込みがあるので交互に読み込めばOKなんですが、どういう順でくるのかわかんないときはどういうタイミングでどれくらいデータを読み込んでおけばいいかがわかりません。

stream_select() と stream_set_blocking()

そこで登場するのが stream_selectstream_set_blocking です。「select()システムコールって何よ」とか詳しいことは man 2 select を見てもらうとして、簡単にまとめると

  • stream_select でどちらか(もしくは両方)のストリームが読み込みできるまでうまいこと待ってくれる
    • 読み込みできるようになった stream を引数に渡した配列に入れて返してくれます。
  • stream_set_blocking でブロックしないようにする
    • とりあえず読み込みしてみて、読めたらOK、ダメだったらすぐに諦める(=0バイト読み込んだことにする)
    • stream_selectがストリーム2つを返したときに、とりあえず配列的に先にあるほうから4096バイト、の途中で止まっちゃわないように。

なんかよくわかんないので以下に例。

<?php

$desc = array(
    1 => array('pipe', 'w'),
    2 => array('pipe', 'w'),
);
$proc = proc_open('sh test.sh', $desc, $pipes);
stream_set_blocking($pipes[1], 0); 
stream_set_blocking($pipes[2], 0); 

$stdout = $stderr = ''; 
while (feof($pipes[1]) === false || feof($pipes[2]) === false) {
    $ret = stream_select(
        $read = array($pipes[1], $pipes[2]),
        $write = null,
        $except = null,
        $timeout = 1 
    );  
    if ($ret === false) {
        echo "error\n";
        break;
    } else if ($ret === 0) {
        echo "timeout\n";
        continue;
    } else {
        foreach ($read as $sock) {
            if ($sock === $pipes[1]) {
                $stdout .= fread($sock, 4096);
            } else if ($sock === $pipes[2]) {
                $stderr .= fread($sock, 4096);
            }   
        }   
    }   
}
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($proc);

echo "-- STDOUT --\n";
echo $stdout;
echo "-- STDERR --\n";
echo $stderr;
?>

おおお!こっちだとサクッと終わってだらだら結果が表示されました。というかほんとにうまく行ってるのか不安になる速さです。

いまは $stdout と $stderr に読み込んだデータを入れて proc_close() してから echo してますが、 fread() のところを直接 echo するように書き換えると、実行する度に微妙にタイミングが違ったりしてちょっと面白い。

細かく見ていくと stream_select() の引数に null じゃなくて "$write = null" と書いているあたりは php 固有の事情とかあるんですが、おおまかな流れとしては

  • stream_set_blocking() でnon-blockingなストリームに
  • stream_select() で読み込みできるまで待つ (timeoutは1秒)
  • 読めるやつを読む。そのときどっちのパイプかに注意
  • 両方とも feof になるまでループ

てなかんじ。

ちなみに resource 型は value.lval に resource id の数字が入っていて、is_identical (===) ならそのまま、is_equal (==) なら convert_scalar_to_number 経由で比較されるようです。

どんな場面?

proc_open で起動したプロセスの入出力がそれなりにある時は、 stream_select とペアで使わないと危ないよ、という話でした。どんなときにこの落とし穴にハマるかというと、たとえば rsync を -avz で起動してこまかいエラーを補足したいときとか。phpをプロセスの起動スクリプトとして使いたいときですね。

これって pcntl_fork みたいに「よくあるパターン」的なコードが流通していいんじゃないのかなぁ。"foreach ($read as $sock)" のあたりとか、もっと洗練したコードが書けるような気がしてなりません。

えらい人たちの話によると select よりも epoll とかの kqueue とかのほうが効率良いけどポータブルじゃないらしい。データがくるのを待ちうけるようなコード書くならそういうことも気にしないといけないでしょうが、今はこれで十分、かな。