突然ですがPHPのFiber/Fibersってご存知でしょうか?
言うまでもなくPHPというプログラムは、基本的に上から下に向かって記述された順に処理が行われます。
途中で下から上に戻ることや、条件式に従って途中でループを離脱することはありますが、その動作は同期的であって、何らかの応答を待つ場合はその場でプログラムは待機します。
つまり概ねシングルスレッドで逐次処理なわけです。
当たり前ですが、ここで ごっつ重たい処理(自分の場合は画像変換)を走らせると非常に遅い です。
それでいてPHPが動作するサーバーのCPUリソースは余っています。
(ここまではPHP初心者がある程度PHPを書けるようになった時の感想です)
さて、PHPは古い言語なので、この同期的でシングルスレッドなプログラムを非同期的に動かす知見がインターネットの各所に転がっています。
代表的なのは 「バッチ処理をexecでバックグラウンドに投げる」 だと思います。
しかしこの方法だとバックグラウンドに投げられた処理の終了をPHP側から知るには、投げられた処理の方でログなりデータベースなりにその旨を記録してやる必要があります。
またこの方法を素直にそのままやってしまうと、バックグラウンドに投げられた処理の数がサーバーの同時処理可能な数を超えた場合、応答性が著しく低下してしまいます。
あるいは、各種PHP系フレームワークを利用することで実現できます。
が、偏屈なことに定評のある 私めとしては、用意したWebサーバーに新たにフレームワークをインストールするのは正直御免蒙りたいというのが本音です。これ以上管理する項目を増やさないでくれ(切実)。
というわけで、PHPで非同期的にタスクを実行できて、尚且つ適度なスレッド数に制御する知見はないものかと探しておりました。
素晴らしい紹介記事 が見つかりました。圧倒的感謝。
あとは海外の記事ですが、Fiber/Fibersを使ったサンプルコードも見つかりました。
要約すると
「タスクの実行を一括して保留しておける」
「タスクの実行を一斉に発火させることができる(言い方)」
「タスクの実行数が一定を超えた場合は一時待機し、タスクが掃けた(であろう時間が経過した後に)タスク実行数を1からにリセットして再度実行数が一定を超えるまでタスクを連続的に発火させることが出来る
まさにそれだよ。おじさんこういうのでいいんだよおじさんになっちゃう……。
では実践してみましょう
筆者はPHPで大量の画像をバッチ処理するプログラムを書いています(ニッチな需要)。
筆者の職場には色んな画像データが持ち込まれます。
JPG、PNG、BMPに始まり、PSD、AI、HEIC、RAW、WebPなんかはまあ可愛い方です。
拡張子がJPGなのに中身がJavaScriptだとか(Webブラウザでしか正常に表示できない)、ICCプロファイルが2じゃないとか、普通のJPGの範囲外の色が記録されたものとか(いわゆるHDR)、受付可能なのはRGB24bitだって言ってるのにCMYKだとか(以下怨嗟の声なので省略)
これを普通の(Windows7あたりの古い)PCでも正常に表示可能な、ごくごく普通のJPGファイルに変換します。(CMYKで入稿したのが)色味が変わった?知らんがな(´・ω・`)
PHPで覚えた(書いた)のは単に学習コストが安かったからです。あと個人的にApacheもnginxも嫌なので、OpenLiteSpeedのPHPが使いたかった。
というわけで早速Fiberを使ってループ処理を書いてみたのが以下のコードになります(抜粋)。
//サムネ生成
foreach ($generatelist as $index => $geninfo) {
$quelist[$index] = new Fiber(exec(...));
}
threads($generatelist, $quelist);
sleep(10);
function threads(array $generatelist, array $quelist) {
$waitlist = [];
while (count($quelist) > 0) {
$ique = 1;
while (0 < $ique && $ique <= (int)THREADS) {
foreach ($generatelist as $index => $geninfo) {
foreach ($quelist as $que => $cue) {
if ($que === $index) {
$command = 'php '
.escapeshellarg(realpath(SDOG4Q.'imagegen.php')).' '
.escapeshellarg(str_replace('../../', HOMEDIR, $geninfo['origin'].$geninfo['order'])).' '
.escapeshellarg($geninfo['prtch']).' '
.escapeshellarg($geninfo['prtch_w']).' '
.escapeshellarg(str_replace('../../', HOMEDIR, $geninfo['printfile'])).' >/dev/null 2>&1 &';
$cue->start($command);
$waitlist[$que] = $cue;
$ique++;
if ($ique > (int)THREADS) {
$randomsleep = rand(2,5);
sleep($randomsleep);
$ique = 1;
}
unset($que);
unset($index);
}
}
}
return $ique;
}
foreach ($waitlist as $q9ue => $c9ue) {
usleep(1000);
if ($c9ue->isTerminated()) {
unset($q9ue);
}
if ($c9ue->isSuspended()) {
$c9ue->resume();
}
}
$quelist = $waitlist;
}
sleep(2);
return $quelist;
}
ばっと見たら何やってるか分かりませんよね。順を追ってみていきましょう。
foreach ($generatelist as $index => $geninfo) {
$quelist[$index] = new Fiber(exec(...));
}
この段では、この前段階で生成した配列「generatelist」を展開してインデックス名だけを抜き出し、配列「quelist」のインデックス名に投入しています。
そしてそのインデックス名に対応する値として「new Fiber(exec(...))」を格納しました。
従来のやり方だとここでは値には素直にexec(引数)が与えられると思うのですが、ここではまだ実行を保留したいので値は「new Fiber(exec(...))」と引数は「...」として実行を保留します。
これが配列「generatelist」の中身の数分繰り返され配列「quelist」が生成されます。
このForeach文はここで終了します。
threads($generatelist, $quelist);
はい次の段でいきなり自作関数に配列「generatelist」と「quelist」を与えて実行しています。何が何だかわからないと思いますので後ろに書いている自作関数の内容に触れましょう。
PHP文では通常、上から下に向かって順次内容が実行されますが、実際にはPHPプログラムの実行前にPHP文の全体をPHPサーバー側が査読し、自作関数だけはキャッチアップして先にメモリに格納してしまう……という挙動をする様です(自分なりの解釈なので間違っていたらすみません)。
なので自作関数の記述位置より前に自作関数を使う箇所が発生していても、プログラム的には既に先んじて読み込まれているものですので実行の際には矛盾が生じずにそのまま実行されます。
自作関数が何度も呼び出されるような場合、PHPサーバーは何度も自作関数の記述位置をPHPプログラムの中から検索しなくてはなりませんから、先に自作関数が書かれている部分だけはメモリに格納しておく、という挙動は理に適っていると思います。
直感には反しますが。
function threads(array $generatelist, array $quelist) {
$waitlist = [];
while (count($quelist) > 0) {
$ique = 1;
while (0 < $ique && $ique <= (int)THREADS) {
foreach ($generatelist as $index => $geninfo) {
foreach ($quelist as $que => $cue) {
if ($que === $index) {
$command = 'php '
.escapeshellarg(realpath(SDOG4Q.'imagegen.php')).' '
.escapeshellarg(str_replace('../../', HOMEDIR, $geninfo['origin'].$geninfo['order'])).' '
.escapeshellarg($geninfo['prtch']).' '
.escapeshellarg($geninfo['prtch_w']).' '
.escapeshellarg(str_replace('../../', HOMEDIR, $geninfo['printfile'])).' >/dev/null 2>&1 &';
$cue->start($command);
$waitlist[$que] = $cue;
$ique++;
if ($ique > (int)THREADS) {
$randomsleep = rand(2,5);
sleep($randomsleep);
$ique = 1;
}
unset($que);
unset($index);
}
}
}
return $ique;
}
foreach ($waitlist as $q9ue => $c9ue) {
usleep(1000);
if ($c9ue->isTerminated()) {
unset($q9ue);
}
if ($c9ue->isSuspended()) {
$c9ue->resume();
}
}
$quelist = $waitlist;
}
sleep(2);
return $quelist;
}
自作関数だけを抜き出してみました。引数に「generatelist」「quelist」という配列を要求します。
内容はこうです。
quelistが0になるまでの間(1個目のwhile)
変数「ique」が1以上かつ固定値THREADS(※サーバーの論理CPU数の半分に設定しています)以内の間(2個目のwhile)は配列「generatelist」を展開し、その中で変数「quelist」を展開する
互いのインデックス値が等しい場合にのみ、配列「generatelist」のキー「geninfo」を使った変数「command」を生成し、配列「quelist」のキー「cue(※前段で生成した変数Fiber)」の引数として与え実行を開始する
開始したキー「cue」は配列「waitlist」に格納し、変数「ique」はプラス1する
変数「ique」が固定値THREADSより大きくなった場合は、2〜5秒の範囲でランダムに実行を抑止した後、変数「ique」を1に戻す。未満の場合は続行する
配列「quelist」から値「que」を、「generatelist」から値「index」を削除する
配列「generatelist」をループし終わったら、変数「ique」を返して2個目のwhile文を離脱する
配列「waitlist」を展開し、1000マイクロ秒実行を抑止した後、キー「c9ue(※2個目のwhile文の中で実行を開始した後配列waitlistに格納されたキーcueのことです)」の現在の状態を調べる。
もし完了している(c9ue->isTerminated())場合、配列「waitlist」から値「q9ue」は削除して、foreachの次のループに入る。
もし実行が保留されている(c9ue->isSuspended())場合は開始する(c9ue->resume())
配列「waitlist」のループが一巡したら、配列「quelist」の内容を配列「waitlist」の残余に変更する
1個目のwhileの末尾に到達したので、配列「quelist」に格納された値の数が0より大きいか評価し、大きい場合は2回目のループに入る。
中身がない(=0)の場合は1個目のwhileは終了し、2秒待機した後に配列「quelist(※空の配列)」を戻り値として返し、自作関数の実行を終了する
大体こんなところでしょうか。
厳密に言うと「resume()」はカーソル位置をFiberが開始された位置(「cue->start(command)」)に戻しているらしいのですが、話がややこしくなり過ぎるので実際の動きの見た目に絞って流れを書いてみました。
ここで変数「command」を目敏く見ていた方は、「execに渡す引数に「>/dev/null 2>&1 &」が含まれてるじゃんexecの終了検知できないじゃん!」ということに気付かれるかと思います。
そうです。実はFiber文を作っている最中に気付いたのですが、
標準出力で実行結果を取得する様にすると、Fiberと言えども非同期処理にならずに同期処理となって、実行結果が得られるまで次の実行を保留してしまう
のです。
飽く迄もFiberはPHPサーバーのカーソル位置を任意の位置(->start(command)が書かれている位置)に戻す制御方法なのであって、引数に「実行結果が返ってくるまで待て」と書かれていたら「待ちます!」になってしまうのです。
なので実際には「>/dev/null 2>&1 &」を与えて実行を開始した時点で「実行完了」とFiber側に認識させて、変数「ique」の加減で同時実行数を制御し、規定数に到達した場合は次の実行をランダムな秒数(標準的なデータに於いて概ね処理が完了する時間分)だけ保留する、という運用になっています(そうせざるを得なかった)。
「じゃあForeachで変数commandを生成する度にexec投げて、変数「ique」を加減して制御した方が可読性も向上して楽だったのでは?」というツッコミは至極真っ当だと思います。
でも何故かそのやり方で書くと上手く期待通りに動いてはくれなかったんですよ……(自分の能力の低さが露呈してしまう)。
総括
Fiberによって非同期処理を習得した
Fiberは飽く迄も「非同期的」なのであって、書き方次第では同期処理になってしまう
FiberはどちらかというとPHPフレームワーク向けに書かれた機能なのであろうかと推察
以上、自分への戒めとして、備忘録も兼ねて「Fiber/Fibersを使ってみた記録」を綴ってみました。