FiberとPHPのメモリプロファイリング:非同期処理におけるメモリリークの特定と原因究明
テックリードの私たちがコードレビューで最も恐れるのは、「動くが、徐々にメモリを食いつぶすゾンビプロセス」の誕生だ。特にPHP 8.1で導入された`Fiber`(ファイバー)を用いた協調的マルチタスキング(Cooperative Multitasking)において、この恐怖は現実のものとなりやすい。
多くのエンジニアは「Fiberは軽量なスレッドだ」と誤解し、従来の同期処理の感覚でクロージャの中に巨大なオブジェクトやデータベースのコネクションを閉じ込める。そして、イベントループ上で何千ものFiberを並行稼働させ、ある日突然、FPMやDaemonプロセスのメモリ使用量が爆発してOOM Killerにプロセスを屠られる。
本稿では、Zend VMのメモリ管理とFiberの内部構造(コールスタックのヒープ退避)のメカニズムにメスを入れ、非同期アプリケーションにおけるメモリリークの真犯人を特定し、実務で耐えうる堅牢な設計ルールをコードベースで伝授する。
—
1. Zend VMのメモリ管理とFiberの裏側で何が起きているか
PHPの変数やコールスタックは、通常、リクエスト単位のメモリプール(ZendMM)上で効率よく管理され、リクエスト終了とともに一網打尽に解放される。しかし、Fiberが生成されると、その世界観が変わる。
コールスタックのヒープ化
通常の関数呼び出しはCのコールスタック(またはZend VMの実行スタック)上で完結するが、`Fiber`はその実行コンテキスト(ローカル変数、引数、実行ポインタ)をヒープメモリ上に切り出す。
つまり、`Fiber::suspend()`がコールされた瞬間、そのファイバーが保持しているすべての変数は、GC(ガベージコレクション)のスコープから一時的に逃れ、親スコープやイベントループの参照ツリーに繋ぎ止められることになる。
循環参照の温床
Fiber内で例外が発生した際、そのスタックトレース(`Throwable::getTrace()`)や、`Closure`の`use()`構文でキャプチャされた変数が循環参照(Circular Reference)を引き起こすケースが後を絶たない。Zend VMの参照カウンティング方式だけでは、これらは即座に回収されず、ルートバッファがいっぱいになるまでヒープに居座り続ける。これが、非同期デーモンにおける「静かなるメモリリーク」の正体だ。
—
2. 【実務コード】メモリリークを引き起こす「危険な設計」と、そのリファレンス
まずは、コードレビューで即座にリジェクトすべき「アンチパターン」を見てみよう。イベントループとFiberを組み合わせたAPIクライアントのモックだが、メモリリークの爆弾が仕掛けられている。
🚨 危険なコード例(メモリリークの温床)
simulateNetworkIo($endpoint, $largeData);
// 【悪夢】クラスプロパティにデータを蓄積し続ける設計
// Fiberが終了しても、$this(呼び出し元インスタンス)が生きていれば参照が残り続ける
$this->buffer[] = $response;
Fiber::suspend($response);
});
}
private function simulateNetworkIo(string $endpoint, string $data): string
{
// I/O待ちのシミュレーション
return “Response from {$endpoint}: ” . strlen($data) . ” bytes”;
}
public function getBufferCount(): int
{
return count($this->buffer);
}
}
// イベントループの簡易シミュレーション
$client = new leakyApiClient();
$fibers = [];
for ($i = 0; $i < 100; $i++) {
$fibers[] = $client->fetchAsync(“https://api.example.com/item/{$i}”);
}
// 実行
foreach ($fibers as $fiber) {
$fiber->start();
}
// この時点で $client->buffer には巨大なデータが蓄積され、
// リクエスト(またはデーモンプロセス)が終了するまでメモリが解放されない。
echo “Current memory usage: ” . round(memory_get_usage(true) / 1024 / 1024, 2) . ” MB\n”;
このコードの問題点は、「Fiberのライフサイクル」と「サービスコンテナ(依存性注入されたクラス)のライフサイクル」が分離されていない点にある。Fiber内で生成・処理されたデータが、長寿命なオブジェクトのプロパティにアタッチされることで、GCのスコープ外へと逃れてしまうのだ。
—
3. 安全かつメモリ効率を極限まで高めたリファレンス実装
では、上記の問題をクリアし、数万件の非同期リクエストをさばいてもメモリ使用量がフラットに保たれる「正しい設計」を示そう。
✨ 安全なリファレンスコード
/
public function executePool(array $endpoints, int $concurrency = 10): Generator
{
$queue = […$endpoints];
$activeFibers = [];
while (!empty($queue) || !empty($activeFibers)) {
// 同時実行数の制御と新規Fiberの生成
while (count($activeFibers) < $concurrency && !empty($queue)) {
$endpoint = array_shift($queue);
$fiber = $this->createTask($endpoint);
$fiber->start();
$activeFibers[] = $fiber;
}
// イベントループのイテレーション(簡易版)
foreach ($activeFibers as $key => $fiber) {
if ($fiber->isTerminated()) {
// 終了したFiberから返り値を取得し、即座にメモリから解放
yield $fiber->getReturn();
unset($activeFibers[$key]);
continue;
}
if ($fiber->isSuspended()) {
// I/Oの完了を待たずに次のFiberへスイッチ(非同期の恩恵)
$fiber->resume();
}
}
// CPUのスパイクを防ぐための微小なウェイト(実際のプロダクションではイベントループライブラリを使用)
usleep(1000);
}
}
private function createTask(string $endpoint): Fiber
{
return new Fiber(function () use ($endpoint) {
// スコープ内で完結させる変数。Fiber終了時に自動で破棄される。
$largeData = str_repeat(‘X’, 1024 1024 1); // 1MB
// ネットワークI/Oのモック
$response = $this->nonBlockingIoMock($endpoint, $largeData);
// データを加工したら、$largeData はこの時点で不要になるため
// 明示的にnullを代入してZendMMへ早期解放を促すこともテクニカルには有効
unset($largeData);
Fiber::suspend();
return $response; // 戻り値としてのみデータを渡す
});
}
private function nonBlockingIoMock(string $endpoint, string $data): string
{
return “Processed: {$endpoint} (” . strlen($data) . ” bytes)”;
}
}
// — 実行とプロファイリング計測 —
$dispatcher = new SafeAsyncDispatcher();
$endpoints = array_map(fn($i) => “https://api.example.com/v1/resource/{$i}”, range(1, 50));
$initialMemory = memory_get_usage(true);
echo “Initial Memory: ” . round($initialMemory / 1024 / 1024, 2) . ” MB\n”;
foreach ($dispatcher->executePool($endpoints, 5) as $result) {
// ストリーム処理的に結果を処理するため、メモリ上に全データを蓄積しない
// unsetやメモリ枯渇の心配がない
}
$peakMemory = memory_get_peak_usage(true);
echo “Peak Memory: ” . round($peakMemory / 1024 / 1024, 2) . ” MB\n”;
echo “Final Memory: ” . round(memory_get_usage(true) / 1024 / 1024, 2) . ” MB\n”;
設計上のキモ
1. ステートレスなFiberの運用: Fiber内で生成した重い変数は、処理完了・破棄のタイミングで確実にスコープ外へ追い出す。
2. ジェネレータ(`yield`)との統合: データを配列に蓄積して一括返却するのではなく、ストリーム状に処理を流すことで、ピークメモリ(Peak Memory)を極小化している。
3. `unset()`による早期の参照断ち: 巨大な文字列やオブジェクトは、役割を終えた瞬間に`unset()`を叩くことで、Zend VMの参照カウンタを即座にゼロにし、メモリプールへ返却させる。
—
4. Blackfire / Xdebugを用いたメモリプロファイリングの実践手法
どれだけコードレビューで気を付けても、サードパーティ製ライブラリのFiber内部実装が原因でメモリリークが起きることはある。プロファイリングツールを用いた原因究明の手順を頭に叩き込んでおこう。
1. Xdebugによるメモリリークのトレース
`xdebug.mode=gc_stats` または `xdebug.mode=profile` を有効にし、スクリプト実行前後のメモリ割り当ての変化を追う。
[xdebug]
xdebug.mode = profile,gc_stats
xdebug.output_dir = “/tmp/xdebug”
出力されたプロファイルデータをKCacheGrindやWebgrindで開き、以下の指標に注目する。
- `Excl. Mem` (Exclusive Memory): その関数自身が消費したメモリ量。ここに異常な数値が出る関数があれば、Fiber内で巨大な変数を抱え込んでいる。
- `Calls`: Fiberのインスタンス化回数に対し、正常に破棄(Destruct)されているか。破棄されていない場合、どこかの配列やプロファイルに参照が残っている。
2. Blackfireを用いたコールグラフ解析
Blackfireを使用する場合、非同期処理のプロファイリングでは「Memory」メトリックを有効にしてコールグラフを生成する。
blackfire run php bin/async_worker.php
レポート画面で Memory タブを開き、タイムライン上に鋸歯状(ノコギリ状)のメモリ増減が見られるか確認する。
- 綺麗な状態: タスクごとにメモリが上下動し、処理終了時にはベースラインに戻る。
- リークしている状態: タスクをこなすにつれてメモリの底上げ(Baseline creep)が発生し、右肩上がりになっている。
コールグラフでメモリを消費しているノードを辿っていくと、多くの場合 `Fiber::__construct` のクロージャ内でキャプチャされた変数(`use ($heavyObject)`)に行き着く。原因が特定できたら、そのクロージャのキャプチャをID(文字列や数値)の受け渡しに変更し、必要な時だけサービスからフェッチする設計(Lazy Loading)へとリファクタリングすれば良い。
—
5. テックリードからの最終提言
Fiberは、PHPを「リクエスト単位のスクリプト言語」から「常駐型の高性能バックエンドエンジン」へと昇華させる劇薬だ。しかし、メモリ管理のプリミティブな挙動(Zend VMの参照カウンティングとヒープ上のスタック退避)を理解せずに雑に扱えば、従来の同期処理よりもタチの悪いメモリリークを引き起こす。
プロダクションコードにFiberを導入する際は、以下のルールをチームの合意として厳守してほしい。
1. Fiberのクロージャ内で長寿命なオブジェクトをキャプチャしない(IDやキーのみを渡せ)。
2. 処理結果は配列に溜め込まず、`Generator` を使ってストリーム処理する。
3. デーモンプロセスとして常駐させる場合は、一定回数のリクエスト処理(あるいは定期的なインターバル)ごとにプロセスを安全に再起動(Graceful Restart)する仕組みをインフラレベルで担保する。
機械的な文法チェックを超え、PHPの「内部の息づかい」までを掌握した上で、美しく堅牢な非同期システムを組み上げてほしい。君たちのコードレビューを期待している。