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

Что такое генератор и чем он отличается от обычной функции
Генератор — это функция, в теле которой встречается ключевое слово 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
- Объём данных заранее неизвестен или больше доступной памяти.
- Источник «потоковый» по природе: файл, сокет, выгрузка из API с пагинацией, выборка из БД.
- Потребитель тоже не накапливает результат целиком (пишет в файл, шлёт по сети, агрегирует).
- Нужно собрать конвейер обработки из переиспользуемых шагов (filter / map / reduce).
- Ленивая инициализация: данные могут вообще не понадобиться, лишь часть — не тратьте время на построение всего массива.
Генераторы — это бесплатная оптимизация: тот же код становится в десятки раз экономнее, не теряя в читаемости. Заведите привычку: видите file(), fetchAll() или большой SELECT * в скриптах импорта/экспорта — переписывайте на yield, и проблема «нехватки памяти на проде» перестанет существовать.
