【テクニカル・上級編】Zend VMにおける参照カウント(Refcount)とCopy-on-Writeの境界条件:循環参照がGCのトリガーを引くまでのメモリ遷移 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

Zend VMにおける参照カウントとCopy-on-Writeの境界条件:循環参照がGCのトリガーを引くまでのメモリ遷移

PHPの実行速度、それはZend VMのメモリ管理モデルをどれだけ正確にハックしているかに依存する。
世のチュートリアルは「変数は箱である」という童話のような比喩で初学者を欺くが、プロフェッショナルなアーキテクトであれば、すべてのデータが`zval`(Zend Value)というC言語の構造体としてヒープ上にアロケートされ、`zend_refcounted_h`によって厳密にライフサイクルを管理されている現実を知っているはずだ。

今回は、Zend VMの根幹をなす参照カウント(Refcount)とCopy-on-Write(CoW)の不可分な関係、そしてオブジェクトや配列が陥る「循環参照」が、いかにしてガベージコレクタ(GC)のバッファを満たし、エンジンに強制的な回収サイクルを踏ませるのか、その極限のメモリ遷移を解き明かす。

—

1. `zval`の物理構造とCopy-on-Writeの限界

PHP 7以降、`zval`のサイズは16バイト(64bit環境)に最適化された。この小さな構造体が、スカラ値から巨大な配列、オブジェクトまでをハンドリングする。

typedef struct _zval_struct {
zend_value value; // 8バイト: 実際のデータまたはポインタ
union {
uint32_t type_info; // 型情報と各種フラグ
// …
} u1;
union {
uint32_t var_flags;
// …
} u2;
} zval;

配列(`array`)や文字列(string、特にパース時や長大なデータ)を別の変数に代入した際、即座にメモリの複製(深いコピー)が行われないことは周知の事実だ。Zend VMはCopy-on-Write(CoW)を採用し、`zend_refcounted`の参照カウンタをインクリメントするだけでポインタを共有する。

しかし、このCoWが機能しなくなる境界条件が存在する。それが、オブジェクト(`object`)の振る舞いだ。

配列のCoWとオブジェクトの参照セマンティクス

PHPのオブジェクトは、初期設計の段階から「参照渡し(正確にはオブジェクトハンドルの共有)」として扱われる。オブジェクトを別の変数に代入しても、参照カウンタは増えるが、配列のように書き込み時に分離(Split)することはない。

class Node {
public ?Node $next = null;
}

$a = new Node();
$b = $a; // $bは$aと同じオブジェクトハンドルを指す(CoWではなく同一実体の共有)
$b->next = new Node(); // $a->nextも影響を受ける

この仕様の違いを理解していないと、複雑なドメインモデルを構築した際に意図しない副作用を生み、さらにはZend VMのメモリ管理機構を致命的なループへ追い込むことになる。

—

2. 循環参照の誕生と `GC_PURPLE` への遷移

配列やオブジェクトが自分自身、あるいは互いを参照し合う構造(Self-referencing / Cyclic reference)を作ったとき、Zend VMのメモリ管理に歪みが生じる。

以下のコードを見てみよう。

// 循環参照の典型例
$a = [];
$a[‘self’] = &$a;

unset($a);

ここで何が起きているのか?
通常、`unset($a)`を実行すると、`$a`が指していた配列のzvalの参照カウンタ(`refcount`)が `1` 減算される。しかし、配列自身が自分自身の要素を指しているため、配列の`refcount`は `0` にならず、`1` が残存する。

もしガベージコレクタ(GC)が存在しなければ、このメモリ領域はプロセスが終了するまで解放されない。これがメモリリークの正体である。

Zend VM GCの3色マーキングアルゴリズム

PHPのガベージコレクタは、世代別ではなく、循環参照の疑いがあるルートバッファ(Root Buffer)を監視する同期型のマーク・スイープ方式を採用している。

1. バッファへの登録(`GC_MAYBE_LEAF` からの離脱):
参照カウンタを持つ構造体(配列やオブジェクト)のカウンタが減算された際、その値が `0` にならなかった場合、Zend VMはその構造体が「循環参照の構成要素になった可能性」があるとみなして、GCのルートバッファにバッファリングする。この時、ノードの状態は `GC_Buffered` となる。
2. ルートバッファの満杯(デフォルトで10,000要素):
バッファが溢れるか、あるいは明示的に `gc_collect_cycles()` が呼ばれた瞬間、GCエンジンが起動する。
3. 物理的なスキャンとカラーリング:

  • `GC_GREY`: 候補となったzvalに対し、再帰的に参照カウンタから「内部からの参照分」を仮に減算していく。
  • `GC_WHITE`: 仮減算の結果、参照カウンタが `0` になったzvalを「ゴミ(孤立した循環参照群)」と断定し、白にマークする。
  • `GC_BLACK`: 外部からの参照がまだ生きていると判明したzvalを黒に戻し、カウンタを復元する。

