php

PHP Generators (yield): обработка больших данных без переполнения памяти

Когда PHP-скрипт читает гигабайтный CSV, парсит выгрузку из 1С или обходит миллион записей из БД, классический подход через массивы упирается в Allowed memory size exhausted. Генераторы решают эту проблему одной конструкцией: yield отдаёт значения по одному, не накапливая их в памяти. Разберём, как они работают изнутри, и соберём боевые примеры — от чтения файла построчно до пагинации запросов к БД и пайплайнов обработки.

Сравнение потребления памяти при чтении CSV: file() против yield
Один и тот же миллион строк: 512 МБ против 2 МБ.

Что такое генератор и чем он отличается от обычной функции

Генератор — это функция, в теле которой встречается ключевое слово yield. Вызов такой функции не выполняет её код, а возвращает объект Generator, реализующий Iterator. Тело выполняется только при итерации: на каждом yield функция «замораживается» и отдаёт значение, а на следующей итерации продолжает с того же места.

<?php
// Обычная функция — собирает всё в массив
function rangeArray(int $start, int $end): array {
    $result = [];
    for ($i = $start; $i <= $end; $i++) {
        $result[] = $i;
    }
    return $result; // вернётся только когда массив целиком построен
}

// Генератор — отдаёт по одному значению
function rangeGen(int $start, int $end): Generator {
    for ($i = $start; $i <= $end; $i++) {
        yield $i; // здесь функция «приостанавливается»
    }
}

// 10 млн целых в массиве — Fatal error на дефолтных 128M
// $nums = rangeArray(1, 10_000_000);

// 10 млн через генератор — около 2 МБ памяти
foreach (rangeGen(1, 10_000_000) as $n) {
    if ($n % 1_000_000 === 0) {
        echo $n, ' ', round(memory_get_usage() / 1024 / 1024, 2), " MB\n";
    }
}

Главное правило: генератор ленив. Пока вы не запросили следующее значение через foreach или вручную через $gen->current(), код в теле функции не выполняется. Это и даёт экономию памяти и мгновенный старт обработки.


Чтение больших файлов построчно

Самая частая задача — разобрать большой лог, дамп БД или выгрузку. Функция file() загружает файл целиком в массив, а file_get_contents() — в строку. На файле 2 ГБ оба варианта упадут. Генератор плюс fopen + fgets читает построчно и держит в памяти ровно одну строку.

<?php
/**
 * Читает файл построчно и отдаёт строки без перевода каретки.
 * Память — O(1), независимо от размера файла.
 */
function readLines(string $path): Generator {
    $fp = fopen($path, 'rb');
    if ($fp === false) {
        throw new RuntimeException("Не открыть файл: {$path}");
    }
    try {
        while (($line = fgets($fp)) !== false) {
            yield rtrim($line, "\r\n");
        }
    } finally {
        fclose($fp); // закроется и при исключении, и при break внутри foreach
    }
}

// Считаем ошибки в access.log весом 4 ГБ
$errors = 0;
foreach (readLines('/var/log/nginx/access.log') as $line) {
    if (str_contains($line, ' 500 ') || str_contains($line, ' 502 ')) {
        $errors++;
    }
}
echo "Ошибок 5xx: {$errors}\n";

Блок try/finally здесь критичен: если вызвавший код прервёт итерацию через break или выбросит исключение, fclose всё равно выполнится. Это поведение генераторов специально продумано — «уборка» работает как в обычной функции.

Парсинг CSV: yield вместо fgetcsv в цикле

Связка fgetcsv + генератор позволяет читать огромные таблицы и сразу отдавать клиенту нормализованные строки в виде ассоциативных массивов. Заголовок читается один раз, остальные строки превращаются в ['column' => value].

<?php
function readCsv(string $path, string $sep = ',', string $enc = '"'): Generator {
    $fp = fopen($path, 'rb');
    if ($fp === false) {
        throw new RuntimeException("Не открыть CSV: {$path}");
    }
    try {
        $headers = fgetcsv($fp, 0, $sep, $enc);
        if ($headers === false) {
            return; // пустой файл
        }
        while (($row = fgetcsv($fp, 0, $sep, $enc)) !== false) {
            // если в строке меньше колонок — дозаполним null
            if (count($row) < count($headers)) {
                $row = array_pad($row, count($headers), null);
            }
            yield array_combine($headers, array_slice($row, 0, count($headers)));
        }
    } finally {
        fclose($fp);
    }
}

// Подсчитаем сумму продаж и количество уникальных клиентов
$total = 0;
$clients = [];
foreach (readCsv('/tmp/sales_2026.csv') as $row) {
    $total += (float) $row['amount'];
    $clients[$row['client_id']] = true;
}
printf("Сумма: %.2f, клиентов: %d\n", $total, count($clients));

Постраничные запросы к БД через yield

Если в таблице 5 миллионов строк, SELECT * уронит и PHP, и MySQL. Решение — итерировать по «окнам» с помощью keyset-пагинации (по последнему id), а наружу отдавать строки по одной через генератор. Вызывающий код пишется так, как будто работает с обычным массивом.

<?php
function fetchUsers(PDO $pdo, int $batch = 1000): Generator {
    $sql = 'SELECT id, email, created_at
              FROM users
             WHERE id > :last
          ORDER BY id ASC
             LIMIT :lim';
    $stmt = $pdo->prepare($sql);
    $lastId = 0;

    while (true) {
        $stmt->bindValue(':last', $lastId, PDO::PARAM_INT);
        $stmt->bindValue(':lim',  $batch,  PDO::PARAM_INT);
        $stmt->execute();

        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
        if (!$rows) {
            return; // данные закончились
        }

        foreach ($rows as $row) {
            $lastId = (int) $row['id'];
            yield $row;
        }

        // Освободим буфер драйвера до следующей пачки
        $stmt->closeCursor();
    }
}

