序章:Zend VMのメモリ空間から見た「巨大データ処理」の現実
コードレビューの場で、こんな実装を見かけて冷や汗をかいたことはないか?
// 危険な実装例:数百万件のレコードを一度にメモリへロードする
$users = $pdo->query(‘SELECT FROM huge_table’)->fetchAll(PDO::FETCH_ASSOC);
foreach ($users as $user) {
// 巨大な配列を回すだけの悲劇
}
「動動けばいい」という甘い認識で書かれたこのコードは、本番環境のアクセススパイク時にOOM(Out of Memory)を引き起こし、PHP-FPMのワーカープロセスを容赦なくクラッシュさせる。
PHPの内部エンジン(Zend VM)において、プレーンな配列(`array`)は実態として`HashTable`構造体である。キーと値のペアを保持するため、メタデータやハッシュ衝突回避のためのポインタ領域など、C言語レベルで多大なオーバヘッドを消費する。数百万件のレコードを単一の配列に格納すれば、数ギガバイトのメモリが瞬時に蒸発し、Zendのメモリマネージャ(emalloc)は断片化の海に沈むのだ。
この絶望的なメモリ枯渇を防ぐための特効薬として、我々は長年ジェネレータ(Generator)を愛用してきた。しかし、現代のPHP(PHP 8.1以降)には、さらにプリエンプティブな並行処理を可能にするFiber(ファイバー)が存在する。
「ジェネレータがあるのに、なぜFiberなのか?」
「逆に、ただのループ処理にFiberを持ち込むのはメモリの無駄遣いではないのか?」
今回は、Zend VMのスタックフレームとメモリ消費のメカニズムを紐解きながら、大規模データ処理における「ジェネレータ」と「Fiber」の決定的な違い、そして実務で迷わないための設計基準を叩き込む。
—
内部構造の比較:Generatorの遅延評価 vs Fiberのスタック管理
まずは、Zendエンジン内部でこの2つがどのようにメモリを占有しているのか、その構造的差異を正確に理解しておこう。
1. ジェネレータ(Generator)の省メモリの正体
ジェネレータは、内部で `Generator` クラスのインスタンスと、それを実行するための `zend_generator` 構造体を生成する。
配列と決定的に違うのは、「データを一度にメモリへ展開せず、`yield` に到達するたびにコンテキスト(実行状態)を一時停止し、必要最小限の値だけをオンデマンドで返却する」点にある。
- メモリ計算量: $O(1)$ (定数オーダー)
- Zend VM上の挙動: 関数呼び出しのスタックフレームを部分的に保持しつつ、Cのコールスタックではなくヒープ上に実行状態(`zend_execute_data`の一部)を退避させる。
2. Fiber(ファイバー)の「協調的マルチタスク」とメモリコスト
Fiberは、独立した実行スタックを持つ。つまり、各Fiberは独自のコールスタック空間(Zend VMのスタックフレーム群)をメモリ上に確保する。
- メモリ計算量: $O(N + \text{スタックサイズ})$
- Zend VM上の挙動: `Fiber::suspend()` と `Fiber::resume()` により、CPUのレジスタ状態やコールスタックをごっそり切り替える。強力な非同期制御(コールバック地獄の回避など)を実現できる反面、ジェネレータと比較してベースラインのメモリ消費量が大きい。
> テクニカルリードの視点:
> 単なる「巨大なデータストリームの順次処理」にFiberを持ち込むのは、大型トラックで近所のコンビニにアイスを買いに行くようなものだ。エンジンへの負荷とメモリフットプリントが無駄に増大する。Fiberの真価は「複数ソースからの非同期I/O待ちのインターリーブ」にあり、「メモリ効率の良いデータストリーム」の主役はあくまでジェネレータである。
—
実務リファレンス:数百万件を安全に裁く「メモリ爆破耐性」パイプライン設計
ここからは、実務のAPIバッチ処理や大規模ETL(Extract/Transform/Load)を想定し、ジェネレータを駆使した「安全かつ極限までメモリを削ぎ落としたパイプライン処理」の実装コードを示す。
このコードでは、データベースからのフェッチ、データ変換、外部APIへの送信(あるいはファイル出力)を、メモリを1バイトも無駄にせずストリーミング処理する。
/
final class DataStreamPipeline
{
private PDO $pdo;
private LoggerInterface $logger;
public function __construct(PDO $pdo, LoggerInterface $logger)
{
// プリペアドステートメントのエミュレーションを無効化し、真のストリーミング(CURSOR_SCROLL等)を活かす
$this->pdo = $pdo;
$this->logger = $logger;
}
/
- 1. 抽出フェーズ(Generatorによる遅延ロード)
- @return Generator
>
/
private function extract(string $sql, int $batchSize = 1000): Generator
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute();
//fetchAll()の代わりにcursor()を使い、Zend VMへ一度に全レコードを載せない
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
yield $row;
}
}
/
- 2. 変換フェーズ(メモリを汚さないクロージャのマッピング)
- @param Generator
> $source - @param callable(array
): array $transformer - @return Generator
>
/
private function transform(Generator $source, callable $transformer): Generator
{
foreach ($source as $item) {
// 各レコード単位で変換を適用。配列の重複コピーを避けるため参照や破壊的変更に注意
yield $transformer($item);
}
}
/
- 3. 読み込み/出力フェーズ(チャンク単位でのバルク処理)
- @param Generator
> $source - @param int $chunkSize
- @param callable(array
>): void $loader
/
public function process(string $sql, callable $transformer, callable $loader, int $chunkSize = 500): void
{
$memoryStart = memory_get_usage(true);
$this->logger->info(‘パイプライン処理を開始します。’, [‘memory_start_mb’ => $memoryStart / 1024 / 1024]);
$extractGen = $this->extract($sql);
$transformGen = $this->transform($extractGen, $transformer);
$chunk = [];
$counter = 0;
foreach ($transformGen as $item) {
$chunk[] = $item;
$counter++;
// 指定チャンクサイズに達したらバルク処理を実行し、即座にメモリを解放する
if (count($chunk) >= $chunkSize) {
$loader($chunk);
$chunk = []; // 参照を切ってGC(ガベージコレクション)の対象にする
}
}
// 最後に端数があれば処理
if (!empty($chunk)) {
$loader($chunk);
}
$memoryPeak = memory_get_peak_usage(true);
$this->logger->info(‘パイプライン処理が完了しました。’, [
‘total_processed’ => $counter,
‘memory_peak_mb’ => $memoryPeak / 1024 / 1024,
]);
}
}
この設計が実務で絶対に壊れない理由
1. `PDO::FETCH_ASSOC` とカーソルの組み合わせ:
全件をメモリにロードせず、ネットワークパケット(あるいはローカルバッファ)から1行ずつ読み込むため、DBサーバ側とPHP側のメモリバウンダリーを完全に分離できる。
2. チャンク単位のメモリ明示的クリア:
`$chunk = [];` とすることで、Zend VMのスコープ外になった古いHashTableの参照カウンタがゼロになり、次回のループ開始時に速やかに再利用(または解放)される。
3. 副作用の排除:
抽出・変換・読み込み(ETL)の責務がジェネレータを介して完全に疎結合になっており、メモリリークの温床になりやすい静的変数やグローバルスコープへの依存を排除している。
—
では、いつFiberを使うべきなのか?:非同期I/Oバウンドな設計判断
「ジェネレータの極限の省メモリ性」を確認した上で、ではFiberをどの場面で投入すべきか。
実務においてFiberを導入すべき唯一にして最大のユースケースは、「複数の外部APIや非同期データベースクエリを、ブロッキングさせずに並行実行(Concurrently)させたい場合」である。
以下のコードは、Fiberを用いた非同期HTTPリクエスト・シミュレータの骨子だ。
/
final class AsyncDispatcher
{
/ @var array
private array $fibers = [];
public function add(callable $task): void
{
$fiber = new Fiber($task);
$this->fibers[] = $fiber;
// 初回起動
$fiber->start();
}
public function run(): void
{
while (!empty($this->fibers)) {
foreach ($this->fibers as $index => $fiber) {
if ($fiber->isTerminated()) {
unset($this->fibers[$index]);
continue;
}
// Fiberがサスペンド(一時停止)状態なら、I/Oの完了を待たずに他のタスクへCPUを譲る
if ($fiber->isSuspended()) {
// ※実務ではここにStreams/Socketsの非同期ポーリング(stream_select等)が入る
$fiber->resume();
}
}
// CPUのスパイクを防ぐための微小なスリープ
usleep(1000);
}
}
}
アーキテクトからの最終提言
- 大規模なデータセットのストレージ・DB処理(ETL、巨大CSVパース、バッチ)
$\rightarrow$ 迷わず「ジェネレータ(Generator)」を選択せよ。 メモリ消費量は常にフラット($O(1)$)に保たれ、Zend VMの負荷を最小化できる。
- 複数の外部I/O(HTTPクライアント、RPC、マイクロサービス間通信)の並行待ち合わせ
$\rightarrow$ 「Fiber」を選択せよ。 ただし、コールスタックごとのメモリコスト(数KB〜数十KB/Fiber)が発生することをチーム全員が共通認識として持っておくこと。
PHPはもはや「動けばいいおもちゃのスクリプト言語」ではない。Zend VMの内部挙動を支配し、メモリのライフサイクルをコントロールできた者だけが、モダンで超高速なWebアプリケーションの覇権を握るのだ。