ワーカーモード
ワーカーモードは、一度だけブートストラップしてその後多数のリクエストを処理する永続的な PHP ワーカーを実行します。そのため、PHP の起動コストはリクエストごとにではなく一度だけ支払われます。リクエストごとに PHP の状態を破棄して再構築するのではなく、アプリケーションはオートローダー、設定、データベース接続を一度だけ読み込み、ワーカーの生存期間を通じてそれらを再利用します。
仕組み
- ワーカーモードを有効化します。
WORKER_MODE_ENABLED=trueを設定し、ENTRY_FILEをブートストラップスクリプトに向けます。これにより、プール内のすべての PHP ワーカーでワーカーモードが有効になります。 - 一度だけブートストラップします。 PHP が起動し、外側のスコープを一度だけ実行します。オートローダーの登録、設定の読み込み、データベース接続、その他の初期化コードは一度だけ実行されます。
- リクエストループに入ります。
oxphp_worker(callback)を呼び出します。OxPHP は受信した HTTP リクエストをコールバックへディスパッチし始めます。 - リクエスト間でリセットします。 スーパーグローバル(
$_GET、$_POST、$_SERVER、$_COOKIE、$_FILES、$_REQUEST、php://input)、出力バッファ、レスポンスヘッダー、リクエストが開いたセッション、そしてリクエストが変更した ini ディレクティブは自動的にリセットされます。何がカバーされ、どこで止まるかについてはリセットされるものと維持されるものを参照してください。ソフトリセットは、外側のスコープでブートストラップされたリソースを保持しつつ、リクエストごとの状態をクリーンアップします。$_ENVは意図的な例外です。PHP のデフォルトであるauto_globals_jit=1の下では、リセットされません(スーパーグローバルを参照)。 - 外側のスコープは維持されます。
oxphp_worker()より前に定義された変数、静的プロパティ、データベース接続、オートローダーは、そのワーカーが処理するすべてのリクエストにわたって利用可能なまま残ります。
ワーカーモードはルーティングの挙動を変更します。ディスク上の静的ファイルにマッチしないすべてのリクエストは、404 を返す代わりにワーカーへディスパッチされます。詳細は ルーティング を参照してください。
設定
| Variable | Default | Description |
|---|---|---|
WORKER_MODE_ENABLED |
false |
永続的なワーカーモードを有効にします。true、1、yes を受け付けます。ENTRY_FILE が .php スクリプトを指している必要があります |
ENTRY_FILE |
(未設定) | ワーカーのブートストラップスクリプトへのパス。相対パスの場合は DOCUMENT_ROOT を基準に解決されます。.. セグメントと絶対パスが許可されます(公開ドキュメントルートの外に置かれたワーカーブートストラップはサポートされているレイアウトです) |
WORKER_MAX_MEMORY_MIB |
0 |
リサイクル前のワーカーごとの最大 PHP メモリ量(MiB 単位)。0 = 無制限 |
レガシーの WORKER_FILE 変数は引き続きパースされ(起動時に WARN が出ます)、WORKER_MODE_ENABLED=true ENTRY_FILE=$WORKER_FILE と同じように振る舞います。新しいデプロイでは明示的なペアを使用してください。レガシーの形式は将来のリリースで削除されます。
アプリケーション駆動のリサイクルには、リクエストハンドラーの内部から OxPHP\Server\Worker::scheduleExit() を呼び出します。ワーカーは現在のリクエストが完了した後にクリーンに終了します。
ワーカースクリプトの記述
ワーカースクリプトは 2 つの部分から構成されます。起動時に一度だけ実行される外側のスコープと、リクエストごとに実行される oxphp_worker() に渡されるコールバックです。
<?php
// Outer scope: runs once at startup
require __DIR__ . '/../vendor/autoload.php';
$config = parse_ini_file(__DIR__ . '/../config/app.ini');
$db = new PDO($config['dsn'], $config['user'], $config['pass'], [
PDO::ATTR_PERSISTENT => true,
]);
$app = new MyApp\Application($config, $db);
// Request loop: runs for every request
oxphp_worker(function () use ($app) {
$app->handle();
});
// Teardown: runs when this worker's loop ends, which is not
// only at server shutdown — see Recycling below
$app->terminate();リセットされるものと維持されるもの
OxPHP はリクエスト間でソフトリセットを実行します。リクエストごとの状態は自動的にクリーンアップされ、外側のスコープでブートストラップされたものはワーカーの生存期間を通じて残ります。
- スーパーグローバル —
$_GET、$_POST、$_SERVER、$_COOKIE、$_FILES、$_REQUEST、php://inputは新しいリクエストデータで再投入されます - 出力バッファ — すべての出力バッファがフラッシュされ、クリーンアップされます
- レスポンスヘッダー — HTTP ステータスコードとヘッダーはデフォルトにリセットされます
- エラー状態 — 直近のエラー情報(メッセージ、ファイル、行、タイプ)と接続ステータスがクリアされます。ユーザーが登録したエラーハンドラー(
set_error_handler())と例外ハンドラー(set_exception_handler())はリクエスト間で維持されます - セッション状態 — リクエストが開いたセッションはワーカーの次のリクエストの前に閉じられ、
$_SESSIONはクリアされます。境界については後述します - リクエストが変更した ini ディレクティブ —
ini_set()、ignore_user_abort()、error_reporting()による変更はそのリクエストに属し、リクエストの終了時にブートストラップ時のベースラインへロールバックされます。境界については後述します
- 外側のスコープの変数 —
oxphp_worker()より前に定義され、useでキャプチャされたすべてのもの - 静的プロパティ — クラスの静的プロパティはその値を保持します
- データベース接続 — PDO、MySQLi、その他の永続的な接続は開いたままになります
- オートローダー — 登録されたオートローダー(Composer、カスタム)は有効なまま残ります
- 読み込み済みのクラスと関数 — 以前に読み込まれたすべてのクラス、インターフェース、トレイト、関数
- ブートストラップで設定された ini ディレクティブ — 外側のスコープでの
ini_set()はワーカーの生存期間を通じて保持され、リクエストごとの変更がロールバックする先になります $_ENV— 意図的にリセットされません(PHP のデフォルトであるauto_globals_jit=1の場合)。これはリクエストではなくワーカーを表すものです。そのため、.envローダーが書き込んだ値はそれを書き込んだブートの後も生き続け、リクエストハンドラー内で行われた書き込みも同じだけ生き続けます。そのワーカー上の後続のすべてのリクエストと、それと並行して多重化されるすべてのリクエストに対してです。スーパーグローバルを参照してください
セッション状態
リクエストが開いたセッションは、ワーカーが次のリクエストを受け取る前に閉じられます。データはセーブハンドラーに渡され、セッション ID は解放され、$_SESSION はシンボルテーブルから取り除かれます。そのため次のリクエストは自身の Cookie の下でセッションを開始し、それまでは session_id() から '' を読み取り、$_SESSION は未定義のままです。知っておく価値のある境界が 3 つあります。
- 別のリクエストがまだ中にいるセッションは、それを開いたリクエストが戻ったときではなく、ワーカーが次にアイドルになった時点で閉じられます。 ワーカー上の他のどのリクエストも中にいないセッションは、リクエストの終了時に閉じられます。シャットダウン関数の後、ini の変更が元に戻される前です。これは PHP 自身のリクエストシャットダウンと同じ順序なので、書き込みはそのリクエストの設定の下で実行されます。共有されているセッションは、スレッドが返却される時点でスレッドが持っている設定の下で書き込まれます。それまでワーカーはそのセッションを開いたまま保持し、セーブハンドラーはロックしたものを保持し続けます。デフォルトの
filesハンドラーでは、それはセッションファイルに対する排他的かつブロッキングなロックです。同じセッション ID に対するリクエストが他の場所(別のワーカー、別のプロセス)に届くとセーブハンドラーの中で待機し、その待機はワーカースレッド上のブロッキングなシステムコールなので、そのワーカーが抱えているすべてのリクエストを停滞させます。セッションデータが確定したらsession_write_close()を呼び出してください。そうすれば、書き込みとロック解除の両方がアプリケーションの置いた場所で行われます。 - セッションはリクエストではなくワーカースレッドに属するため、1 つのワーカー上で重なり合うリクエストはそれを共有します。 ワーカーは、実行中のリクエストが中断するたびに(
oxphp_async_await()、oxphp_sleep()、oxphp_usleep()、あるいはRUNTIME_HOOKS下のソケット待機で。ソケット待機には読み取りが含まれ、netカテゴリではさらに接続、TLS ハンドシェイク、空きを待つ必要がある書き込みも含まれます。接続にはnew PDO('mysql:…')やnew mysqli(...)も含まれます)新しいリクエストを受け取ります。そして、抱えているいずれかのリクエストがまだ中にいる可能性がある間は、セッションを返却できません。その期間に受け取られたリクエストには、代わりにそのセッションが渡されます。session_start()は新しいリクエストの Cookie を見ずにtrueを返し、$_SESSIONは一時停止中のリクエストの配列です。session_write_close()でも両者は分離されません。セッションは閉じますが ID はインストールされたまま残るため、その期間に受け取られたリクエストはその ID でセッションを開始します。一時停止中のリクエストのデータをストアから読み取り、同じ ID を自身のSet-Cookieで受け取ることになります。アプリケーションがセッションを使い、かつリクエストの中断を許す場合、頼るべき分離は、中断ポイントをまたいでセッションを開いたままにしないことです。 - セッションが現れる前にすでに一時停止していたリクエストは、その中にいるとは扱われません。 共有は、リクエストが受け取られた時点から数えられます。セッションを開始したリクエストと、セッションが存在する間に受け取られたすべてのリクエストがその中にいて、ワーカーはそのいずれかが生きている限りセッションを保持します。それより前に中断したリクエストは含まれません。そうでなければ、セッションに一切触れない長寿命のリクエスト 1 つのせいで、ワーカーがどのセッションも返却できなくなってしまいます。そのようなリクエストでも目覚めた後に
$_SESSIONを読むことはでき、読み取るのはその時点で存在しているもので、それは別のクライアントのものである可能性があります。自身でセッションを開始していないリクエストでは、中断ポイントの後に$_SESSIONを読まないでください。
oxphp_async_await()、oxphp_sleep()、フックされたソケット待機の前に、session_write_close() でセッションを閉じてください。中断ポイントをまたいで開いたままのセッションは、ワーカーが次に受け取るリクエストと共有されます。
ini のロールバック
リクエストが ini_set()、ignore_user_abort()、error_reporting() で変更したものは、PHP-FPM 下と同じようにそのリクエストに属し、リクエストの終了時に元へ戻されます。ほとんどのディレクティブは、リクエストが await、スリープ、ソケット待機で一時停止している間もそのリクエスト自身のものです。ワーカーから取り外されるため、その間にワーカーが実行するリクエストにはベースラインが見え、一時停止していたリクエストは自身の値で再開します。ある HTTP 呼び出しを囲む ini_set('default_socket_timeout', 5) も、デバッグ用の分岐での ini_set('display_errors', '1') も、それを行ったリクエストにのみ適用され、次のリクエストには適用されません。
復元先のベースラインは php.ini の値ではなく、ブートストラップが設定したものです。外側のスコープでの ini_set() はアプリケーション設定であり、すべてのリクエストを生き延び、すべてのリクエストはその値で始まります。これには ignore_user_abort(true) も含まれます。ブートストラップで呼び出すと、ストリームも含め、ワーカーが処理するすべてのリクエストに適用されます(Server-Sent Eventsを参照)。知っておく価値のある境界が 7 つあります。
memory_limitとsession.*設定は、ワーカーが他のリクエストを処理中の間はワーカーに留まります。 ヒープはリクエストではなくワーカースレッドに属し、session.*設定が構成するセッションも同様です。session_set_save_handler()がインストールするセーブハンドラーもこれに含まれます。これらはリクエストが一時停止してもワーカーから取り外されず、ワーカーが他に何も実行していない状態で次のリクエストを受け取るときにのみ元へ戻されます。ワーカーに処理中の作業がある間(リクエストが一時停止しているときは常に、またファイア・アンド・フォーゲットの Promise がまだ回収されていない間も)、あるリクエストがここで設定した値は、その期間にワーカーが受け取るリクエストにも適用されます。set_time_limit()は呼び出したリクエストの制限を設定しますが、ini_get('max_execution_time')が報告するのはワーカーの値です。 各リクエストはそれぞれ独自の時間制限を持ち(リクエストのデッドラインを参照)、set_time_limit()、あるいはmax_execution_timeに対するini_set()やini_restore()が変更するのはその制限だけで、他の制限には影響しません。ディレクティブ自体はmemory_limitと同じくワーカーに留まります。ini_get('max_execution_time')は、ワーカーが最後に処理中の作業を持たなかった時点以降にいずれかのリクエストが設定した最後の値を返します(どのリクエストも設定していなければブートストラップの値です)。複数のリクエストを抱えるワーカーでは、それが呼び出し元自身の制限であるとは限りません。ディレクティブは、ワーカーが他に何も実行していない状態で次のリクエストを受け取るときに元へ戻されます。- 値を保存する以上のことを行うハンドラーを持つディレクティブは、そのリクエストが一時停止している間もワーカーに留まります。 ディレクティブを移動するとそのハンドラーが実行され、ハンドラーの中には保存と同時に動作するものがあります。
default_charsetとエンコーディング設定は mbstring のエンコーディングをリセットし、zlib.output_compressionは出力ハンドラーを開始し、open_basedirは相対パスをカレントディレクトリに対して解決し、非推奨のassert.*設定はそのたびに非推奨の警告を発します。一時停止と再開のたびに実行されれば、その間にリクエストが行ったことを取り消してしまうため、これらはワーカーに留まります。あるリクエストが一時停止している間にワーカーが実行するリクエストには、一時停止中のリクエストの値が見え、ディレクティブは処理中の最後のリクエストが終了したときに元へ戻されます。移動されるのは PHP の標準セッターのいずれかで保存されるディレクティブで、ignore_user_abort、precision、display_errors、error_reporting、default_socket_timeout、user_agentをはじめとするほとんどのものです。検証しかしないのにワーカーに留まるものもいくつかあります。OPcache が有効な間のinclude_path(set_include_path()を含む。OPcache がオフなら移動されます)、error_log、date.timezone、default_mimetypeです。リクエストが ini ディレクティブではなく関数を通じて設定するもの(mb_internal_encoding()、date_default_timezone_set()、setlocale())は、そもそも ini の変更ではありません。それはワーカーに属し、リクエストが元に戻さない限りリクエストより長く生き続けます。 - ハンドラーが元に戻すことを拒否するディレクティブは、リクエストが残した状態のままになります。 そのようなディレクティブは、ワーカーが他に何も実行していない状態で次にリクエストを受け取るまでリクエストの値を保持し、再開時に変更を拒否された一時停止中のリクエストは、その変更なしで処理を続けます。
- ディレクティブの 1 つが移動されている最中に終了したリクエストは、ワーカーをリサイクルさせます。 ハンドラーはアプリケーションコードを実行することがあります。
assert.callbackを元に戻すとコールバックが破棄され、それとともにコールバックが保持していたものも破棄され、そのデストラクタがそこで実行されます。その中のいずれかからリクエストを終了させるもの(致命的エラー、あるいはクライアントがすでに去ったストリームへの出力)は、ハンドラーを途中で離脱させ、コールバックは設定されたまま、半分解体されたクロージャを指した状態になります。これを正せるのは新しいワーカーだけなので、ワーカーはoxphp_worker_recycles_by_reason_total{reason="scheduled"}の下で終了し、そのワーカー上でまだ処理中のリクエストは、他のリサイクルと同様に終了させられます。リクエスト自体はその終わり方に従って記録されます。致命的エラーは500で応答されてエラーブレーカーにカウントされ、去ったクライアントはキャンセルとなりカウントされません。そのようなデストラクタが投げた例外は、代わりにリクエストの未捕捉例外として報告され、それ以上のコストはかかりません。 memory_limitは、リミットとして復元される前に、まず値として復元されます。 PHP は、新しい上限を超えるメモリがまだ保持されている間、アロケーターの上限を下げることを拒否します。そのため、リクエストが割り当てたものを抱えたままのワーカーは、ini_get()では復元後のmemory_limitを報告する一方で、アロケーターは引き上げられた方を強制し続けます。ワーカー自身のフットプリントに余裕ができ次第、上限も追従します。opcache.enableは一切復元されません。 リクエストが OPcache に対してできるのはオフにすることだけであり(PHP はリクエストの途中で再びオンに切り替えることを拒否します)、それを再び立ち上げるのは OPcache 自身のリクエストごとの起動処理ですが、ワーカーはそれを起動時に一度しか実行しません。したがって、リクエストが OPcache をオフにしたワーカーは、その後の生存期間中すべてのファイルをソースからコンパイルし続け、ディレクティブはそれを示すために0と読めるまま残されます。復元してしまうと、ini_get('opcache.enable')とopcache_get_status()が、動いていないキャッシュを有効と報告することになります — 何かを判断するために問い合わせるアプリケーションが、実際に起きていることと逆を告げられることになるため、そちらの方が悪い選択です。
リサイクル
リサイクルされたワーカーはループが終了し、その PHP 状態は破棄されます。新しいワーカーが代わりに入るかどうかは、後述するようにプール次第です。ワーカーは、以下のいずれかの条件が満たされると自動的にリサイクルされます。
- 最大メモリ超過 — ワーカーの PHP メモリ使用量が
WORKER_MAX_MEMORY_MIBMiB を超えた場合 - アプリケーションによる終了要求 — ハンドラーが
Worker::scheduleExit()を呼び出した場合。アプリケーション制御のホットリロード、ファイルの mtime に基づくリロード、リクエストごとのブートストラップ再実行などに役立ちます - 連続エラー — ワーカーが 3 回連続で崩壊したリクエストを受け取った場合。ここで言う崩壊とは、致命的エラー、メモリ不足、スタックオーバーフロー、あるいはリクエストが実行されるファイバーにまつわる 2 種類の失敗のいずれかです。3 回連続するということは、個々のリクエストではなくワーカーに問題があることを示しており、崩壊を繰り返すワーカーは再び取り繕われるのではなく置き換えられます。何がカウントされ、何がカウントされないかは以下を参照してください
- 内部関数の中で終了したリクエストが多くのものを残した — 内部関数の中でリクエストを終了させる致命的エラーやキャンセル(たとえば
max_execution_timeの経過)は、その関数が自身のために割り当てたものを置き去りにし、ワーカーはそれを返却できません。usort()は配列のコピーをソートし、そのコピーがすべての要素を生かし続けます。ワーカーは、処理中のリクエストがない 2 つの時点の間でメモリ使用量を比較します。その間に内部関数の呼び出しをスタック上に残したまま終了したリクエストがあり、使用量が 4 MiB を超えて増加していた場合、ワーカーはリサイクルされ、その理由を示す警告がログに記録されます。curl_exec()のような内部関数が戻った直後に終了したリクエストも同様にカウントされます。時間制限は、その呼び出しがまだスタック上にある間に検知されるからです。その間にワーカーが作成したファイバーのスタックはカウントされません。ワーカーはそれらを生存期間中ずっと保持するからです。循環参照コレクターだけが解放できるガベージは先に回収されますが、対象はコレクターがすでに把握していたものに限られます。ベイルアウト後のクリーンアップによって初めて解放された循環はコレクターに渡されることがなく、割り当てられたまま残ってカウントされ、ワーカーをリサイクルさせます。その時点で処理中のリクエストはないため、これによって終了させられるリクエストはなく、リサイクルはoxphp_worker_recycles_by_reason_total{reason="scheduled"}の下でカウントされます。これはWORKER_MAX_MEMORY_MIBには依存しません。決め手は呼び出しではなく増加量なので、同じ期間にワーカーのメモリを増やした他のものもカウントされます。初めて満たされたキャッシュや、まだ解放されていない大きなリクエストボディなどです。これが検出しないものは次のとおりです。約 4 MiB 未満のリーク(WORKER_MAX_MEMORY_MIBを超えるまで、あるいはそれが設定されていなければリクエストが PHP のmemory_limitに引っかかって失敗し始めるまで蓄積します)。ワーカーが最初に忙しくなる期間でのリーク(これは判定されません。判定すると、新しいワーカーが最初のリクエストで自身のために構築するものによって、タイムアウトが続く間、代わりのワーカーが次々とリサイクルされてしまうからです)。サーバーのシャットダウン中のすべて。そして、処理中のリクエストがない時点が一度も訪れないワーカー(チェックはそのような最初の時点まで待ちます) - メモリ不足エラーが
memory_limitを超える使用量を残した — PHP はAllowed memory size … exhaustedのレポートを、上限をいったん脇に置いて組み立てます。レポート自体にもメモリが必要だからです。PHP 8.5 では、そのレポートにスタック上のすべてのフレームのバックトレースが含まれます(fatal_error_backtraces、デフォルトで有効)。暴走した再帰の後では、このバックトレースが上限そのものより大きくなることがあります。memory_limit=128Mの下で 400,000 段の深さの再帰を行うと、約 290 MiB が使用中としてカウントされたまま残りました。アロケーターが上限を超える量を使用中とカウントしている間は、ワーカー上のどのリクエストに対しても何も拒否しません。PHP-FPM はリクエストをシャットダウンすることでそのメモリを取り戻します。ワーカーはリクエストをシャットダウンしないため、失敗したリクエストが終了する時点(そのシャットダウン関数の後)でメモリを返却し、上限を再び有効にします。それらのシャットダウン関数のいずれかが中断している間(oxphp_async_await()、oxphp_sleep()/oxphp_usleep()、あるいはRUNTIME_HOOKSが引き受けたsleep()、usleep()、ソケット待機で)にワーカーが処理するリクエストも、上限なしで実行されます。それでもなおワーカーが保持している量が上限を超える場合(たとえばそのリクエストのシャットダウン関数がそれだけのメモリを保持し続けた場合)、ワーカーは処理中のリクエストがない次の時点でリサイクルされ、その理由を示す警告がログに記録され、oxphp_worker_recycles_by_reason_total{reason="scheduled"}の下でカウントされます。それまでにワーカーが処理するリクエストはmemory_limitが有効でない状態で、原則としてまったく上限なしで実行されます。上記のチェックとは異なり、こちらはワーカーの最初のリクエストから適用されますが、サーバーのシャットダウン中は適用されません。非同期プールのスレッドは、失敗したタスクの終了時にメモリを返却し、それで足りない場合は後続の各タスクの終了時に再試行します。スレッドはリサイクルされません。fatal_error_backtraces=0を設定するとバックトレースが省かれ、それとともにレポートが消費する量の大部分もなくなります
失敗したリクエストのすべてがカウントされるわけではありません。すべての失敗が、ワーカーが処理に不適格になったことを意味するわけではないからです。
| 結果 | カウントへの影響 |
|---|---|
| リクエストハンドラー、シャットダウン関数、またはそれらのシャットダウン関数が解放される際に実行されるデストラクタで発生した致命的エラー、メモリ不足、スタックオーバーフロー | カウントされる |
| リクエストが実行されるファイバーが開始できなかった:エンジンがその C スタックを割り当てられず、ハンドラーは一度も実行されなかった | カウントされる |
| リクエストがユーザーランドからそのファイバーを中断し続けるのをやめず、サーバーがそれを終了させた | カウントされる |
未捕捉の例外(500 で応答)— リクエストハンドラーからでも、シャットダウン関数からでも |
中立 |
キャンセルされたリクエスト:max_execution_time が経過した、サーバーがシャットダウン中、または ignore_user_abort(true) を呼び出していないストリーミングレスポンスのクライアントが切断した |
中立 |
リクエストの完了(exit()/die() を含む) |
カウントをクリアする |
「中立」とは文字どおりの意味です。致命的エラーの連続の途中にこれらのいずれかが挟まっても、カウントを増やすこともクリアすることもないため、fatal, exception, fatal, fatal でもワーカーはリサイクルされます。シャットダウン関数での失敗は、リクエストハンドラーでの失敗と同じように読み取られます。PHP はシャットダウン関数を独自の保護下で実行するため、ワーカーからはその中で失敗したリクエストも正常に戻ってきたように見えます。そこでの致命的エラーもリクエストが自ら崩壊したことに変わりはないため同じようにカウントされ、そこでの例外はハンドラーからの例外と同じくクリーンに巻き戻るため同じように中立です。
カウントは、リクエストが何を残したかではなく、なぜ終了したかによって決まります。カウントが答えるのはこのワーカーに問題があるかどうかであり、サーバー自身が打ち切ったリクエストはワーカーに不利な証拠にはなりません。そのためキャンセルは、ハンドラー内で起きても(レンダリング中にデッドラインが切れた、サーバーがシャットダウン中)、シャットダウン関数内で起きても、リクエストがそれ以前に致命的エラーに達していなければ中立です。すでに崩壊したハンドラーのシャットダウン関数でデッドラインが切れた場合、その失敗はカウントされたままです。サーバーのシャットダウンは無条件に中立となる唯一のキャンセルです。終了に向かっているワーカーは評価の対象ではないからです。
ストリーミングレスポンス以外では、ワーカーモードにおいてクライアントの切断はそれ自体で独立した結果にはなりません。切断はリクエストを終了させず、リクエストは最後まで処理して自身のクリーンアップを実行でき、その後リクエストはその終わり方によって読み取られます。最後まで到達すればカウントはクリアされ、致命的エラーはカウントされ、未捕捉の例外は中立です。これは意図的なものです。ハンドラーを最後まで実行したリクエストは、レスポンスを読む相手が残っていたかどうかに関係なく、ワーカーが処理できることの証拠です。また、すべてのリクエストで致命的エラーを起こすワーカーはこのリクエストでも致命的エラーを起こすため、去っていくクライアントがそのようなワーカーを生き延びさせることはできません。
ファイバーに関する 2 つの行がカウントされる理由は、致命的エラーと同じです。リクエストは自身の処理の終わりに到達せず、かつサーバーがそれを打ち切ったわけでもありません。ファイバーは、エンジンがその実行に使う C スタックを割り当てられないと開始されず(fiber.stack_size がプラットフォームの最小値である 2 メモリページ未満に設定されている、あるいはプロセスがそれ以上メモリをマップできない)、ハンドラーには到達しません。もう 1 つはアプリケーション自身が原因です。サーバーはリクエストのファイバーを自ら駆動するため、そのファイバー上でのユーザーランドの Fiber::suspend() は FiberError で拒否され、それをキャッチしてループで再試行するハンドラーは、無期限に再試行することを許されずに終了させられます。この終了はユーザーランドではキャッチできませんが、それでもリクエストの finally ブロック、デストラクタ、シャットダウン関数は実行され、リクエストが生成したものはハンドラーが設定したステータスで送信されます。カウントされた失敗の WARN 行には、2 つのうちどちらだったかが記されます。fiber would not start または fiber refused to park です。
これらのいずれも行わないのは、失敗したのではなく固まってしまったワーカーの診断です。システムコールで動けなくなったリクエストは、キャンセルもカウントもされず、オペレーターが対処できるように oxphp_worker_stuck_total を通じて報告されます。サーバー内のどの仕組みもそのようなリクエストを自動的に終了させることはない(リサイクルもキャンセルもない)ため、固まったワーカーは呼び出しが戻るかプロセスが再起動するまで固まったままです。max_execution_time もその歯止めにはなりません。時間制限がリクエストを終了させられるのは PHP コードが実行される場所だけであり、システムコールで動けなくなったリクエストは、その呼び出しが戻るまでそこに到達しないからです(リクエストのデッドラインを参照)。
ワーカーがリサイクルされると、そのループは終了し、代わりのワーカーがワーカースクリプトの外側のスコープを再実行します。ワーカーは単一の OxPHP プロセス内の OS スレッドであり、別々のプロセスではないため、オペレーティングシステムから見て再起動するものは何もなく、getmypid() はリサイクルの前後を通じてすべてのワーカーで同じ値を返します。代わりのワーカーがすぐに現れるかどうかはプール次第です。
- 静的プール(
PHP_WORKERS=N)は次のスキャンでNまで補充されるため、置き換えは 1 対 1 です。 - 動的プール(
PHP_WORKERS=MIN:MAX)はMINまでしか補充されません。最小値を上回る状態でリサイクルされたワーカーの分だけプールは 1 つ小さくなり、通常のスケールアップで再び拡大するまでそのままです。
メモリベースおよびスケジュールされた終了の場合、ワーカーが終了する前に現在のリクエストが正常に完了します。エラーベースのリサイクルの場合、ワーカーは失敗したリクエストの後に終了します。
メモリベースまたはスケジュールされたリサイクルは INFO でログに記録されます。ワーカーは Worker mode thread recycled を書き込み、reason には oxphp_worker_recycles_by_reason_total がそのリサイクルをカウントするラベルが設定されます。max_memory の場合は memory_bytes(上限を超えた使用量)と max_memory_bytes(上限)が併せて記録され、scheduled は Worker::scheduleExit() と、ワーカーが自身でスケジュールしたリサイクルを対象とします。続いてプールが Recycled workers detected を記録します。連続エラーの後にリサイクルされたワーカーは WARN で Worker mode thread retired after consecutive request failures として記録され、プールはそれを、自ら選ばずに終了した他のワーカースレッドと同様に Dead workers detected, respawning として報告します。
したがって LOG_LEVEL=warn では、WORKER_MAX_MEMORY_MIB や Worker::scheduleExit() によるリサイクルは、それ自体のログ行を残しません。メモリで頻繁にリサイクルされるワーカーは追跡する価値のあるリークであり、それを確認する場所は oxphp_worker_recycles_by_reason_total{reason="max_memory"} のレートです。
動的プールがアイドルなワーカーをリタイアさせる場合(動的プールを参照)も、そのワーカーのループは終了し、oxphp_worker() の後のコードも実行されますが、ワーカーを置き換えるのではなくプールを縮小します。代わりに何も生成されず、oxphp_worker_recycles_total も oxphp_worker_recycles_by_reason_total も変化せず、代わりに oxphp_workers_retired_total がそれをカウントします。したがって、oxphp_worker() の後に置かれたアプリケーションの終了処理は、正常に稼働中のプールでも実行されます(oxphp_worker() を参照)。
同じワーカーが並行して処理していた他のリクエスト — oxphp_async_await() や oxphp_sleep()、あるいは RUNTIME_HOOKS 下のソケット待機で中断していたもの — は、最後まで実行されません。それぞれ中断していた場所でキャンセルされ、自身のシャットダウン関数を実行した後、Retry-After 付きの 503 Service Unavailable で応答されます。したがってリサイクルは、たまたま処理中だったリクエストのクライアントから見えるものであり、並行リクエストを処理するワーカーで WORKER_MAX_MEMORY_MIB を選んだり scheduleExit() を呼び出したりする際に知っておく価値があります。サーバー全体のシャットダウンは異なります。そこでは、処理中のリクエストはドレイン期間を与えられ、正常に完了できます。
リクエストのデッドライン
ワーカーが処理するすべてのリクエストは、ワーカーが他に何も実行していない状態で受け取ったか、他のリクエストを抱えている間に受け取ったかにかかわらず、それぞれ独自に max_execution_time で制限されます。ファイバー多重化ではワーカーは 1 つのスレッド上で複数のリクエストを実行しますが、それでも各リクエストは自身の制限をまるごと受け取り、それ以上は受け取りません。
- リクエストの制限は、ワーカーがそれを受け取った時点から数えられます。 初期値は、ワーカーのブートストラップが残した
max_execution_timeの値(php.iniの値、またはワーカースクリプトの外側のスコープが設定した値)です。OxPHP のスレッドセーフな PHP ビルドではどこでもそうであるように、これはウォールクロック時間を数え、中断していた時間も含まれます(タイムアウトを参照)。 set_time_limit()は呼び出したリクエストに適用されます。 PHP-FPM と同じく、そのリクエストの制限を呼び出し時点から再始動させ、ワーカー上の他のすべてのリクエストはそのままにします。それに合わせて動くのはリクエスト自身のデッドラインだけで、呼び出しが書き込むディレクティブはワーカーに留まります(ini のロールバックの境界を参照)。- 制限が尽きると、その制限が属するリクエストが終了させられます。 そのリクエストは時間切れとして読み取られます。
504となり、シャットダウン関数のために自身のconnection_status()にタイムアウトのビットが設定され、リサイクルのカウントに対しては中立です。並行して実行されているリクエストは処理を続けます。 - 中断中のリクエストは、デッドラインを過ぎた瞬間ではなく、再開したときに終了させられます。
oxphp_async_await()、oxphp_sleep()/oxphp_usleep()、あるいはRUNTIME_HOOKSが引き受けたsleep()やソケット待機(読み取り、およびnetでは接続、TLS ハンドシェイク、空きを待つ書き込み)で待機しているリクエストは PHP コードを実行しておらず、時間制限が終了させられるのは実行中のコードだけです。待機中に制限を過ぎたリクエストは、PHP コードに戻ったらすぐに終了させられます。待機していた呼び出しが戻ると、リクエストは他のタイムアウトと同様にそこで終了し、シャットダウン関数も実行されます。1 回だけ待機する呼び出し(スリープ、単一の読み取り)は再開時に戻りますが、ストリームを最後まで読み取るstream_get_contents()のように内部で何度も待機する呼び出しは、全体が完了したときにだけ戻ります。リクエストは、待っているものが届いたとき(Promise が確定する、スリープが経過する、ソケットが読み取り可能になる、空きを待つ書き込みなら書き込み可能になる、接続なら接続が確立する)、待機がそのタイムアウトに達したとき、あるいはサーバーがそれをキャンセルしたときに再開します。読み取りと書き込みでは、そのタイムアウトはストリーム自身のもの(stream_set_timeout()で別の値が設定されていなければdefault_socket_timeout)で、接続ではstream_socket_client()またはfsockopen()に渡されたタイムアウトです。ドレインのデッドラインを過ぎたサーバーのシャットダウンと、ワーカーのリサイクルは、その時点で中断しているすべてのリクエストをキャンセルします。すでに制限を過ぎていたリクエストも、他と同様にそのキャンセルによって終了させられます。503となり、シャットダウン関数が実行され、尽きた制限によって途中で打ち切られることはありません。 - ワーカーがリクエストの合間に実行するものには制限がかかりません。 どのリクエストも中にいなくなった時点でワーカーが返却するセッションは、どのリクエストにも属さない状態でセーブハンドラーを通じて書き込まれ、ワーカーの定期的なガベージコレクションが解放するオブジェクトのデストラクタも、どのリクエストにも属さない状態で実行されます。時間制限はそのいずれも制限しません。
中断中のリクエストを代わりに終了させてくれるものは他にありません。クライアントの切断は非ストリーミングのリクエストを終了させず、そのリクエストは設計どおり最後まで実行されます(リサイクルを参照)。また、スーパーバイザーのしきい値を超えて実行されているリクエストは oxphp_worker_stuck_total を通じて報告されるだけで、キャンセルはされません。
制限は待機そのものにかけてください。oxphp_async_await() とその派生関数にタイムアウトを渡し、ソケットとデータベースクライアントにタイムアウトを設定します。
開発時のリロード
ワーカーモードはブートストラップの状態(オートローダー、DI コンテナ、DB 接続)をメモリ内に保持するため、外側のスコープで実行されたコードの変更を反映するには opcache.validate_timestamps=1 だけでは不十分です。開発時のループには 2 つの選択肢があります。
- リクエストごとにリサイクルする。 すべてのハンドラー呼び出しの最後に
OxPHP\Server\Worker::current()->scheduleExit()を呼び出します(たとえばOXPHP_DEV環境変数フラグで制御します)。現在のリクエストは正常に完了し、その後ワーカーは終了し、代わりのワーカーが外側のスコープを再実行します。開発環境で通常使われる静的プールでは、これは即座に行われます。これはワーカーモードのパフォーマンス上の利点を FPM スタイルのリロードセマンティクスと引き換えにします。アクティブな開発において最もシンプルで信頼性の高いアプローチです。 - ワーカーをウォームに保ち、リクエストハンドラーをリロードする。
scheduleExit()を完全にスキップし、opcache.validate_timestamps=1を有効にして、ブートストラップを最小限に保ちます。リクエストコールバック内で読み込まれたコードは、次のリクエストで OPcache によってリフレッシュされます。外側のスコープで一度だけ読み込まれたコードはリフレッシュされません。注意点の完全な一覧については OPcache と JIT → 開発時の設定 を参照してください。
トラブルシューティング
リクエストがハングして完了しない
ブートストラップスクリプトで oxphp_worker() が一度も呼び出されないと、リクエストはディスパッチされず、すべてのリクエストが無期限に待機します。スクリプトが通常のコードパスで無条件に oxphp_worker() を呼び出していることを確認してください。
リクエスト間で状態がリークする
oxphp_worker() コールバック内で定義された変数は PHP のガベージコレクターによってクリーンアップされますが、外側のスコープで定義された静的プロパティやグローバル変数は維持されます。あるリクエストのデータが別のリクエストに現れる場合は、呼び出しをまたいで状態を蓄積している静的プロパティやグローバル変数がないか確認してください。
対処法: 各リクエストコールバックの先頭で静的状態を明示的にリセットするか、リクエストごとの状態を静的変数に格納しないようにします。
ワーカーが即座にリサイクルされる(メモリ制限)
ワーカーのメモリ制限は、PHP が報告するメモリ使用量を用いて各リクエストの後にチェックされます。ブートストラップフェーズで大量のメモリを割り当てる場合(たとえば大きなキャッシュを読み込む場合)、初期のメモリフットプリントがすでに制限に近い可能性があります。
対処法: WORKER_MAX_MEMORY_MIB を増やすか、大きな割り当てを最初のリクエストまで遅延させます。
確認: LOG_LEVEL=info では、このようなリサイクルのたびに、上限を超えた使用量が memory_bytes として、上限が max_memory_bytes として並べて記録されます。それ以前のチェックではいずれも使用量が上限内に収まっていたため、両者の差は、その直前のチェック以降にワーカーが増加した量を超えません。差が小さければ緩やかな増加、大きければ一度の急増と考えられます。最初のリクエストの後にリサイクルされるワーカーでは、その急増にはブートストラップが割り当てたすべてのものが含まれます。
docker logs <container> 2>&1 | grep '"reason":"max_memory"'ワーカーが即座にリサイクルされる(エラー制限)
3 回連続でカウントされる失敗が起きるとリサイクルが発生します。よくあるのはリクエストコールバック内の致命的エラーなので、まずアプリケーションのログでそれを確認してください。未捕捉の例外はカウントされず、キャンセルされたリクエストは、そのリクエストで致命的エラーも報告されていた場合にのみカウントされます。
致命的エラーを伴わないリサイクルは 2 種類のファイバー障害のいずれかであり、WARN 行にどちらかが記されます。fiber would not start は fiber.stack_size またはホストを示しており(その C スタックは PHP のヒープではなくオペレーティングシステムからマップされるため、memory_limit も WORKER_MAX_MEMORY_MIB も調整すべきつまみではありません)、fiber refused to park は自身のコードが再試行し続けている Fiber::suspend() を示しています。
サーバーは、カウントされた失敗 1 件ごとに WARN 行を 1 行記録し、ワーカー、失敗の発生元、連続回数を示します。そして 3 件目に続くリサイクルについてさらに 1 行記録します。カウントはワーカーごとです。1 つのワーカーからの 3 行はリサイクルを意味しますが、3 つのワーカーからの 3 行はそうではありません。
確認: 構造化ログ出力でエラーと警告を探します。
docker logs <container> 2>&1 | grep -E '"level":"(ERROR|WARN)"'アイドル後にデータベース接続が切断される
データベースサーバーがアイドル接続を閉じる場合、次のリクエストでの再接続の試みが失敗する可能性があります。再接続を処理するコネクションプールを使用するか、例外をキャッチして手動で再接続してください。
Docker の例
services:
app:
image: ghcr.io/oxphp/oxphp:0.13.0
ports:
- "8080:80"
volumes:
- ./src:/var/www/html
environment:
- DOCUMENT_ROOT=/var/www/html/public
- WORKER_MODE_ENABLED=true
- ENTRY_FILE=/var/www/html/worker.php
- WORKER_MAX_MEMORY_MIB=128PHP API
ワーカーのイントロスペクションとワーカーのエントリーポイントは、OxPHP\Server\Worker クラスを通じて公開されます。
<?php
$worker = OxPHP\Server\Worker::current();
$worker->serve(function () {
handleRequest();
});レガシーの自由関数(oxphp_is_worker、oxphp_worker_id、oxphp_worker)は引き続き利用可能で、同じ内部状態を経由します。新しいコードではクラス API を優先してください。
このクラスは、グレースフルな自己リサイクル、可観測性、ヘルスチェックに役立つランタイムのイントロスペクションも公開します。
| Method | Returns |
|---|---|
Worker::isWorkerMode(): bool |
サーバーがワーカーモードで実行されているかどうか |
$worker->id(): int |
スレッドごとの安定したワーカーID |
$worker->startTime(): float |
このワーカーが起動した Unix タイムスタンプ |
$worker->requestCount(): int |
このワーカーが処理したリクエスト数 |
$worker->memoryUsage(): int |
このワーカーの現在の memory_get_usage(true) |
$worker->rss(): int |
現在の resident set size(バイト単位、Linux/macOS) |
$worker->maxMemoryBytes(): int |
リサイクルのしきい値 — WORKER_MAX_MEMORY_MIB × 1 MiB、無制限の場合は 0 |
$worker->isExitScheduled(): bool |
理由が "scheduled" の終了が保留中かどうか。scheduleExit() が呼び出されたか、ワーカーが自身をリタイアさせた場合です |
$worker->exitReason(): ?string |
実行中は null。ワーカーが停止する際は "scheduled"、"max_memory"、"error" のいずれか |
完全なシグネチャと実践的な例については OxPHP\Server\Worker を参照してください。
PHP の例
ワーカーモードの検出
現在のプロセスがワーカーモードで実行されているかどうかを確認するには、OxPHP\Server\Worker::isWorkerMode() を使用します。これは、従来のモードとワーカーモードの両方で動作するコードを書く際に役立ちます。
<?php
if (OxPHP\Server\Worker::isWorkerMode()) {
// Reuse a persistent connection
$redis = new Redis();
$redis->pconnect('redis', 6379);
} else {
// Traditional mode: connect per request
$redis = new Redis();
$redis->connect('redis', 6379);
}Symfony のワーカースクリプト
<?php
use App\Kernel;
require __DIR__ . '/../vendor/autoload.php';
$kernel = new Kernel('prod', false);
$kernel->boot();
oxphp_worker(function () use ($kernel) {
$request = Symfony\Component\HttpFoundation\Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
});
$kernel->shutdown();ベストプラクティス
WORKER_MAX_MEMORY_MIBを設定します(たとえば128)。これにより、リークしているワーカーがホストを消費し尽くす代わりに自動的にリサイクルされます。さらにアプリケーション駆動のリサイクルのためにWorker::scheduleExit()と組み合わせます。アプリケーションが何もリークしていなくても、ワーカーが肥大化することはあります。致命的エラーやキャンセルが内部関数の中でリクエストを放棄させると、その関数が自身のために割り当てたものは返却されません。たとえば、usort()の途中でキャンセルされたリクエストは、ソート中の配列全体を生かしたままにします。そのようにして 4 MiB を超えて肥大化したワーカーは、リサイクルで説明したとおりリサイクルされます。それより小さなリークは、WORKER_MAX_MEMORY_MIBを超えるまで、あるいはそれが設定されていなければリクエストがmemory_limitに引っかかって失敗し始めるまで残ります。リクエストが実行するFiber(自ら開始したもの、または PHP がガベージコレクションのデストラクタを実行するファイバー)の中でリクエストを終了させる致命的エラーやキャンセルも、同じように、そのファイバー自身のフレームが保持していたものをワーカーの生存期間中ずっと割り当てられたままにします。- 中断ポイントの前にセッションを閉じます。 セッションはワーカースレッドに属するため、
oxphp_async_await()やoxphp_sleep()をまたいで開いたままのセッションは、ワーカーが次に受け取るリクエストと共有されます。セッション状態を参照してください。 - リクエストごとの状態を静的プロパティやグローバル変数に格納しないようにします。 これらはリクエスト間で維持されるため、あるリクエストの残留状態が別のリクエストにリークする可能性があります。
- ソフトリセットを早い段階で検証します。 開発用フラグの下でハンドラーに
Worker::current()->scheduleExit()を追加し、アプリケーションをエンドツーエンドで動かしてみます。これにより、長寿命のワーカーに移行する前に状態リークのバグを捕捉できます。 - データベースのアイドルタイムアウトに対処します。 データベースドライバーがアイドル期間の後に切断する場合は、例外をキャッチして再接続するか、再接続を自動的に処理するコネクションプールを使用します。
- 外側のスコープを最小限に保ちます。 本当に維持する必要があるもの、つまりオートローダー、設定、共有サービスだけをブートストラップします。リクエスト固有のセットアップはコールバックに遅延させます。
エージェント向けプロンプト
これらのプロンプトは、OxPHP ドキュメント MCP サーバー を接続したエージェントを前提としています — 必要なページはエージェントが自分で読み取ります。
私のアプリケーションを OxPHP のワーカーモードへ移行してください。OxPHP ドキュメント MCP サーバー経由で `features/worker-mode` ページを読んでください。
私のブートストラップとリクエストハンドラーを監査して、リクエスト間で生き残る状態や、ワーカー上で重なり合うリクエスト間で共有される状態(中断ポイントをまたいで開いたままのセッションを含む)を洗い出し、このリポジトリを永続的なワーカーで安全に動かせるようにする変更を見せてください。