拡張システム、OPcache、JIT:PHPの拡張と最適化のしくみ
前提知識
- ›第1〜4回:エンジン全体の理解(アーキテクチャ、型、コンパイル、VM)
- ›共有メモリとプロセス間通信の基礎知識
- ›JITコンパイルの基本的な概念
拡張システム、OPcache、JIT:PHPの拡張と最適化のしくみ
前回までの4回で、PHPをSAPIエントリーポイントから型システム、コンパイルパイプライン、仮想マシンまで順に追ってきました。しかし、Zend Engineだけでウェブアプリケーションを構築できません。データベース、HTTP、JSON、暗号化、ファイルシステムなど、開発者が必要とするあらゆる機能とPHPをつなぐのが拡張システムです。この仕組みがあってこそ、言語エンジンは完全なランタイムへと変貌します。
この最終回では、72を超えるバンドル拡張が利用する拡張APIを詳しく見ていきます。そのうえで、アーキテクチャ上とくに重要な3つのサブシステムに焦点を当てます。コンパイル済みコードを共有メモリにキャッシュしてPHPを高速化するOPcache、さらにネイティブマシンコードを生成するJITコンパイラ、そしてPHPに協調的な並行処理をもたらすFiberです。最後に、ストリームI/O抽象化とTSRMスレッドセーフレイヤーについても取り上げます。
拡張API
ext/にバンドルされているものであれPECL経由でインストールするものであれ、すべてのPHP拡張はZend/zend_modules.hで定義されたzend_module_entry構造体を使って自身を登録します。
classDiagram
class zend_module_entry {
+char *name
+zend_function_entry *functions
+module_startup_func MINIT
+module_shutdown_func MSHUTDOWN
+request_startup_func RINIT
+request_shutdown_func RSHUTDOWN
+info_func MINFO
+char *version
+globals_size
+globals_ctor GINIT
+globals_dtor GDTOR
+post_deactivate_func
+deps: zend_module_dep*
}
ext/json/json.cのJSON拡張は、シンプルな実例として最適です。このモジュールエントリはMINIT関数、関数テーブル、そしてphpinfo()出力用のMINFO関数を登録するだけですが、5分で読み切れるコード量のなかに完全な登録パターンが凝縮されています。
functionsフィールドはzend_function_entry構造体の配列を指しており、各エントリがPHPの関数名をCのハンドラ関数と引数情報にマッピングします。PHP 8.0以降、引数情報は.stub.phpファイル——PHP構文による関数宣言——からビルドツールが_arginfo.hヘッダとして自動生成する方式に変わりました。たとえばext/json/json_arginfo.hはext/json/json.stub.phpから生成されています。
ヒント: 新しく拡張を書くなら、
ext/jsonのようなシンプルな拡張をコピーして改造するところから始めましょう。stubファイルシステム(*.stub.php→*_arginfo.h)を使えば、拡張開発でもっともミスを起こしやすい部分——引数情報構造体の手書き——を省けます。
拡張のライフサイクルフック
第1回で見たように、PHPのライフサイクルにはモジュールレベルとリクエストレベルの2つのフェーズがあります。拡張はzend_module_entryのコールバックを通じてこれらのフェーズに割り込みます。
flowchart TD
START["Process Start"] --> GINIT["GINIT<br/>Initialize extension globals struct<br/>(called once per thread in ZTS)"]
GINIT --> MINIT["MINIT<br/>Module initialization:<br/>register classes, constants,<br/>INI entries, resources"]
MINIT --> LOOP["Request Loop"]
LOOP --> RINIT["RINIT<br/>Per-request setup:<br/>reset counters, open connections"]
RINIT --> EXEC["Script Execution"]
EXEC --> RSHUTDOWN["RSHUTDOWN<br/>Per-request teardown:<br/>close connections, flush buffers"]
RSHUTDOWN --> POST["post_deactivate_func<br/>Late cleanup after output sent"]
POST --> LOOP
LOOP -->|"Process exit"| MSHUTDOWN["MSHUTDOWN<br/>Module teardown:<br/>unregister, free persistent memory"]
MSHUTDOWN --> GDTOR["GDTOR<br/>Destroy extension globals struct"]
GDTOR --> END["Process End"]
各フェーズの役割を正しく理解することが、正確な実装につながります。
- GINIT/GDTOR: 拡張のグローバル構造体を初期化・破棄します。ZTSビルドではスレッドごとに実行されます。グローバル構造体はリクエスト単位ではなくモジュール単位の状態を保持します。
- MINIT: クラス、関数、定数、INIエントリなど、リクエストをまたいで持続するリソースをすべて登録します。ここでは
emalloc()ではなくpemalloc()(永続アロケーション)を使いましょう。 - RINIT: リクエストごとの状態を準備します。たとえばセッション拡張がここでセッションファイルを開きます。
- RSHUTDOWN: リクエストごとの状態を後片付けします。出力がフラッシュされる前に呼ばれます。
- MSHUTDOWN: モジュールレベルのリソースを解放します。永続アロケーションの解放やハンドラの登録解除を行います。
内部関数の登録
C言語で実装されたすべてのPHP内部関数は、Zend/zend.hで定義されたINTERNAL_FUNCTION_PARAMETERSマクロを通じて引数を受け取ります。
#define INTERNAL_FUNCTION_PARAMETERS zend_execute_data *execute_data, zval *return_value
すべての内部関数はvoid fn_name(zend_execute_data *execute_data, zval *return_value)というシグネチャを持ちます。execute_dataからzend_parse_parameters()あるいは高速なZEND_PARSE_PARAMETERS_*マクロで引数を取り出し、戻り値をreturn_valueに書き込む、というのが基本パターンです。
flowchart LR
STUB["json.stub.php<br/>PHP-syntax declarations:<br/>function json_encode(mixed $value, int $flags = 0): string|false"]
GEN["gen_stub.php<br/>Build-time generator"]
ARGINFO["json_arginfo.h<br/>Generated zend_function_entry[]<br/>+ ZEND_ARG_INFO structs"]
IMPL["json.c<br/>PHP_FUNCTION(json_encode)<br/>{ ... C implementation ... }"]
STUB --> GEN
GEN --> ARGINFO
ARGINFO --> |"Linked at compile"| IMPL
第3回で見たzend_function unionのレベルでは、内部関数はzend_op_arrayではなくzend_internal_functionを使います。共通ヘッダは同一なのでVMはどちらも統一的に扱えますが、内部関数をディスパッチするときはオペコードインタープリタに入らず、zend_execute_internalを通じてCハンドラを直接呼び出します。
OPcache:共有メモリオペコードキャッシュ
OPcacheはPHPのパフォーマンスを支える最重要拡張です。OPcacheなしでは、すべてのリクエストでPHPファイルをソースからコンパイルし直します。OPcacheを使えば、コンパイル済みのop_arrayを共有メモリに保存し、すべてのワーカープロセスで再利用できます。
OPcacheの中核はext/opcache/ZendAccelerator.cにあります。MINITのタイミングで、第4回で紹介したフック可能な関数ポインタパターンを使い、zend_compile_file関数ポインタを独自のpersistent_compile_fileに差し替えます。処理の流れはこうです。
- リクエストが来ると、PHPは
zend_compile_file("script.php")を呼ぶ。 - OPcacheの代替関数が「このファイルは共有メモリキャッシュにあるか?」を確認する。
- キャッシュヒット: キャッシュされた
zend_op_arrayへのポインタを返す。コンパイルは一切しない。 - キャッシュミス: 元の
compile_fileを呼んでコンパイルし、結果を共有メモリに永続化する。
flowchart TD
subgraph shm["Shared Memory (SHM)"]
direction TB
ALLOC["SHM Allocator<br/>(mmap / shm / posix)"]
STRINGS["Interned Strings Table<br/>(shared across processes)"]
CACHE["Opcode Cache<br/>filename → cached_script"]
CACHED["Cached Script:<br/>op_array, class_table,<br/>function_table"]
end
subgraph workers["FPM Worker Processes"]
W1["Worker 1"]
W2["Worker 2"]
W3["Worker 3"]
end
W1 -->|"Read-only access"| CACHE
W2 -->|"Read-only access"| CACHE
W3 -->|"Read-only access"| CACHE
W1 -->|"First compile"| CACHED
永続化を担うext/opcache/zend_persist.cは、もっとも複雑な部分です。op_arrayは文字列、リテラル、クラスエントリ、他のop_arrayなどへのポインタを含んでいます。共有メモリにコピーするとき、これらのポインタをすべて共有メモリ上のアドレスに修正しなければなりません。文字列は共有インターンド文字列テーブルにインターンされ、クラスエントリ内の関数テーブルといったネストした構造体は再帰的に永続化されます。
共有メモリのアロケーションはext/opcache/zend_shared_alloc.cが担い、mmap(Linuxのデフォルト)、shm(System V共有メモリ)、posix(POSIX共有メモリ)の複数バックエンドをサポートします。確保するメモリ領域のサイズはopcache.memory_consumption(デフォルト128MB)で指定します。
OPcacheにはプリロード(opcache.preload)機能もあります。サーバー起動時に一度だけ実行されるPHPスクリプトを指定すると、クラスや関数が恒久的に共有メモリへ読み込まれます。プリロードされたコードは無効化もコンパイルも行われないため、最速のアクセスが実現します。
JITコンパイラ
PHP 8.0で導入されたJIT(Just-In-Time)コンパイラは、頻繁に実行されるオペコードをネイティブマシンコードに変換します。JITはOPcacheの内部に組み込まれており、第4回で説明したSSAベースのオプティマイザによる型推論の結果を活用します。
JITのエントリーポイントはext/opcache/jit/zend_jit.cで、2つのモードで動作します。
Function JIT は関数全体をネイティブコードにコンパイルします。ある関数の実行回数が閾値を超えると、JITがその関数をコンパイルし、op_arrayのハンドラポインタをネイティブコードに直接ジャンプするよう書き換えます。
Tracing JIT(ext/opcache/jit/zend_jit_trace.c)はより高度です。ホットなループを通るオペコードの線形シーケンス(トレース)を記録し、そのトレースをネイティブコードにコンパイルします。トレースは関数の境界を越えることができ、インライン化された関数呼び出しを含む実際のホットパスをまるごとコンパイル対象にできます。
flowchart TD
OPCODE["Opcode execution"] --> COUNT{"Execution count<br/>> threshold?"}
COUNT -->|"No"| INTERP["Continue interpreting"]
COUNT -->|"Yes"| MODE{"JIT mode?"}
MODE -->|"Function JIT"| FJIT["Compile entire function<br/>to native code"]
MODE -->|"Tracing JIT"| RECORD["Record trace<br/>(linear opcode path)"]
RECORD --> TRACE_END{"Loop back or<br/>return?"}
TRACE_END -->|"Loop"| COMPILE["Compile trace"]
TRACE_END -->|"Side exit"| LINK["Link to other traces<br/>or back to interpreter"]
FJIT --> PATCH["Patch handler<br/>to native entry point"]
COMPILE --> PATCH
PATCH --> NATIVE["Execute native code<br/>Type guards inline"]
NATIVE --> DEOPT{"Type guard<br/>fails?"}
DEOPT -->|"Yes"| INTERP
DEOPT -->|"No"| NATIVE
ネイティブコードの生成にはext/opcache/jit/ir/にあるIRフレームワークを使います。IR(Intermediate Representation)はPHP専用のコンパイラフレームワークで、SSAベースのIR構築、最適化パス(定数畳み込み、コピー伝播、レジスタ割り当て)、そしてx86_64とAArch64向けのコード出力を備えています。
ext/opcache/jit/zend_jit_ir.cのJIT IRパイプラインは、ZendオペコードをIR命令に変換します。型ガードはオプティマイザのSSA型推論に基づいて挿入されます。たとえば変数がIS_LONGと推論されていれば、JITはトレースの入口に型チェックを、トレース本体には整数専用の算術命令を生成します。実行時に型ガードが失敗すると、トレースはデオプティマイズされてインタープリタに戻ります。
JITの設定はopcache.jit(4桁のビットマスク)とopcache.jit_buffer_sizeで制御します。PHP 8.4ではデフォルトでTracing JITが有効になっています。
ヒント: JITが最も効果を発揮するのは、数値計算やデータ処理のようなCPUバウンドなコードです。I/Oバウンドが主体の一般的なウェブアプリケーションでは、OPcacheだけでも十分な性能向上が得られます。JITはメモリを追加消費し、起動時間も増加することがあるため、有効化する前にプロファイリングで効果を確認しましょう。
Fiber:協調的な並行処理
PHP 8.1で導入されたFiberは、協調的マルチタスクを実現する仕組みです。Fiberは独自のコールスタックを持つ軽量な実行コンテキストで、処理を中断・再開できます。実装はZend/zend_fibers.hとZend/zend_fibers.cにあります。
sequenceDiagram
participant Main as Main Context
participant Fiber as Fiber Context
Main->>Fiber: $fiber->start()
Note over Fiber: Execute callback<br/>on Fiber's own C stack
Fiber->>Main: Fiber::suspend($value)
Note over Main: Context switch back<br/>$fiber->start() returns $value
Main->>Fiber: $fiber->resume($sent)
Note over Fiber: Fiber::suspend() returns $sent
Fiber->>Main: return $result
Note over Main: $fiber->getReturn() → $result
各Fiberは独自のCスタックを持ちます(zend_fiber_stack_allocateで確保。通常はガードページ付きのmmapで512KB)。コンテキストスイッチではCPUレジスタを保存・復元し、プラットフォーム固有のアセンブリ(x86_64やARM64向けのboost.context由来のコード)を使ってスタックポインタを切り替えます。
FiberはVMのexecute_dataチェーンと統合されています。Fiberが中断されると、そのFiber上のすべてのVMフレーム、CV(コンパイル済み変数)スロット、一時変数がスタック上に保存されたままになります。再開されると、VMは中断した箇所からそのまま実行を続けます。
Fiberの重要な設計上の特徴は、協調的であって先占的ではないという点です。Fiberは明示的にFiber::suspend()を呼び出すまで実行を続けます。同時に実行されるFiberは常に1つだけなので、ロックやアトミック操作が不要になります。ReactPHPやRevoltといったライブラリは、I/Oバウンドな処理向けのasync/awaitパターンを提供するためにFiberを内部で活用しています。
ストリーム:統一されたI/O抽象化
main/streams/streams.cを基盤とするPHPのストリームレイヤーは、あらゆるI/O操作への統一インターフェースを提供します。fopen()、file_get_contents()、fread()を呼び出すときは、必ずこのストリームレイヤーを通ります。
classDiagram
class php_stream {
+ops: php_stream_ops*
+readbuf: char*
+readbuflen: size_t
+wrapper: php_stream_wrapper*
+context: php_stream_context*
}
class php_stream_ops {
+write(stream, buf, count) ssize_t
+read(stream, buf, count) ssize_t
+close(stream) int
+flush(stream) int
+seek(stream, offset, whence) int
+cast(stream, castas, ret) int
+stat(stream, ssb) int
+label: char*
}
class php_stream_wrapper {
+wops: php_stream_wrapper_ops*
+abstract: void*
+is_url: int
}
class php_stream_wrapper_ops {
+stream_opener()
+stream_closer()
+url_stat()
+dir_opener()
+unlink()
+rename()
+stream_mkdir()
+label: char*
}
php_stream --> php_stream_ops : ops
php_stream --> php_stream_wrapper : wrapper
php_stream_wrapper --> php_stream_wrapper_ops : wops
wrapperシステムがあるおかげで、fopen("http://example.com/file.txt")のような呼び出しが成り立ちます。各URLスキームは登録済みのwrapperが処理します。
| Wrapper | スキーム | 実装 |
|---|---|---|
| 通常ファイル | file:// |
main/streams/plain_wrapper.c |
| HTTP | http://, https:// |
ext/standard/http_fopen_wrapper.c |
| FTP | ftp:// |
ext/standard/ftp_fopen_wrapper.c |
| PHP | php://stdin, php://memory など |
ext/standard/php_fopen_wrapper.c |
| 圧縮 | compress.zlib:// |
ext/zlib/zlib_fopen_wrapper.c |
| ユーザー定義 | カスタムスキーム | stream_wrapper_register()で登録 |
トランスポートレイヤー(main/streams/xp_socket.c)はソケットI/O——TCP、UDP、Unixドメインソケット、SSL/TLS接続——を担います。stream_socket_client()とstream_socket_server()はこのレイヤーを通じて動作します。
TSRM:スレッドセーフ
TSRM/TSRM.hとTSRM/TSRM.cにあるThread Safe Resource Managerは、根本的な問題を解決します。PHPのエンジンはコンパイラグローバル、エクゼキュータグローバル、SAPIグローバル、拡張ごとのグローバルといった広範なグローバル状態を使いますが、ZTS(Zend Thread Safety)ビルドでは複数のスレッドがPHPを同時に実行することがあります。
TSRMはすべてのグローバル状態にスレッドローカルストレージを提供します。各モジュールはts_allocate_id()でリソースIDを取得します。これは整数インデックスです。実行時には、グローバルアクセサマクロがTSRMを通じて解決されます。
| ビルド | EG(current_execute_data)の展開結果 |
|---|---|
| 非ZTS | executor_globals.current_execute_data — 構造体への直接アクセス |
| ZTS | ((zend_executor_globals*)tsrm_get_ls_cache())->current_execute_data — スレッドローカル参照 |
非ZTSビルド(FPMでは一般的)ではTSRMはコンパイル時に完全に除外されます。マクロは構造体への直接アクセスに展開され、オーバーヘッドはゼロです。PHPディストリビューションがZTSビルドと非ZTSビルドを別々に提供しているのはこのためで、非ZTSビルドは計測可能なほど高速です。
ZTSビルドが必要になるのは次のケースです。
- 複数スレッドでPHPを処理するWindows IIS
- スレッドを使うイベント駆動型SAPI
- 真のマルチスレッドを実現する
parallel拡張
本番環境のほとんどは非ZTSのFPMを使っており、各ワーカーが独立したアドレス空間を持つプロセスレベルの分離が「スレッドセーフ」の代わりを果たしています。
シリーズのまとめ
この5回のシリーズを通じて、PHPのトップレベルディレクトリ構造から主要なサブシステムまでを順に追ってきました。
- アーキテクチャとライフサイクル — 4つのレイヤー、SAPIコントラクト、リクエストライフサイクル
- zvalとメモリモデル — 16バイトの値表現、参照カウント、COW、アロケーター、GC
- コンパイルパイプライン — レキサー、パーサー、AST、コンパイラ、オペコード、フラグシステム
- 仮想マシン — コード生成、ディスパッチモード、レジスタピニング、オプティマイザ
- 拡張、OPcache、JIT — 拡張API、共有メモリキャッシュ、ネイティブコード生成、Fiber、ストリーム、TSRM
php-srcのコードベースは大規模ですが、構造はよく整理されています。アーキテクチャ上の境界は明確で、命名規則は一貫しており、フック可能な関数ポインタ、ライフサイクルフック、関数ポインタのvtableといった設計パターンがコードベース全体で繰り返し登場します。これらのパターンを理解してしまえば、初めて読むコードでも迷わずに読み進められるようになるはずです。
ヒント: 理解をさらに深めるには、
array_map()やjson_decode()など毎日使うPHP関数を1つ選んで、その実装をエンドツーエンドで読み通してみましょう。このシリーズを読み終えた今なら、すべての行の意味を理解できるはずです。