【テクニカル・上級編】PHPのGC(ガベージコレクション)におけるマークフェーズとスイープフェーズのZend VM内部実装詳細 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

PHPエンジンの深淵:Zend VMにおけるガベージコレクション(マーク・スイープフェーズ)の内部実装とメモリ管理の極意

PHPは「スクリプト言語だからメモリ管理はエンジンが勝手にやってくれる」という認識のままでいると、大規模な高負荷システムを構築した瞬間に壁に突き当たる。1リクエストあたりのメモリフットプリントを極限まで削り、数万のオブジェクトが入り乱れる巨大なオブジェクトグラフを効率的に回収するためには、Zend Engineが内部でどのようなデータ構造を持ち、どのようにメモリを解釈しているのかを熟知していなければならない。

本稿では、PHPのガベージコレクション(GC)の核心であるマーク・スイープフェーズのZend VM内部実装に焦点を当て、C言語レベルのメモリ空間、参照カウントの挙動、そして循環参照がどのように検知され破棄されるのかを、圧倒的な低レイヤの視点から解き明かす。

—

1. Zend Engineにおけるメモリの基本単位:`zval` と参照カウント

PHPのすべての変数、すべてのデータ構造は、C言語の共用体である `zval`(Zend Value)構造体として表現されている。Zend Engineのメモリ管理の根底にあるのは参照カウント(Reference Counting)だ。

typedef struct _zval_struct {
zend_value value;
union {
struct {
ZEND_ENDIAN_LOHI_4(
zend_uchar type, // データ型(IS_STRING, IS_OBJECT, IS_ARRAY 等)
zend_uchar type_flags, // 型フラグ
zend_uchar const_flags,
zend_uchar reserved)
} v;
uint32_t type_info;
} u1;
union {
uint32_t var_flags;
uint32_t next; // ハッシュ衝突時のチェインやフリーリスト用
uint32_t cache_slot; // OPcacheのキャッシュスロット
uint32_t linfo;
} u2;
} zval;

変数代入や関数への値渡しが行われるたびに、値がプリミティブであればコピーオンライト(Copy-on-Write: CoW)が発動し、オブジェクトや配列であればポインタの共有と参照カウンタのインクリメント(`gc.refcount`)が行われる。

参照カウントの限界と循環参照の罠

単純な参照カウント方式には致命的な欠点がある。それは、自分自身を指す、あるいは互いに参照し合う循環参照(Circular Reference)が発生した場合、スコープを抜けて変数へのポインタが消滅しても、お互いの `refcount` が「1」以上残るため、参照カウント方式だけではメモリが永遠に解放されないという点だ。

この「メモリリークの温床」を断ち切るために導入されたのが、PHP 5.3以降に搭載され、世代を追うごとに洗練されてきたコンカレントな循環参照ガベージコレクタである。

—

2. GCルートバッファ(GC Buffer)のメカニズム

Zend Engineは、すべての `zval` を逐一スキャンするような非効率な真似はしない。そんなことをすれば、1リクエストの実行時間はスキャン処理だけで爆発的に肥大化してしまう。

代わりに、エンジンは「循環参照の発生源になり得る型(主に `IS_ARRAY` と `IS_OBJECT`)」の参照カウントが「デクリメントされたが、0にはならなかった」瞬間を検知し、その候補をGCルートバッファ(Circular Buffer)にバッファリングする。

バッファリングの条件

1. `zval` の型がコンテナ(配列またはオブジェクト)である。
2. 参照カウントが減少した(例:`unset()` や変数のスコープアウト)が、0にはならなかった。
3. まだGCルートバッファに登録されていない。

この条件を満たした段階で、その `zval` は `gc_root` として二重双方向リストに繋がれる。バッファが一定数(デフォルトでは10,000エントリ)に達すると、自動的に(あるいは明示的な `gc_collect_cycles()` の呼び出しによって)ガベージコレクションのアルゴリズムが発動する。