$pdo = new PDO('mysql:host=localhost;dbname=shop;charset=utf8mb4', 'user', 'pass');

// Экспортируем 5 млн пользователей в CSV — память константная
$out = fopen('users_export.csv', 'wb');
fputcsv($out, ['id', 'email', 'created_at']);
foreach (fetchUsers($pdo, 2000) as $u) {
    fputcsv($out, [$u['id'], $u['email'], $u['created_at']]);
}
fclose($out);

Почему keyset, а не LIMIT … OFFSET: большой offset заставляет MySQL пробегать все пропускаемые строки. На 4-миллионном offset запрос становится в сотни раз медленнее, чем фильтр id > :last с индексом по первичному ключу.


Пайплайны: yield from и комбинирование генераторов

Генераторы прекрасно компонуются. Один читает файл, второй фильтрует, третий преобразует — и всё это без промежуточных массивов. Связку упрощает yield from, который делегирует итерацию вложенному генератору.

<?php
// Фильтр — отдаёт только строки, удовлетворяющие предикату
function filterGen(iterable $src, callable $fn): Generator {
    foreach ($src as $key => $value) {
        if ($fn($value)) {
            yield $key => $value;
        }
    }
}

// Мап — преобразует каждый элемент
function mapGen(iterable $src, callable $fn): Generator {
    foreach ($src as $key => $value) {
        yield $key => $fn($value);
    }
}

// Объединение нескольких источников через yield from
function concatGen(iterable ...$sources): Generator {
    foreach ($sources as $src) {
        yield from $src;
    }
}

// Сценарий: соберём ошибки из двух логов, оставим только 5xx,
// и превратим в массив с распарсенными полями.
$april = readLines('/var/log/nginx/access-04.log');
$may   = readLines('/var/log/nginx/access-05.log');

$pipeline = mapGen(
    filterGen(
        concatGen($april, $may),
        fn(string $l) => (bool) preg_match('# 5\d\d #', $l)
    ),
    fn(string $l) => [
        'ip'   => strtok($l, ' '),
        'line' => $l,
    ]
);

foreach ($pipeline as $event) {
    // обрабатываем по одной записи: пишем в БД, шлём в Sentry и т.п.
}

Тут стоит обратить внимание: каждая «обёртка» — тоже генератор, и память по-прежнему держит ровно одну запись. Это идиоматический способ собирать ETL-конвейеры на чистом PHP, без сторонних библиотек вроде nikic/iter или RxPHP.

Двунаправленные генераторы: yield как выражение

yield может стоять справа от знака равенства — тогда генератор не только отдаёт значения, но и принимает их обратно через $gen->send(). Эта возможность редко нужна для данных, но даёт мощный приём — корутины (cooperative coroutines). Простейший пример — управляемый счётчик.

<?php
function controlledCounter(): Generator {
    $i = 0;
    while (true) {
        // отдаём текущее значение и ждём команду
        $cmd = yield $i;
        if ($cmd === 'reset') {
            $i = 0;
        } elseif (is_int($cmd)) {
            $i += $cmd; // увеличить на N
        } else {
            $i++;
        }
    }
}

$c = controlledCounter();
echo $c->current(), "\n";   // 0
$c->send(5);  echo $c->current(), "\n"; // 5
$c->send(10); echo $c->current(), "\n"; // 15
$c->send('reset'); echo $c->current(), "\n"; // 0
$c->next();   echo $c->current(), "\n"; // 1

На корутинах построены асинхронные движки amphp и старый react/promise-подход до появления Fibers. Сегодня для асинхронности используют Fiber из PHP 8.1+, но базовое понимание send()/throw()/getReturn() у генератора всё равно полезно.


Подводные камни

  • Один проход. Генератор нельзя «перемотать»: после полного обхода foreach по тому же объекту даст ошибку Cannot traverse an already closed generator. Нужен повторный проход — вызывайте функцию-генератор заново.
  • Не работает count() и обращение по индексу. Generator — это итератор, а не массив. Хотите длину — используйте iterator_count() (он съест генератор) или ведите счётчик руками.
  • Ключи могут совпадать. Если в нескольких источниках одинаковые ключи и вы прогоняете их через iterator_to_array($gen) — поздние значения перезатрут ранние. Передавайте вторым аргументом false, чтобы перенумеровать.
  • Память — это не только ваш код. Если внутри foreach вы накапливаете результат в массив ($result[] = $row), память снова растёт линейно. Генератор экономит, только если потребитель тоже потоковый.
  • Исключения внутри генератора. Бросаются только при попытке получить следующий элемент. Поэтому try/catch ставьте вокруг foreach, а не вокруг вызова функции-генератора.

Чеклист: когда уместен yield

  1. Объём данных заранее неизвестен или больше доступной памяти.
  2. Источник «потоковый» по природе: файл, сокет, выгрузка из API с пагинацией, выборка из БД.
  3. Потребитель тоже не накапливает результат целиком (пишет в файл, шлёт по сети, агрегирует).
  4. Нужно собрать конвейер обработки из переиспользуемых шагов (filter / map / reduce).
  5. Ленивая инициализация: данные могут вообще не понадобиться, лишь часть — не тратьте время на построение всего массива.

Генераторы — это бесплатная оптимизация: тот же код становится в десятки раз экономнее, не теряя в читаемости. Заведите привычку: видите file(), fetchAll() или большой SELECT * в скриптах импорта/экспорта — переписывайте на yield, и проблема «нехватки памяти на проде» перестанет существовать.