【テクニカル・上級編】Zend VMの参照カウント(Refcount)と循環参照コレクタの挙動解析 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

Zend VMの深淵:参照カウントの物理構造と循環参照コレクタの正確な挙動解析

PHPの高速化とメモリ効率の追求において、Zend Engineがどのようにメモリを割り当て、解放しているかを理解することは、シニアエンジニアにとって必須の素養である。多くの開発者は「変数を代入すれば値がコピーされる」「スコープを抜けたらメモリは自動で解放される」という高水準な抽象化の恩恵に預かっている。しかし、1リクエストの寿命が数ミリ秒単位で競われる極限のWebシステムアーキテクチャにおいて、この「ブラックボックス」の内部構造を看破していなければ、予期せぬメモリリークやパフォーマンスの劣化(GCのストップ・ザ・ワールド的挙動)に対処することはできない。

本稿では、Zend VMのメモリ管理の根幹である参照カウント(Refcount)の低レイヤ構造、循環参照コレクタ(Garbage Collector)のトリガー条件、そしてそれらがOPcacheやFiber、さらにはオブジェクトインジェクションの脆弱性解析にどう結びついているかを、エンジン内部の挙動から徹底的に解き明かす。

—

1. Zend VMにおける変数の物理表現:`zval` と `refcount`

PHP 7以降、Zend Engineのデータ構造は劇的に刷新された。すべての値は16バイトの共用体構造体である `zval`(Zend Value)として表現され、メモリ上に配置される。

スカラー値(整数や浮動小数点数など)は `zval` の中に直接値が埋め込まれる(值渡し / Value-based)が、文字列、配列(HashTable)、オブジェクト、リソースなどの複合データや可変長データは、ヒープ領域に別個にアロケートされ、`zval` からはそのポインタが参照される。

ここで重要となるのが、`zval` のヘッダ部に存在する `u1.v.type_info` と、実体側構造体(例えば `zend_string` や `zend_array`)に埋め込まれた参照カウンタ(`refcount`)である。

/ PHPソースコード(Zend/zend_types.h)の概念的イメージ /
typedef struct _zend_refcounted {
uint32_t refcount; / 参照カウンタ /
union {
uint32_t type_info;
} u;
} zend_refcounted;

PHPコード上で変数代入や関数への引数渡しの際に行われるのは、原則として「Copy-on-Write(COW:書き込み時コピー)」を前提とした `refcount` のインクリメントである。

2. 参照カウントの限界と「循環参照(Circular Reference)」の罠

参照カウント方式の最大の弱点は、「自分自身、あるいは相互に参照し合う構造(循環参照)」が形成された場合、外部からの参照がすべて絶たれても `refcount` が 0 にならず、メモリリークを引き起こす点にある。

古典的なPHPスクリプトにおいて、意図せず循環参照を生む典型例を見てみよう。

children[] = $child; // child の zval の refcount がインクリメント
$child->parent = $parent; // parent の zval の refcount がインクリメント

// スコープを抜ける、あるいは変数を破棄
unset($parent, $child);

上記のコードを実行した時、`$parent` と `$child` の変数は破棄され、ローカルシンボルテーブルからエントリは消える。しかし、お互いに相手を指し合っているため、それぞれのオブジェクトの `refcount` は `1` のまま残存する。

これがミリ秒単位で何万回もリクエストを処理するPHP-FPMのワーカープロセス内で発生すれば、プロセスが終了するまでメモリは解放されず、OOM(Out of Memory)Killerの餌食となる。

—

3. 循環参照コレクタ(Garbage Collector)の内部アルゴリズム

Zend Engineはこの問題に対処するため、参照カウントとは独立した循環参照コレクタ(Concurrent Cycle Collector)を内蔵している。これはJavaやGoのような全域をスキャンするStop-The-World型のフルGCではなく、「バッファリングされた疑わしいルート(Root Buffer)」を対象にした世代別・三色標識法ベースのアルゴリズムである。

コレクションのトリガーと3つのカラー状態

Zend VMのGCバッファ(デフォルトでは上限数に達するか、明示的な `gc_collect_cycles()` が呼ばれた時)がいっぱいになると、コレクタが起動する。アルゴリズムは以下のフェーズで動作する。

1. 色づけ(Coloring – Grey):
バッファ内のすべてのコンテナ(配列やオブジェクト)を巡回し、参照カウントから「自分自身の内部で減算されたと仮定した仮想的な参照数」を計算する。この時点で見つかった候補を Grey(灰色) にマークする。
2. 減算スキャン(Simulated Decr):
グラフを再帰的にたどり、コンテナ内部から参照されている子要素の参照カウントを仮想的に `-1` する。これにより、もし「真に孤立した循環参照」であれば、参照カウントは `0` に落ちる。
3. 判定と回収(White / Black):

  • 仮想デクリメントの結果、参照カウントが `0` になったものは、外部からも参照されていない真のゴミとみなされ、White(白色)にマークされ回収対象となる。
  • 逆に、まだ外部から参照されているものが含まれていた場合は、参照カウントを復元し、Black(黒色)に戻してバッファから外す。

この一連のプロセスは、メモリ上に複雑なオブジェクトグラフを構築する大規模なフレームワーク(SymfonyやLaravelなど)において、メモリ肥大化を防ぐための最後の防壁として機能している。

—

4. OPcacheプリローディングとメモリ空間の共有

PHP 7.4以降で導入されたOPcacheプリローディング(Preloading)は、このメモリ管理モデルをさらに拡張する。通常、PHPスクリプトはリクエストごとにパースされ、AST(抽象構文木)を経てオペコード(Opcode)にコンパイルされ、プロセス固有のヒープに展開される。

