PHPのジェネレータ(yield)で大量データを省メモリに処理する

配列で全件読み込むと何が起きるか

CSVを1行ずつ処理するだけの単純なバッチ処理で、突然「Allowed memory size exhausted」が出た経験はないでしょうか。原因の多くは、file()fetchAll()のように「結果を一度に全部配列へ詰め込む」関数を無邪気に使っていることです。100万行のCSVをfile()で読み込めば、その時点で全行分の文字列がメモリに乗ります。

// メモリを食い潰しがちな書き方
function loadRows(string $path): array
{
    $rows = [];
    $handle = fopen($path, 'r');
    while (($line = fgetcsv($handle)) !== false) {
        $rows[] = $line;
    }
    fclose($handle);
    return $rows;
}

foreach (loadRows('huge.csv') as $row) {
    process($row);
}

この関数は呼び出された瞬間に「全行を配列に詰め終わるまで」処理をブロックし、しかもその配列を最後まで保持し続けます。件数がスケールした瞬間に破綻するタイプのコードです。

yieldで「必要な分だけ」返す

PHPのジェネレータは、関数の中にyieldを1つ書くだけで、その関数がイテレータを返す関数に変わるという仕組みです。呼び出し側がforeachで1件取り出すたびに、関数の実行が再開されて次の値を返します。

function loadRowsLazy(string $path): Generator
{
    $handle = fopen($path, 'r');
    try {
        while (($line = fgetcsv($handle)) !== false) {
            yield $line;
        }
    } finally {
        fclose($handle);
    }
}

foreach (loadRowsLazy('huge.csv') as $row) {
    process($row);
}

見た目の変化は「returnをyieldに変えて型をGeneratorにした」程度ですが、実行時の挙動は全く違います。メモリに乗るのは常に「今処理している1行分」だけです。100万行だろうと1000万行だろうと、消費メモリはほぼ一定になります。

ここでtry/finallyでファイルハンドルを閉じている点も重要です。ジェネレータは呼び出し側が全件回し切らずにbreakする可能性があるため、リソースの後始末は忘れずに書いておく必要があります。

yield from でジェネレータを合成する

複数のソースを1本のイテレータとして扱いたいとき、いちいち配列にマージすると省メモリの意味がなくなります。yield fromを使えば、ジェネレータ同士をメモリコピーなしで連結できます。

function loadMultipleFiles(array $paths): Generator
{
    foreach ($paths as $path) {
        yield from loadRowsLazy($path);
    }
}

foreach (loadMultipleFiles(['a.csv', 'b.csv', 'c.csv']) as $row) {
    process($row);
}

ファイルが何個に増えても、呼び出し側のコードは一切変わりません。これはジェネレータが「値の集合」ではなく「値を生成する手続き」を表現しているからこそ可能な合成のしかたです。

ジェネレータから値を”返す”こともできる

意外と知られていませんが、ジェネレータはyieldで値を出しつつ、最後にreturnで戻り値を持つこともできます。この戻り値はgetReturn()で取得します。ただし、呼び出し側がforeachを最後まで回し切っていないと正しく取得できない点には注意してください。

function countingGenerator(array $items): Generator
{
    $count = 0;
    foreach ($items as $item) {
        yield $item;
        $count++;
    }
    return $count;
}

$gen = countingGenerator(['a', 'b', 'c']);
foreach ($gen as $item) {
    echo $item, PHP_EOL;
}
echo "処理件数: {$gen->getReturn()}", PHP_EOL;

send()で”値を受け取りながら”進めることもできる

ジェネレータは一方通行の出力装置だと思われがちですが、send()メソッドを使うと外部から値を注入できます。yield式自体が「send()で渡された値」を返すという性質を利用します。簡易的なコルーチンやキュー処理でたまに使う場面があります。

function echoServer(): Generator
{
    while (true) {
        $received = yield;
        if ($received === null) {
            continue;
        }
        echo "受信: {$received}", PHP_EOL;
    }
}

$gen = echoServer();
$gen->current(); // 最初のyieldまで進める(初期化)
$gen->send('hello');
$gen->send('world');

最初にcurrent()rewind()を呼んでジェネレータを最初のyieldまで進めておかないと、send()した値が意図せず捨てられてしまう点はハマりどころです。

ハマりどころ:ジェネレータは巻き戻せない

普通の配列と違い、ジェネレータは一度foreachし終わると再利用できません。もう一度回そうとすると例外が飛びます。

$gen = loadRowsLazy('huge.csv');

foreach ($gen as $row) {
    // 1回目の走査
}

foreach ($gen as $row) {
    // Exception: Cannot rewind a generator that was already run
}

2回以上走査したい場合は、都度ジェネレータ関数を呼び直して新しいインスタンスを作るのが正解です。「関数」と「その実行結果であるジェネレータオブジェクト」を混同しないのがコツです。

まとめ

大量データを扱うバッチ処理やCSVインポート、ログ解析のようなコードでは、配列で全件抱え込む実装がそのままメモリ不足の温床になります。ジェネレータへの置き換えは既存の関数シグネチャをGeneratorに変えてyieldするだけで済むことが多く、割に合う投資です。yield fromによる合成、getReturn()send()あたりまで押さえておくと、単なる遅延評価以上の使い方ができるようになります。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です