—

3. マーク・スイープフェーズのZend VM内部実装

PHPのGCアルゴリズムは、ベリ・ディッチ(Bacon, Attanasio, Lee, Rajan, Smith)らが提唱した循環参照ガベージコレクションアルゴリズムをベースに、Zend Engine向けに最適化されている。

処理は大きく分けて以下の4つのフェーズ(内部的にはマーク・スイープの変形)で構成される。

[ステップ 1: 根のバッファ走査 (Color: GREY)]
↓
[ステップ 2: グラフの逆方向探索 (Decrement & Color: WHITE)]
↓
[ステップ 3: 生きているノードの復元 (Color: BLACK)]
↓
[ステップ 4: ゴミの回収・スイープ (Sweep & Free: WHITE)]

ステップ 1: バッファの走査と「灰色(GREY)」へのマーキング

GCルートバッファに登録されたすべてのコンテナに対して、深さ優先探索(DFS)またはそれに準ずる走査を行う。

  • 各ノードのカラーフラグを `GC_GREY` に変更する。
  • ここでのポイントは、コンテナが保持している子要素(プロパティや配列の要素)の参照カウントを一時的にシミュレーションとしてデクリメントしていく点だ。これは「もしこの親が外部からの参照を失っていた場合、子要素の参照カウントはどうなるか」を測定するための前処理である。

ステップ 2: グラフの逆方向探索と「白色(WHITE)」の確定

`GC_GREY` にマークされたノードからさらに子孫を辿る。

  • もし子要素の(シミュレーション上の)参照カウントが `0` に達した場合、その子要素は「外部からの正当な参照を失い、完全に孤立した循環の中だけで支えられている可能性が高い」と判断し、カラーを `GC_WHITE` に変更する。
  • 逆に、シミュレーション上の参照カウントが `1` 以上であれば、そのノードはバッファ外の外部変数からまだ参照されているため、次のステップで救出される対象となる。

ステップ 3: 生きているノードの復元(「黒色(BLACK)」への昇格)

孤立していない(外部から参照されている)ことが判明したノード、およびその子孫を再走査する。

  • シミュレーションで減らした参照カウントを元に戻し(復元)、カラーを `GC_BLACK`(または通常のアイドル状態)に戻す。
  • これにより、真の「ゴミ(孤立した循環参照)」だけが `GC_WHITE` として浮き彫りになる。

ステップ 4: スイープフェーズ(ゴミの解放)

最後に、バッファをもう一度走査し、カラーが `GC_WHITE` のまま残っているノードを特定し、メモリから物理的に解放(Destruct & Free)する。

  • オブジェクトの場合は `__destruct()` メソッドの呼び出しキューイングが行われ、ハッシュテーブルの破壊、メモリブロックの解放(ZendMMへの返却)が実行される。

—

4. 実践:循環参照とGCの挙動をコードでトレースする

以下のPHPコードは、意図的にオブジェクトの循環参照を発生させ、Zend EngineのGCがどのようにメモリを回収するかを示すものである。

name = $name;
echo “Node {$this->name} が生成されました。\n”;
}

public function __destruct() {
echo “Node {$this->name} が破棄されました(__destruct 実行)。\n”;
}
}

// GCの状態を手動で確認・制御するための関数群を有効化
gc_enable();

echo “— オブジェクトの循環参照を構築 — \n”;
$parent = new Node(“Parent”);
$child = new Node(“Child”);

// 循環参照の形成 ($parent -> $child, $child -> $parent)
$parent->child = $child;
$child->child = $parent;

// 変数のスコープを切る(参照カウントは 1 のこるため、通常の代入解除では消えない)
unset($parent, $child);

echo “— 循環参照作成後のメモリ状態(この時点ではまだ破棄されていない) — \n”;
echo “現在のGC収集回数: ” . gc_status()[‘collected’] . “\n”;

