PHPのジェネレータ(Generators)によるメモリ効率化の物理的根拠
コードレビューの場で、10万件のレコードを扱うバッチ処理や、巨大なCSVのエクスポート処理に対して `range(1, 100000)` や全件取得の `PDO::fetchAll()` がそのまま書かれているコードを見かけたことはないだろうか。その瞬間、私はこう問いかける。「君は、このプロセスが背負うメモリの重みを物理的に想像できたか?」と。
Webアプリケーションの寿命は、1リクエストあたりのメモリフットプリントと密接に結びついている。今回は、Zend VMの内部構造とメモリ空間の挙動に踏み込み、ジェネレータがなぜメモリを救うのか、その物理的根拠を解き明かす。
—
1. なぜ「全件配列化」は悪なのか? Zend VMのメモリ空間から見る真実
PHPで巨大なデータを扱う際、次のようなコードを書く人間は後を絶たない。
// 【アンチパターン】メモリを爆発させる典型例
function getHugeData(): array {
$data = [];
for ($i = 0; $i < 1000000; $i++) {
$data[] = computeExpensiveData($i);
}
return $data;
}
foreach (getHugeData() as $row) {
process($row);
}
このコードが実行された瞬間、Zend Engineの内部では何が起きているか。
PHPの配列(`array`)の実体は、単なるC言語の連続したメモリ領域(バッファ)ではない。その正体は、ハッシュ衝突を防ぎ、順序を保証するための複雑な二重連結リスト構造を持つ `HashTable`(ハッシュテーブル) である。
1つのzval(PHPの変数を表現する構造体)が約32バイト、さらに`HashTable`のエントリやバケツ(Bucket)のメタデータを考慮すると、数百万件のデータを配列に格納した瞬間、数100MBから場合によってはギガバイト単位のヒープメモリが容赦なく消費される。
FPMワーカープロセスがこれを抱え込んだ瞬間、OSのOOM Killer(Out-Of-Memory Killer)の標的となるか、スワップアウトが発生してサーバー全体が沈黙する。
—
2. ジェネレータの物理的根拠:実行コンテキストの退避と再開
ここでジェネレータ(`yield`)の出番だ。ジェネレータは、メモリ上に巨大な配列を構築せず、「計算の状態(ステート)だけを保持し、要求された瞬間に1つずつ値を生成する」。
これをZend VMの低レイヤの視点から解説しよう。
実行コンテキスト(`zend_generator` 構造体)の保持
通常の関数は、呼び出されるとスタックフレーム(`zend_execute_data`)が作られ、処理が完結するとそのフレームは破棄される。
しかし、関数内に `yield` が存在する場合、Zend VMはその関数を通常の関数ではなく「ジェネレータ」としてコンパイルする。
ジェネレータが呼び出されると、PHPは即座にコードの本体を実行するのではなく、`zend_generator` オブジェクトをヒープ上にインスタンス化する。このオブジェクトは以下の極めて軽量な情報を保持している:
- 現在の実行ポインタ(Opline): 関数内のどこまで実行を進めたか。
- ローカル変数のスコープ(Symbol Table): ループカウンタや途中の計算結果などのローカル変数。
- 呼び出し元のコンテキスト: 制御をどこに戻すべきか。
つまり、100万件のデータそのものをメモリに載せるのではなく、「100万件を生成できるイテレータのポインタとローカル変数のスナップショット(数キロバイト程度)」だけをメモリに保持し続けるのだ。
—
3. 実務で耐えうる堅牢な実装:メモリ枯渇を防ぐストリーミング処理
では、実務の現場――例えば、数百万行におよぶ巨大なトランザクションログを安全にストリーミング処理するアーキテクチャをコードで示そう。ここでは、外部リソース(ファイルやDBカーソル)を確実にクローズし、メモリリークを完全に排除する堅牢な実装を行う。
/
class SecureStreamRepository
{
public function __construct(
private PDO $pdo,
private LoggerInterface $logger
) {}
/
- 100万件超のレコードであっても、メモリ消費量を一定(O(1))に抑えてイテレートする
- @return Generator
>
/
public function cursor(string $sql, array $params = []): Generator
{
// プリペアドステートメントの作成
$stmt = $this->pdo->prepare($sql);
// 【重要】PDOのバッファードクエリを無効化し、サーバーサイドカーソル(非バッファ)を利用する
// これにより、DBドライバ側で全件をメモリにロードせず、ネットワーク経由で1行ずつストリーミングさせる
$stmt->execute($params);
try {
// fetchラッパーを通じて、1行ずつZend VMのスタックへ流し込む
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
// yieldによって、呼び出し元に制御権とデータを「1行分だけ」渡す
// この瞬間、この行のデータ以外のメモリは一切消費されない
yield $stmt->rowCount() => $row;
}
} catch (\Throwable $e) {
$this->logger->error(“ストリーミング処理中に例外が発生しました: {$e->getMessage()}”, [
‘exception’ => $e
]);
throw $e;
} finally {
// ジェネレータが途中で破棄(break)された場合でも、確実にあらゆるリソース(カーソル)を解放する
$stmt->closeCursor();
$this->logger->info(“データベースカーソルを正常にクローズしました。”);
}
}
}
この設計が実務で絶大な信頼を得る理由
1. O(1) メモリ複雑性の達成: データが何行あろうとも、PHPプロセスが消費するメモリは「現在の1行分の連想配列サイズ」に収束する。
2. `finally` ブロックによるリソースの確実な解放: 途中で例外が発生したり、`foreach` を途中で `break` 抜けした場合でも、ジェネレータのライフサイクル終了時に `closeCursor()` が確実に走り、データベースサーバー側のリソースをリークさせない。
3. PDOの非バッファード(Unbuffered)クエリとの連携: PHP側だけでなく、データベースドライバ層からメモリ効率化を徹底している。
—
4. ジェネレータ利用時の「致命的な罠」とコードレビューの視点
テクニカルリードとして、チームメンバーが書いたジェネレータコードをレビューする際、以下のアンチパターンが見つかったら即座に差し戻すべきだ。
罠1: ジェネレータの結果を一度 `iterator_to_array()` で配列に戻す愚行
// 【最悪のアンチパターン】
// メモリ効率を上げるためにジェネレータを作ったのに、結局配列に戻したら意味がない
$generator = $repository->cursor(“SELECT FROM massive_table”);
$allData = iterator_to_array($generator); // ここで結局ヒープメモリが爆発する
解説: ジェネレータの恩恵は「逐次処理(Lazy Evaluation)」にある。それを全件配列化したら、わざわざ複雑なコンテキスト管理をしたコストが無駄になるだけだ。
罠2: トランザクションと長寿命ジェネレータの衝突
データベースのトランザクション(`BEGIN` から `COMMIT`/`ROLLBACK`)の中でジェネレータを回し、そのループ内で外部APIコールなど時間のかかる処理を入れると、長期間ロックが保持され、データベースのコネクションプールやトランザクションログが枯渇する。
ジェネレータを使うときは、「データ取得のストリーム化」と「重いI/O処理の分離(チャンク分割など)」のバランスを見極める必要がある。
—
結び:アーキテクトとしての矜持
PHPは「手軽に動かせる言語」として語られがちだが、だからこそ、その裏側でZend VMがどのようにメモリを割り当て、解放しているのかという「物理レイヤの現実」を直視しなければならない。
ジェネレータは、単なる「便利な構文糖菓子」ではない。それは、限られたメモリ資源の中で巨大なデータを安全にハンドリングするための、洗練された低レイヤ・アーキテクチャの結晶である。
「なぜこのコードを書いたのか」をZend VMの挙動まで遡って説明できるエンジニアであれ。君の書くコードの1行が、サーバーの命運を握っているのだから。