Zend MMとヒープの深淵:Swoole長期常駐プロセスにおける「不可視のメモリリーク」を断つ
PHPは「1リクエスト終われば全メモリが解放される」という美しい砂上の楼閣の上に成り立ってきた。CGI時代からFPMに至るまで、OSプロセスはリクエストの終端で`zend_request_startup()`と対をなす`zend_request_shutdown()`を執行し、プロセスが抱えていた一時的なヒープ領域は綺麗さっぱりOSへと返却される(正確にはOSのページテーブル上の管理が変わるか、Zend MMのプールに戻る)。
しかし、SwooleやRoadRunnerに代表される「長期常駐型PHPプロセス(Long-running process)」の世界に足を踏み入れた瞬間、この前提は崩れ去る。リクエストを跨いで蓄積されるデータ、グローバルスコープの汚染、そして何よりもZend Memory Manager(Zend MM)の内部アロケーションにおける「断片化(Fragmentation)」が、静かに、しかし確実にプロセスを蝕む。
今回は、Zend MMがヒープをどのように切り刻み、なぜSwoole環境で物理メモリが枯渇するのか、その低レイヤの真因を解き明かす。
—
1. Zend MMの構造:リクエストプールと`zend_mm_heap`の正体
PHPのメモリ管理は、libcの`malloc()`や`free()`をそのまま叩いていない。システムコールを毎度呼び出すオーバーヘッドを隠蔽し、アロケーションの速度を極限まで高めるために、Zendエンジンは独自のメモリマネージャ(Zend MM)を内包している。
Zend MMの心臓部は `zend_mm_heap` 構造体であり、メモリは大きく分けて以下の3つのサイズクラスで管理される。
1. Small allocations: 数バイトから数千バイトの小さなデータ。`zend_mm_chunk` 内のバケツ(Bucket)構造で高速に管理される。
2. Medium allocations: 中サイズのデータ。
3. Large allocations: 一定サイズを超える巨大なデータ。これはZend MMのプールをバイパスし、直接OSから`mmap()`や`malloc()`で確保される。
ChunkとPageの物理レイアウト
Zend MMは、OSから巨大なメモリチャンク(デフォルトでは通常2MB単位の `ZEND_MM_CHUNK_SIZE`)をまとまった単位で一括取得する。このチャンクはさらに小さなページ(通常4KB単位)に分割される。
[ zend_mm_chunk (2MB) ]
├── [ page 0 (4KB) ] -> Small/Medium Block群
├── [ page 1 (4KB) ] -> Small/Medium Block群
└── …
リクエストが走ると、Zend MMはこのチャンク内から高速にメモリを切り出し(Free listからのポインタ操作)、リクエスト終了時にはそのポインタを「まとめて」未使用状態に戻す。FPMであれば、プロセス終了時にチャンクごとOSに返却されるため、断片化が問題になる暇すらない。
—
2. Swoole環境における「断片化(Fragmentation)」のメカニズム
長期常駐プロセスでは事情が全く異なる。数百万回のリクエストを処理し続ける中で、以下のようなライフサイクルの異なる変数が無数に生成・破棄される。
- 非同期タスクのペイロード(数MBの配列やオブジェクト)
- コルーチン(Fiberベース)のローカル変数やスタックフレーム
- キャッシュやコネクションプールの永続的保持データ
ここで何が起きるか。「メモリアロケーションと解放の順序がランダム化する」ことによる、典型的な外部断片化(External Fragmentation)である。
断片化のプロセス
1. 2MBのチャンク内に、サイズA、サイズB、サイズCのブロックがランダムに割り当てられる。
2. サイズBのブロック(例: 一時的なJSON文字列)が解放される。これにより、使用中メモリの間に「微小な空きスロット(隙間)」が生まれる。
3. 次のリクエストで、サイズBより大きいサイズDのデータが要求される。
4. その隙間にはサイズDが入らないため、Zend MMは新しいチャンクをOSから追加で取得(`mmap()`)せざるを得なくなる。
5. 結果として、メモリ内には「使われていないが、小さすぎて再利用できない隙間(断片)」が無数に発生し、プロセス全体のRSS(Resident Set Size)だけが右肩上がりに肥大化していく。
OSから見ればメモリは消費されているが、PHPの内部カウンター(`memory_get_usage()`)から見ると「解放済み」であるため、プロファイラでは検知しにくい。これが、長期常駐型PHPにおける「不可視のメモリリーク」の正体である。
—
3. 実践:断片化を引き起こすアンチパターンとコードの検証
長期常駐プロセスにおいて、以下のようなコードはZend MMの断片化を加速させる極限の地雷となる。
on(“Request”, function (Request $request, Response $response) use (&$globalCache) {
// 1. リクエストごとにランダムなサイズの巨大な文字列・配列を生成して破棄する
$size = mt_rand(1024, 1024 512); // 1KB ~ 512KB
$data = str_repeat(‘X’, $size);
// 2. 一部をグローバルキャッシュに一時的に保持し、後でunsetする(断片化のトリガー)
$cacheKey = $request->server[‘request_uri’];
$globalCache[$cacheKey] = $data;
if (count($globalCache) > 100) {
// 古いものを削除するが、Zend MMのヒープ上では不規則な穴が空く
array_shift($globalCache);
}
// 処理完了
$response->end(“Memory Usage: ” . memory_get_usage(true));
});
$server->start();
このコードをSwoole上で数万リクエスト走らせると、`memory_get_usage(true)`(OSから確保しているリアルメモリサイズ)は一向に下がらず、むしろ増加し続ける現象に直面する。Zend MMが「空き領域はあるが連続した領域がない」と判断し、次々と新しいChunkを要求し続けるからだ。
—
4. 対策:Zend VMの限界を看破し、アーキテクチャで制圧する
この極限状態に対抗するためには、PHPコードレベルのチューニングだけでなく、Zend VMの挙動を見据えた設計が必要不可欠となる。
1. OPcacheプリローディング(Preloading)の活用と物理構造
OPcacheのプリロードは、起動時にスクリプトをパースし、AST(抽象構文木)を共有メモリ(SHM)上に永続化する。これにより、リクエストごとのスクリプトコンパイルコストが消滅するだけでなく、関数やクラス定義がプロセス間で共有されるため、Zend MMのヒープ汚染を劇的に軽減できる。
プロダクション環境での `php.ini` 設定例:
opcache.enable=1
opcache.memory_consumption=512
opcache.interned_strings_buffer=64
opcache.max_accelerated_files=20000
opcache.enable_cli=1
; プリロードスクリプトの指定
opcache.preload=/var/www/html/config/preload.php
opcache.preload_user=www-data
2. コルーチン・Fiber環境におけるメモリ管理の鉄則
SwooleやOpenSwoole、あるいはPHP 8.1以降のFiberを使用する場合、コンテキストスイッチが発生してもスタック領域やローカル変数は適切に解放されるべきである。
- 巨大な変数は明示的に `unset()` し、さらに必要に応じてシンボルテーブルから消去する。
- 長期常駐プロセスでは、定期的なワーカーの再起動(`max_request` の設定)を必ず組み込む。 Swooleの `max_request` は、指定リクエスト数に達した時点で安全にワーカープロセスをパージし、新しいプロセスを立ち上げることで、Zend MMの断片化や微小なメモリリークを物理的にリセットする唯一無二の防衛策である。
// Swooleサーバー設定でのmax_request活用
$server->set([
‘worker_num’ => 4,
‘max_request’ => 10000, // 1万リクエストごとにプロセスを安全に再起動し、Zend MMをリセット
]);
—
5. 結び:低レイヤを知る者だけがPHPの極限を引き出せる
PHPは「手軽なスクリプト言語」という顔を持ちながら、その下層(Zend Engine)ではC言語による緻密なメモリ管理と仮想マシンとしての最適化が張り巡らされている。
FPMという温室から飛び出し、Swooleや高並行非同期処理の世界へ踏み出すとき、プログラマはもはや「動けばいいコード」を書くことは許されない。Zend MMのバケツがどう溢れ、チャンクがどう断片化するのか――その物理構造を脳内でトレースできた瞬間から、あなたの書くコードは、工業製品としての圧倒的な堅牢性と速度を手に入れることになる。