こんにちは。普段からLaravelやSymfony、あるいはGoやNode.jsといった他のモダンなエコシステムを深く渡り歩きながら、「ふと、PHPの裏側では一体何が起きているんだろう?」と知的好奇心を刺激されているあなたへ。
今日は、PHPという言語の心臓部である Zend VM(Zend Engine) の領域に踏み込み、C言語による拡張開発、そして最も避けて通れない「`zval`(Zend Value)の参照カウントとメモリ管理の闇」についてお話しします。
他の言語(例えばGoのガベージコレクションや、Rustの厳格な所有権モデル)に慣れている人ほど、PHP拡張の世界に入った瞬間にこの `zval` の手動管理で壁にぶつかります。ですが、ご安心ください。ここでのルールは非常にシンプルで、一度エンジンのメモリ空間の挙動を脳内にインストールしてしまえば、メモリリークやセグメンテーション違反(Segmentation Fault)など怖くありません。
今日は、Zend APIの裏側を覗きながら、Valgrindを駆使してメモリの不正を暴く極意を一緒に紐解いていきましょう。
—
1. Zend VMの基礎:なぜPHPの変数(`zval`)は一筋縄ではいかないのか
私たちが普段何気なく書いている `$a = “Hello World”;` というPHPのコード。Zend Engineの内部では、これは単なる文字列ではなく、`zval`(Zend Value) というC言語の構造体(`_zval_struct`)として表現され、メモリ上に配置されます。
PHP 7以降の `zval` は非常に洗練されており、サイズは一律で16バイト(64bit環境)に最適化されました。この小さな構造体の中に、データの「型(type)」と、実際の「値(value)」または「値へのポインタ」が美しくパッキングされています。
// Zend Engine (Zend/zend_types.h) における zval の概念構造(簡略化)
struct _zval_struct {
zend_value val; // 実際の値、またはヒープ上のデータへのポインタ
union {
struct {
ZEND_ENDIAN_LOHI_4(
zend_uchar type, // データ型(IS_LONG, IS_STRING, IS_ARRAY 等)
zend_uchar type_flags, // 型フラグ(コンパイル時最適化などのヒント)
zend_uchar const_flags, // 定数フラグ
zend_uchar reserved // 予約領域
)
} v;
uint32_t type_info;
} u1;
union {
uint32_t next; // ハッシュ衝突時の次要素(内部構造用)
uint32_t cache_slot; // キャッシュスロット
uint32_t num_args; // 関数の引数の数
uint32_t fe_sep; // foreachのセパレーション用
uint32_t refcount; // ★参照カウント(GC管理用)
} u2;
};
ここでお伝えしたい最も重要なポイントは、PHPの拡張(C言語)を書くということは、この `zval` の生成、値の代入、そして「参照カウント(`refcount`)のインクリメント/デクリメント」を全て自分自身の責任でコントロールするということです。
もし、新しく作った `zval` の参照カウントを減らし忘れたり、解放すべきメモリを二重解放(Double Free)したりすると、PHP-FPMのプロセスは容赦なくクラッシュするか、じわじわとメモリリーク(ZendMMリーク)を引き起こします。
—
2. 拡張開発における「参照カウント」地獄の罠
では、実際にC拡張を書く際によくある「やらかし」をイメージしてみましょう。
例えば、ユーザーランド(PHPスクリプト)から受け取った引数を操作し、新しい配列を返却するようなC関数を実装するとします。
ありがちなアンチパターン(メモリリークの温床)
// 悪い例:zvalを新しく生成したが、参照カウントやメモリ解放の整合性が崩れている
PHP_FUNCTION(my_leaky_function) {
zval my_array;
// 配列用のzvalをメモリ確保して初期化
array_init(my_array); // 内部でemalloc()が走る
// 何かデータを詰める
add_next_index_string(my_array, “Zend Engine Internals”);
// リターンバッファ(return_value)に渡すつもりで、
// ここでコピーや代入の作法を誤ると、メモリリークの完成です
RETVAL_ZVAL(my_array, 1, 0);
// 「あ、自分で作ったから解放しなきゃ」と勘違いしてefreeすると、
// Zend VM側で二重解放が起き、Apache/Nginxごとプロセスが落ちます
// efree(my_array);
}
「どこが間違っているの?」と思われるかもしれませんね。
ここがZend APIの巧妙な罠です。`array_init()` はメモリを割り当てて `refcount` を `1` に設定します。しかし、それを返す際のマクロ(`RETVAL_ZVAL` や `RETURN_ZVAL`)の第2引数(`copy`)や第3引数(`dtor`)の解釈を誤ると、メモリ空間に取り残されたゴーストデータが生まれ、リクエストが終了してもZend Memory Manager(ZendMM)に回収されずに残ってしまいます。
—
3. 正しい `zval` 操作のベストプラクティス
Zend Engineのメモリ管理原則は、突き詰めれば以下の3つに集約されます。ここを抑えれば、もう迷うことはありません。
1. 返り値には `RETVAL_` または `RETURN_` マクロを使う
PHP関数が値を返すときは、あらかじめエンジン側から用意されている `return_value` ポインタに対して値を設定します。自分で `emalloc` した `zval` をそのままぶら下げてはいけません。
2. 変数を複製(デュプリケート)するときは `Z_ADDREF_P` を使う
既存の `zval` を別のコンテナ(配列やオブジェクト)に格納し、共有する場合は、必ず参照カウントを `+1` します。
3. 不要になった `zval` は `zval_ptr_dtor()` に委譲する
C言語の素朴な `free()` や `efree()` を直接呼ぶのではなく、Zend Engineのデストラクタである `zval_ptr_dtor()` を呼び出します。これにより、参照カウントがチェックされ、0になったタイミングで安全にメモリが解放されます。
美しい拡張コードの実装例
// 正しい例:メモリリークを起こさない安全なzvalの構築と返却
PHP_FUNCTION(my_safe_function) {
zval tmp_val;
// 1. 返り値用の配列を初期化(return_valueはPHP側が用意したzvalポインタ)
array_init(return_value);
// 2. 一時的な文字列zvalを作成
ZVAL_STRING(&tmp_val, “Mastering Zend VM”);
// 3. 配列に要素を追加
// add_next_index_zval は、内部でtmp_valの参照カウントを適切に処理(またはコピー)します
add_next_index_zval(return_value, &tmp_val);
// 注意: ZVAL_STRINGで作成した文字列はヒープに実体を持ちます。
// 配列に追加された時点でその所有権は配列側に移譲(あるいは値がコピー)されるため、
// ここで別途 zval_ptr_dtor(&tmp_val) を呼ぶ必要性・挙動は、使用するAPIのセマンティクスに依存します。
// (※文字列リテラルの場合はZVAL_STRINGL等で長さを指定し、zend_stringのrefcount管理に従います)
}
—
4. Valgrindを使ったメモリリークの解析手法
どれだけコードレビューを入念に行っても、C言語とZend VMの境界線では、予期せぬメモリリークや不正アクセス(読み取り・書き込みエラー)が潜むことがあります。
ここで、私たちインフラストラクチャ・エンジニアの最強の味方である Valgrind の登場です。
「PHP拡張のデバッグにValgrind?」と思われるかもしれませんが、Zend Engine自体がメモリ管理を自前で行う(ZendMM)ため、そのまま起動するとZendMMが抱え込んでいる大量の内部キャッシュまでリークとして検知されてしまいます。
そのため、Valgrindで正確な解析を行うには、ZendMMをバイパスしてシステムの標準アロケータ(malloc/free)を直接使わせるモードでPHPをビルド・起動する必要があります。
手順1: ZMALLOCを無効化してPHPをビルドする(または環境変数を使う)
開発用のPHPをソースからビルドする際、あるいは実行時に、以下の環境変数を指定するのが最も手っ取り早いです。
ZendMMによる独自メモリプールの割り当てを無効化し、glibcのmalloc/freeに直接委譲させる
export USE_ZEND_ALLOC=0
手順2: ValgrindでPHPスクリプトを直撃する
拡張のバグ(例えば `zval_ptr_dtor` の漏れ)を検証するために、CLI経由でValgrindを実行します。
valgrind –leak-check=full \
–show-leak-kinds=all \
–track-origins=yes \
–log-file=valgrind_php.log \
php -d extension=./modules/my_extension.so -r ‘my_safe_function();’
手順3: ログを読み解く
生成された `valgrind_php.log` を開きます。もしあなたの書いたC拡張の中で `emalloc` や `zend_string_alloc` したメモリが解放されていない場合、以下のような見事なリーク情報が記録されます。
==12345== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12345== at 0x4C29BDF: malloc (vg_replace_malloc.c:299)
==12345== by 0x10FB2A: my_leaky_function (my_extension.c:14)
==12345== by 0x5E21F0A: execute_ex (zend_execute.c:55)
==12345== …
「`my_extension.c` の14行目、`my_leaky_function` の中で確保された40バイトが確実にロスト(Definitely lost)している」と、Valgrindがピンポイントで教えてくれます。
ここまで分かれば、該当箇所の参照カウントのデクリメント漏れや、`efree()` / `zval_ptr_dtor()` のし忘れを修正するだけです。
—
5. 先輩アーキテクトからのメッセージ
いかがでしたでしょうか?
PHPの裏側で動くZend VMの構造、そしてC拡張における `zval` のライフサイクル管理の世界が、少しだけ解像度高く見えてきたのではないでしょうか。
「たかがスクリプト言語の拡張」と思って足を踏み入れると、そこにはC言語レベルのシビアなメモリ管理と、Zend Engine独自の洗練された世界が広がっています。しかし、このレイヤーを深く理解し、Valgrindを片手にメモリリークを綺麗に駆逐できるようになったとき、あなたのPHPに対する理解は、単なる「フレームワークの使い手」から「言語の挙動を支配するアーキテクト」へと劇的に進化しているはずです。
複雑なバグやメモリの壁にぶつかったときこそ、「あ、今Zend VMのメモリ空間と対話しているんだな」と楽しむ余裕を持って、極限まで美しいコードを追求していきましょう。あなたのエンジニアリングライフのブレイクスルーになれば嬉しいです。