しかし、プリローディングを使用すると、サーバー起動時(`php-fpm.conf` の `opcache.preload`)に指定されたスクリプト群がメモリ上に一度だけコンパイルされ、共有メモリ(SHM: Shared Memory)上に永続配置される。

; php.ini の極限チューニング例
opcache.enable=1
opcache.memory_consumption=512
opcache.interned_strings_buffer=64
opcache.max_accelerated_files=100000
opcache.preload=/var/www/html/config/preload.php

この共有メモリ上のオペコードや内部文字列(Interned Strings)は、各FPM子プロセスから読み取り専用(Read-Only)でマッシュアップされる。ここで参照カウントの挙動は特殊な変遷を辿る。プリロードされたクラス定義や関数は、プロセスごとのローカルヒープにコピーされることなく、共有メモリ上のポインタを直接指すため、リクエストライフサイクル中のオーバーヘッドが極限まで排除される。

—

5. Fiberによる並行処理とコンテキストスイッチ時のメモリ安全地帯

PHP 8.1で導入されたFiber(ファイバー)は、非同期プログラミングのパラダイムを劇的に変えた。スタックレスではなくスタックフル(Stackful Fiber)であるため、各Fiberは独自の実行コンテキスト(コールスタック、ローカル変数、Zend VMのエグゼキューション・グローバル状態)を保持する。

Fiber間でコンテキストスイッチが発生する際、Zend VMは以下のような低レイヤの操作を行う。

  • 現在実行中の `zend_execute_data` ポインタの退避と復元。
  • ヒープ上に割り当てられたFiberスタック領域の切り替え。

このとき、参照カウントとGCの観点で極めて重要なのは、「異なるFiber間で共有されるオブジェクトグラフの競合」である。PHPのFiberはプリエンプティ(占有型)ではなく、協調型(Cooperative)であるため、明示的なサスペンド(`Fiber::suspend()`)ポイント以外でコンテキストが切り替わることはない。したがって、レースコンディションによる `refcount` のアトミック操作の欠落(スレッドセーフではないPHPのシングルスレッド・イベントループモデル)は原則として回避される。

しかし、グローバルな状態やシリアライズされたオブジェクトをFiber間で受け渡す際には、不意の循環参照がメモリリークを引き起こすリスクが増大するため、非同期コンテキストを設計するアーキテクトは、タスク終了時のスコープアウトと `unset` のライフサイクルを厳密に制御しなければならない。

—

6. セキュリティハックへの応用:オブジェクトインジェクションとGadget Chainのメカニズム

Zend VMのメモリ管理とオブジェクトのライフサイクルを知り尽くすことは、防御だけでなく、攻撃者のベクター(脆弱性メカニズム)を正確に理解することに直結する。その最たる例がPHPオブジェクトインジェクション(PHP Object Injection)である。

脆弱なアプリケーションが、信頼できないユーザー入力をそのまま `unserialize()` に渡した場合、何が起きるか。

デストラクタ(`__destruct()`)やマジックメソッド(`__wakeup()`, `__toString()`, `__call()`)は自動的に発火する。

攻撃者はこの挙動を利用し、アプリケーション内に存在する無害なクラス群(これをGadget(ガジェット)と呼ぶ)を連鎖させ、メモリ上で意図したオブジェクトグラフを構築する(Gadget Chain)。

内部エンジンから見た攻撃の軌跡

1. `unserialize()` が実行され、ヒープ上に攻撃者が指定したプロパティを持つオブジェクトの `zval` が生成される。
2. スクリプトの実行が終了するか、変数が破棄される(あるいはガベージコレクタが動作する)際、Zend VMは該当オブジェクトの `refcount` が `0` になったことを検知し、メモリ解放プロセス(Destruction)をトリガーする。
3. この解放シーケンスの中で、Zend Engineはオブジェクトのハンドラテーブル(`zend_class_entry` の `destructor` ポインタ)を参照し、`__destruct()` メソッドの内部オペコード実行を強制する。
4. 攻撃者はこのマジックメソッドの内部で、システムコマンド実行関数(`system()`, `exec()` など)やファイル書き込み処理を呼び出すようにプロパティを改竄しており、結果としてリモートコード実行(RCE)が成立する。

防御策:
この脆弱性を根絶するためには、`unserialize()` にユーザー入力を直接渡さないことは当然として、PHP 7以降で導入された `allowed_classes` オプションを厳格に適用し、意図しないクラスのインスタンス化をエンジンレベルでシャットアウトしなければならない。

// 安全なアンシリアライズの強制
$data = unserialize($userInput, [‘allowed_classes’ => [SafeDTO::class]]);

—

結びにかえて

Zend VMの参照カウント、循環参照コレクタ、そしてOPcacheやFiberに至るメモリ管理のメカニズムは、単なる「言語の内部仕様」にとどまらない。それは、高トラフィックなWebアプリケーションのパフォーマンスを限界まで引き出し、予期せぬ脆弱性を未然に防ぐための「エンジニアの羅針盤」である。

コードの背後でPHPエンジンがどのようにメモリを確保し、どのように `refcount` を操作し、どのタイミングでGCが走っているのか。その物理的なイメージを脳内で完全にトレースできるようになった時、あなたの書くPHPコードは、もはや単なるスクリプトではなく、洗練された最高峰のシステムアーキテクチャへと昇華されるはずだ。

タイトルとURLをコピーしました