// ガベージコレクションを明示的に強制実行
$collectedCycles = gc_collect_cycles();
echo “GCによって回収された循環の数: {$collectedCycles}\n”;

/
実行結果のイメージ:
— オブジェクトの循環参照を構築 —
Node Parent が生成されました。
Node Child が生成されました。
— 循環参照作成後のメモリ状態(この時点ではまだ破棄されていない) —
現在のGC収集回数: 0
GCによって回収された循環の数: 2
Node Parent が破棄されました(__destruct 実行)。
Node Child が破棄されました(__destruct 実行)。
/

このコードにおいて、`unset($parent, $child)` を実行した瞬間、OSやプロセスレベルのメモリは即座には解放されない。Zend EngineのGCルートバッファにこれが蓄積され、`gc_collect_cycles()` が呼び出された(あるいはバッファ溢れが発生した)瞬間に、前述のマーク・スイープフェーズが走ってはじめて `__destruct()` が呼ばれ、メモリが解放されるのだ。

—

5. アーキテクトが知るべきパフォーマンス最適化とセキュリティの罠

Zend VMのGCとメモリ管理の仕組みを理解していると、システム設計や脆弱性分析において強力な武器となる。

A. OPcacheプリローディングとGCの関係

PHP 7.4以降で導入されたOPcacheプリローディング(Preloading)では、スクリプトのバイトコードだけでなく、クラス定義や関数、さらには特定の不変なオブジェクト構造(定数配列や特定のオブジェクト)を共有メモリ(SHM)に永続化する。
プリロードされたデータはプロセス起動時に一度だけメモリ上に構築され、リクエストごとにコピーされることなく共有されるため、リクエストライフサイクル中のGCの負荷を劇的に軽減する。しかし、プリロードされたデータ構造内に循環参照を作り込んでしまうと、共有メモリ領域でのメモリリークやセグメンテーション違反(Segfault)を引き起こす原因になるため、設計には細心の注意が必要だ。

B. PHPオブジェクトインジェクション(Object Injection)とGadget Chainの脅威

セキュリティの文脈において、Zend VMのオブジェクトライフサイクルは攻撃者にとっても重要なターゲットとなる。
`unserialize()` 関数に信頼できないユーザー入力を渡してしまうと、任意のクラスのインスタンスが復元される。このとき、Zend Engineはシリアライズされたバイトストリームをパースし、オブジェクトのプロパティを復元した上で、必要に応じて `__wakeup()` や `__destruct()` などのマジックメソッドを自動的に呼び出す。

攻撃者は、既存のアプリケーションコード内に存在するクラス群(Gadget)を巧みに組み合わせ、次のような連鎖(Gadget Chain)を構築する。
1. `unserialize()` によって不正なオブジェクトが生成される。
2. リクエストの終了時、あるいはGCのスイープフェーズや通常のスコープアウトに伴い、そのオブジェクトが破棄される。
3. デストラクタ(`__destruct()`)や終了処理マジックメソッドがトリガーされ、攻撃者が意図したメソッド(ファイル書き込み、リモートコード実行、SQLインジェクション等)が実行される。

この脆弱性を根本から防ぐためには、`unserialize()` に渡すデータを厳格に検証するか、可能な限り `json_decode()`(連想配列やDTOへのマッピング)を採用し、任意のクラスインスタンスの動的復元を排除するアーキテクチャ設計が不可欠である。

—

終わりに

PHPのガベージコレクションは、単なる「お掃除機能」ではない。Zend EngineのC言語レベルでのポインタ操作、ハッシュテーブルの競合解決、そして `zval` のカラーリングによる巧妙なグラフ理論の応用によって成り立っている。

真に堅牢でスケーラブルなWebシステムを設計するアーキテクトは、フレームワークの背後でPHPエンジンがどのようにメモリを割り当て、どのように回収しているのかの「鼓動」を常に感じ取っていなければならない。メモリの寿命を制する者が、PHPのパフォーマンスとセキュリティを制するのだ。

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