OPcacheプリローディングの物理学:共有メモリセグメントとガベージコレクション無効化の舞台裏
こんにちは。Webアーキテクチャのワールドへようこそ。
PHP言語はその誕生以来、「1リクエスト・シェアリング・ナッシング(Shared-Nothing)」という堅牢な哲学に基づいて進化してきました。リクエストが始まるとメモリ空間が割り振られ、処理が完了すればすべてが綺麗に開放される――このシンプルなライフサイクルが、PHPの並列処理における安全性の源泉です。
しかし、フレームワークの巨大化に伴い、毎リクエストで大量のPHPファイルを読み込み、構文解析し、クラス定義を構築するオーバーヘッドが無視できなくなってきました。そこで登場したのがOPcacheであり、さらにそれを極限まで推し進めたOPcacheプリローディング(Preloading)です。
今回は、OPcacheプリローディングが「物理的にどのようにメモリにマッピングされ、なぜプロセス間で安全に共有できるのか」、そして「ガベージコレクション(GC)や参照カウントとどのように折り合いをつけているのか」という、Zend Engine内部の深淵へとみなさんをお連れします。
ここをマスターすれば、PHPのパフォーマンスチューニングの裏側が驚くほど綺麗に見えるようになりますよ。
—
1. 物理メモリの視点:PHP-FPMと共有メモリ(SHM)の基本構造
まず、オペレーティングシステム(OS)の物理メモリレイヤからPHPの実行環境を捉えてみましょう。
通常のPHPリクエストでは、Zend Memory Manager(ZendMM)がプロセスローカルなヒープ領域(Request Heap)からメモリを割り当てます。これはリクエスト終了時に `zend_mm_shutdown()` によって丸ごと破棄されます。
一方、OPcacheが有効な場合、マスタープロセス(PHP-FPMの親プロセス)の起動時に `mmap` (または `shmget`) システムコールが発行され、共有メモリセグメント(Shared Memory Segment / SHM)が確保されます。
+——————————————————————-+
| OS Physical Memory / Shared Memory |
| |
| +———————————————————–+ |
| | OPcache Shared Memory Segment | |
| | | |
| | – Immutable Opcodes | |
| | – Interned Strings (zend_string) | |
| | – Preloaded Class Entries (zend_class_entry) | |
| | – Preloaded Function Entries (zend_function) | |
| +———————————————————–+ |
+——————————————————————-+
^ ^ ^
| (Read-Only Mapping) | (Read-Only Mapping) | (Read-Only Mapping)
+——-+——-+ +——-+——-+ +——-+——-+
| FPM Worker 1 | | FPM Worker 2 | | FPM Worker 3 |
| | | | | |
| [Local Heap] | | [Local Heap] | | [Local Heap] |
+—————+ +—————+ +—————+
FPM Workerプロセス群は、親プロセスから `fork()` されるか、起動後にこの共有メモリセグメントを自身の仮想アドレス空間にマッピング(`mmap`)します。
従来のOPcacheとプリローディングの大きな違い
- 従来のOPcache: ファイルごとのOPコード(Zend Bytecode)を共有メモリにキャッシュします。しかし、リクエストごとに「そのOPコードを読み込んで、現在の実行コンテキストにクラスや関数としてバインド(シンボルテーブルへ登録)する」という軽量なリンク処理が発生していました。
- OPcacheプリローディング: PHP-FPM起動時の1回限りのタイミングで指定のスクリプトを実行し、クラスや関数の定義そのもの(`zend_class_entry` や `zend_function`)を完全にコンパイル・バインドした状態で共有メモリに固定(永続化)します。
これにより、Workerプロセスはリクエスト受信時にクラスロード処理を「1ミリ秒」すら行う必要がなくなります。完全にシンボルがロード済みの状態で処理を開始できるのです。
—
2. 内部エンジンの挙動:`zend_class_entry` の永続化メカニズム
では、プリローディングが行われる際、Zend Engine内部で何が起きているのでしょうか。C言語レベルの構造体からそのカラクリを紐解いてみましょう。
通常のリクエストで生成される `zend_string`(文字列構造体)や `zend_class_entry`(クラス定義構造体)は、リクエスト終了時に回収される運命にあります。しかし、プリロード時には特別なフラグが付与されます。
内部構造体におけるフラグの制御
Zend Engine内部の参照カウントオブジェクト(`zend_refcounted_h`)には、メモリ管理の戦略を制御するための `flags` フィールドが存在します。
/ Zend Engine内部のオブジェクトヘッダ概念 /
typedef struct _zend_refcounted_h {
uint32_t refcount; / 参照カウンタ /
union {
struct {
uint8_t type;
uint8_t flags; / ここに永続化やGC制御のフラグが入る /
uint16_t gc_info;
} v;
uint32_t type_info;
} u;
} zend_refcounted_h;
プリロード処理中、Zend Compilerは生成された構造体に対して以下の特徴的なフラグを設定します。
1. `IS_STR_PERSISTENT` / `IS_ARRAY_PERSISTENT`: ZendMMのリクエストヒープではなく、システム永続メモリ(またはOPcache SHM)に配置されていることを示す。
2. `GC_IMMUTABLE` (PHP 8.1以降): このオブジェクトが不変(Immutable)であることを意味し、参照カウントの増減(`GC_ADDREF` / `GC_DELREF`)を完全にスキップさせる。
なぜ参照カウントをスキップ(無効化)するのか?
ここが最も重要な物理的制約のポイントです。
もしWorkerプロセス A が共有メモリ上の `zend_class_entry` を参照した際に参照カウントをインクリメント(`+1`)し、処理終了時にデクリメント(`-1`)すると何が起きるでしょうか?
1. マルチプロセス間でのライト・コンテンション(書き込み競合): 共有メモリ内の同一アドレスに対する書き込みが発生するため、プロセス間でアトミックなロック(Mutex)が必要になり、並列性能が壊滅します。
2. Copy-on-Write(CoW)の誘発: OSの仮想メモリ機構において、共有メモリページに書き込みが発生すると、OSはそのページをプロセスローカルにコピーしてしまいます(Copy-on-Write)。結果として「共有メモリ」ではなくなり、Workerプロセスごとにメモリが複製され、メモリ消費量が爆発します。
したがって、プリロードされたすべての構造体は「不変(Immutable)かつ参照カウントの不活性化」というアプローチを取ることで、複数Workerからの並行アクセス(読み取り専用)を物理レベルで完全安全に保護しているのです。
—
3. ガベージコレクション(GC)との物理的制約
PHPには、循環参照によるメモリリークを防ぐためのサイクリックGC(Cyclic Garbage Collector)が備わっています。しかし、プリロードされた領域はこのGC機構から完全に物理的に隔離されています。
循環参照検出アルゴリズムの回避
Zend EngineのGCアルゴリズム(`zend_gc_collect_cycles`)は、可能性のある参照カウント型オブジェクト(`zval`)を「GCバッファ」と呼ばれるルートバッファに登録します。
しかし、プリロードされたオブジェクトやクラス構造体がGCスキャンに巻き込まれると、前述の通り「不変であるはずの共有メモリ」を汚損することになります。そのため、Zend GCは以下のようなチェックを行います。
/ GC対象かどうかの判定処理(概念的なCコード) /
static zend_always_inline void gc_check_possible_root(zend_refcounted p) {
/ GC_IMMUTABLE フラグが立っている、または Refcountが特殊な値の場合はGCスキャンから即座に除外 /
if (GC_FLAGS(p) & GC_IMMUTABLE) {
return; / 共有メモリ上のプリロードデータなので完全スルー /
}
/ 通常のプロセスローカルな動的メモリのみをGCバッファに追記 /
…
}
この仕組みにより、プリロードされたクラスや静的プロパティ(初期値)は、「どれほど複雑な循環参照を持っていようとも、GCによって破壊されることは物理的にあり得ない」という保証を得ます。
—
4. 実証:プリロードによるメモリ構造の変化をコードで体感する
論理が理解できたら、実際のコードでこの挙動を確認してみましょう。
以下は、OPcacheプリローディングを検証するための実践的なコード例です。
1. プリロード対象のスクリプト (`preload.php`)
/
declare(strict_types=1);
// プリロードしたい重量級クラスやフレームワークのファイルを定義
$filesToPreload = [
__DIR__ . ‘/src/HeavyDomainService.php’,
__DIR__ . ‘/src/ValueObject.php’,
];
foreach ($filesToPreload as $file) {
// opcache_compile_file を使うことで、スクリプトを実行せずに
// OPコード化およびシンボルテーブル(クラス/関数)の共有メモリ永続化を行う
if (!opcache_compile_file($file)) {
error_log(“Failed to preload: ” . $file);
}
}
echo “[OPcache Preload] Compiled ” . count($filesToPreload) . ” files into Shared Memory.\n”;
2. プリロードされるクラス (`src/HeavyDomainService.php`)
‘mysql’,
‘isolation_level’ => ‘READ_COMMITTED’,
‘pool’ => [‘min’ => 5, ‘max’ => 50],
];
public function execute(): string
{
return “Executed with driver: ” . self::CONFIG_MAP[‘driver’];
}
}
3. リクエスト実行時の検証用スクリプト (`index.php`)
Workerプロセスがリクエストを処理する際の、メモリ消費量とクラスの存在状態を確認します。
execute();
// 4. インスタンス化後のメモリ使用量を取得
$afterMemory = memory_get_usage();
// メモリ消費の差分を算出
$memoryDiff = $afterMemory – $initialMemory;
header(‘Content-Type: text/plain; charset=utf-8’);
echo “=== OPcache Preload Verification ===\n”;
echo “Class exists without require? : ” . ($isLoadedBeforeUse ? “YES (Preloaded!)” : “NO”) . “\n”;
echo “Execution Result : {$result}\n”;
echo “Initial Request Memory : {$initialMemory} bytes\n”;
echo “Memory Delta after Instantiation: {$memoryDiff} bytes\n”;
// OPcacheの詳細ステータスを取得
$opcacheStatus = opcache_get_status(false);
if ($opcacheStatus && isset($opcacheStatus[‘preload_statistics’])) {
echo “\n=== Preload Statistics (from Shared Memory) ===\n”;
echo “Preloaded Functions : ” . count($opcacheStatus[‘preload_statistics’][‘functions’] ?? []) . “\n”;
echo “Preloaded Classes : ” . count($opcacheStatus[‘preload_statistics’][‘classes’] ?? []) . “\n”;
echo “Preloaded Memory : ” . ($opcacheStatus[‘preload_statistics’][‘memory_consumption’] ?? 0) . ” bytes\n”;
}
実行結果の解釈とアーキテクト視点の解説
このコードを実行すると、次のような結果が得られます。
=== OPcache Preload Verification ===
Class exists without require? : YES (Preloaded!)
Execution Result : Executed with driver: mysql
Initial Request Memory : 388104 bytes
Memory Delta after Instantiation: 416 bytes
ここで注目すべきは、`class_exists(…, false)` が `YES` を返す点と、`Memory Delta` が極めて小さい点です。
通常のオートロード環境であれば、ファイルの読み込み、ASTの構築、OPコードの生成、クラス定義のロードによって数キロバイト〜数メガバイトのリクエストメモリが消費されます。
しかしプリロード環境では、`HeavyDomainService` クラスの定義(`zend_class_entry`)や定数配列 `CONFIG_MAP` の実体は共有メモリ(SHM)に既に存在しているため、Workerプロセスが消費したのは「インスタンス化されたオブジェクト(`zval` とプロパティ構造体)」に割り当てられるわずか数百バイトのローカルヒープ(ZendMM)のみとなります。
—
5. 運用上の「物理的制約」とアーキテクトが冒すべきでない過ち
プリローディングは圧倒的なパフォーマンスをもたらしますが、その物理構造(共有メモリの不変性)ゆえに、絶対に回避しなければならない重大な制約が存在します。
制約1: コード更新時のFPM再起動の強制
共有メモリセグメントにマッピングされたプリロード済みデータは、プロセスが稼働している間は物理的に不変(Immutable)です。
通常のリクエストであれば、ファイルを更新すればOPcacheのタイムスタンプチェックによって自動的にキャッシュが更新されます。しかし、プリロードされたクラス定義はZend Engineのグローバルシンボルテーブルに強固に結合されているため、PHP-FPMのマスタープロセスを再起動(`systemctl reload php-fpm`)しない限り、コードの変更は100%反映されません。
制約2: プリロード空間での「状態(State)」の保持は厳禁
プリロードスクリプトの実行中に、クラスの「静的プロパティ(`public static $cache` など)」へ動的にオブジェクトやデータを代入するような実装は非常に危険です。
プリロード中に生成された静的プロパティの値は共有メモリに配置されます。Workerプロセスがこの静的プロパティを書き換えようとした場合、Zend Engineは共有メモリを破壊しないようにローカルプロセス領域へコピーを作る(Copy-on-Write)か、最悪の場合セグメンテーション違反(`SIGSEGV`)や意図しない不整合を引き起こします。
> アーキテクトの金言:
> 「プリロードは『コード(構造と命令)』を永続化させるための仕組みであり、『データ(状態)』を共有するための仕組みではない」と強く認識してください。
—
まとめ
OPcacheプリローディングの美しさは、単なる「キャッシュ」を超えて、「PHPのコンパイル済み状態を物理メモリのレベルでプロセス間に透過的に共有する」という設計にあります。
1. 共有メモリ(SHM)のマッピング: 親プロセスで生成された `zend_class_entry` や OPコードが読み取り専用で共有される。
2. GCと参照カウントの無効化: `GC_IMMUTABLE` フラグ等により、Workerアクセス時のアトミックロックやCopy-on-Writeによるメモリ破壊を防ぐ。
3. ライフサイクルの分離: リクエストヒープ(ZendMM)の寿命と共有メモリの寿命が明確に区別されるため、GCオーバーヘッドが劇的に減少し、リクエスト処理速度が極限まで高まる。
エンジンの低レイヤで何が起きているかを知ることで、フレームワークのパフォーマンスを限界まで引き出す設計や、安全なデプロイパイプラインの構築ができるようになります。
PHPの裏側は、実に合理的で美しく設計されていますよね。この知見をぜひ、みなさんのWebシステムのアーキテクチャ設計に役立ててください!