declare(ticks=1)を宣言していてもシグナルを処理できない事がある

以前からSnidelというPHPで手軽にマルチプロセスで並列処理をするライブラリを作っているのだが、このお盆休みの間にその開発中にハマったことがあったのでブログに残しておく。

はじめに何にハマったのか端的に書くと、declare(ticks=1) を宣言していても子プロセスでtickされず、親から子プロセスに送信したシグナルが処理されないケースがあった。

サンプルコード

まずはちゃんと動くパターンのシンプルなサンプルコードを見ていただきたい。

<?php
declare(ticks=1);

$pid = pcntl_fork();

switch ($pid) {
    case -1:
        die('Failed to pcntl_fork()');
        break;
    case 0:
        /*
         * 子プロセス
         */
        // シグナルを受取ったことをわかりやすくするためにハンドラを設定しておく
        pcntl_signal(
            SIGTERM,
            function ($sig) {
                die('Received a signal: ' . $sig);
            },
            false
        );

        $t = time();
        while (true) {
            // 3秒経過後にドットを出力しつづける
            if ($t + 3 < time()) {
                echo '.';
            }
        }
        exit;
    default:
        /*
         * 親プロセス
         */
        // 5秒待ってから子プロセスを終了する
        sleep(5);
        posix_kill($pid, SIGTERM);
        $status = null;
        pcntl_waitpid($pid, $status);
        break;
}
  • 子プロセス: 3秒経過したらドットを出力しつづける
  • 親プロセス: 5秒後にSIGTERMを子プロセスに送信して終了させる

やっていることは ↑ シンプルで、スクリプトを実行すると下記のように最後に子プロセスがシグナルを受け取った旨が出力されて終了するはず。(※pcntl拡張モジュールが必要)

$ php index.php
...............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................Received a signal: 15

補足: declare(ticks=1) とマルチプロセス

PHP: declare - Manual

declare 文は、あるコードブロックの中に 実行ディレクティブをセットするために使用されます。

PHP: declare - Manual > Ticks

tickとはdeclareブロックの実行中にパーサが N個の低レベル tick 可能な文を実行するごとに 発生するイベントのことです。

マニュアルを読んでもちょっと分かりづらいが declare(ticks=1) を宣言することで(そのスクリプト内の処理において)、1つ1つのステートメントを実行する間に任意の処理を挟む隙間を作ることができるイメージで間違いない(はず)。

(Ticksについてのより詳細(厳密な表現)は下記が大変わかりやすい)
http://ku.ido.nu/post/90223971739/what-is-tick-of-php

イベントという言葉に惑わされて非同期に呼ばれたりするのかと思いましたが、ticksの正体は単純に N opcodes ごとに実行されるopcodeなのでした。

tickごとに実行する関数は register_tick_function で登録できるが、サンプルコードではそれはやっていない。ではなぜ declare(ticks=1) を宣言しているかというと、 シグナルをハンドリングするための仕組みとしてTicksを利用している からで、なので逆にこれを宣言していないといくら親プロセスからシグナルを送信しても子プロセスはそれを受取ってくれない。

関数リファレンス > プロセス制御 > PCNTL > はじめに

現在 PCNTL はシグナルハンドルコールバックの仕組みとして ticks を 使用しており、これは以前の仕組みよりずっと高速です。

declare(ticks=1) を宣言していてもシグナルを処理できない事がある

では本題のハマったことに話を移すと、サンプルコードの子プロセスのwhileループの中を下記のように書き換えると declare(ticks=1) を宣言しているにも関わらず子プロセスがシグナルを受取らずいつまで経ってもスクリプトが終了しなくなる。

        while (true) {
            // 3秒経過後にドットを出力しつづける
            // if ($t + 3 < time()) {
            if (false) {
                echo '.';
            }
        }

原因がわからず小一時間悩んでいたのだがPHPマニュアルにしっかり書いてあった。

PHP: declare - Manual > Ticks

すべての文が tick 可能なわけではありません。 たとえば条件式や引数式などは tick できません。

上記、書き換え後のスクリプトでは、whileループの中のif文の評価結果が常にfalseになるので、 処理のステートメントは進み続けている(ループが回っている)が、全て条件式なのでtickされていなかった 。これにより親プロセスが送信したシグナルを受付ける隙間が無くなってしまいスクリプトが終了しなくなっていた。

サンプルコードでは分かりやすくするために不自然なコードになっているが、例えば Snidel では下記のような感じのコードがあって、

        while (true) {
            // キューからジョブを取り出す.
            // dequeue() は一定時間ポーリングした後、ジョブが無ければ null を返す.
            if ($job = dequeue()) {
                $job->run();
            }
        }

この場合は 「ジョブが無くて待機中だとシグナルを受付けない」 「ジョブを捌いてるときは受付ける」 みたいなことになって、しかも 「 if文の後ろにデバッグコードを入れると普通にシグナルを受付ける 」 みたいな挙動になるので完全に迷宮入りしていた…。

        while (true) {
            if (false) {
                echo '.';
            }
            // デバッグコードを入れるとココでtickする
            $logger->debug('デバッグコードを入れるとシグナルを受付けるようになるからめっちゃ混乱したよ!');
        }

おわりに

丁寧に書いてあるPHPマニュアルに感謝!