4. スイープ(Sweeping):
`GC_WHITE`に染まったzval群を特定し、デストラクタを呼び出した上で、HashTableおよびzval本体をメモリプールへ返却する。

この一連のプロセスは、CPUキャッシュを大量に消費し、Zend VMの実行を一時的にブロッキングする。高スループットを要求されるWebアプリケーションにおいて、GCの頻発は致命的なレイテンシの悪化(GCパニック)を引き起こす。

—

3. OPcacheとプリローディングにおけるメモリ空間の罠

PHP 7.4で導入され、現代のプロダクション環境では必須となったOPcache Preloading。スクリプトをパースし、Zend VMのオペコード(Opcode)にコンパイルした上で、共有メモリ(SHM: Shared Memory)上に永続化するこの技術は、ファイルI/Oとコンパイルコストをゼロにする。

しかし、ここに循環参照を持ったデータ構造や定数、あるいはグローバルに近い静的プロパティを配置すると、深刻なセキュリティおよびパフォーマンス上の問題が発生する。

共有メモリ(SHM)とCopy-on-Writeの衝突

OPcacheのプリローディングによって読み込まれたクラスや関数は、すべてのPHP-FPMワーカープロセス間で共有される。もし、プリロードされたクラスの静的プロパティ(`public static`)に循環参照が含まれている場合、そのメモリ領域は親プロセス(FPMマスター)の空間で固定化される。

子プロセス(Worker)がリクエストを受け、その静的プロパティにアクセス・変更を試みた瞬間、SHM上のメモリに対してCoWが発生し、子プロセスのプライベートメモリ(Anonymous Memory)へとページがコピーされる(Cow Fault)。
大規模なデータ構造がプリロード時に循環参照を含んでいると、各ワーカープロセスが独自のメモリ空間にそれを展開し合い、結果としてOS全体の実メモリ(Resident Set Size: RSS)を圧迫し、OOM Killerの餌食となる。

対策の極意:
プリローディングの対象とするコードベースには、巨大なデータ構造の静的保持、特にORMのマッメタデータやキャッシュコンテナの静的プロパティ内での循環参照を一切排除しなければならない。

—

4. アーキテクトが実践すべきメモリ設計の極意

PHPエンジンを限界までチューニングし、メモリリークとGCの暴走を防ぐためには、以下のアーキテクチャ原則をコードに落とし込む必要がある。

① 弱参照(WeakReference)の積極的活用

PHP 7.4以降で導入された `WeakReference` は、Zend VMの参照カウンタをインクリメントせずにオブジェクトを指し示す強力な手段である。親子関係(ツリー構造やグラフ構造)において、子から親へのバックポインタ(逆方向の参照)が必要な場合、通常のプロパティ代入ではなく、必ず弱参照を使用する。

class ParentNode {
/ @var ChildNode[] /
public array $children = [];
}

class ChildNode {
public ?WeakReference $parent = null;

public function __construct(ParentNode $parent) {
// 親の参照カウンタを増やさずに保持する(循環参照を完全に回避)
$this->parent = WeakReference::create($parent);
}
}

この設計であれば、`ParentNode` を `unset()` した瞬間に参照カウンタは確実に `0` になり、GCの介入すら必要とせず即座にメモリが解放される。

② デストラクタ(`__destruct`)と循環参照の悪夢

PHPにおいて、循環参照に含まれているオブジェクトに `__destruct()` が定義されている場合、Zend VMのGCは「どの順番でデストラクタを呼べばいいか分からない」というジレンマに陥る。
そのため、PHP 5/7の初期の頃は、循環参照の中にデストラクタを持つオブジェクトが含まれていると、メモリリークとして永遠に回収されない(Leak Memory)という仕様上のバグ(あるいは制限)が存在した。

現代のPHP(PHP 8系)では、GCが改善され、循環参照内のデストラクタも適切に処理されるようになったが、依然としてデストラクタの実行順序は非決定的(Non-deterministic)である。
ドメインロジックの終了処理を `__destruct` に依存する設計そのものが、Zend VMのメモリモデルにおいてアンチパターンであると心得よ。

—

5. 結び:エンジンを支配する者のみがWebを制す

PHPは「手軽なスクリプト言語」ではない。C言語の薄いラッパーであり、Zend VMという極めて高度な仮想マシン上で稼働するリアルタイム・コンパイラ・ランタイムである。

`zval`の挙動を脳内でトレースし、Copy-on-Writeの境界線を理解し、循環参照がGCのトリガーを引くメカニズムをコントロール下におくこと。それこそが、数百万リクエストを裁く高負荷システムを無停止で稼働させ続ける、真のWebシステムアーキテクトの条件である。

フレームワークの背後でうごめくZend VMの息吹を感じ取れ。メモリは、ただ消費されるためにあるのではない。管理し、掌握するためにあるのだ。

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