Вы когда-нибудь пытались загрузить в память CSV-файл на миллион строк и увидели что-то вроде:
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted
Даже если увеличить memory_limit
, ощущение всё равно неприятное: мы держим в памяти весь массив данных, хотя работаем с ним построчно.
Решение? Ленивые вычисления — подход, при котором данные генерируются и обрабатываются только тогда, когда они реально нужны.
В PHP это можно сделать двумя способами: с помощью генераторов (yield)
и через Iterator API. Сегодня разберём оба.
Что такое ленивые вычисления
Обычно, когда мы создаём массив, PHP загружает в память сразу все элементы:
function getNumbersArray(int $count): array {
$result = [];
for ($i = 1; $i <= $count; $i++) {
$result[] = $i;
}
return $result;
}
foreach (getNumbersArray(5) as $number) {
echo $number . PHP_EOL;
}
Здесь в памяти хранится сразу весь массив [1, 2, 3, 4, 5]
.
А теперь попробуем ленивый подход:
function getNumbersGenerator(int $count): Generator {
for ($i = 1; $i <= $count; $i++) {
yield $i;
}
}
foreach (getNumbersGenerator(5) as $number) {
echo $number . PHP_EOL;
}
? Разница: генератор не хранит всё — он отдаёт элемент только тогда, когда foreach
его запросит.
Читаем огромный CSV без боли
Представим, что у нас есть файл data.csv
на 2 ГБ. Обычный file()
или fgetcsv
в массиве — мгновенный Out of Memory.
С генератором — всё просто:
function readCsv(string $filename): Generator {
$handle = fopen($filename, 'r');
if ($handle === false) {
throw new RuntimeException("Не удалось открыть файл $filename");
}
while (($row = fgetcsv($handle)) !== false) {
yield $row;
}
fclose($handle);
}
foreach (readCsv('data.csv') as $row) {
// Обрабатываем строку
}
? Память: даже для 2 ГБ CSV этот код будет занимать несколько килобайт, потому что в памяти всегда только одна строка.
Бенчмарк: массив vs генератор
$startMemory = memory_get_usage();
$array = range(1, 1_000_000); // создаёт массив из миллиона чисел
echo "Массив: " . (memory_get_usage() - $startMemory) / 1024 / 1024 . " MB\n";
unset($array);
$startMemory = memory_get_usage();
function bigGenerator(): Generator {
for ($i = 1; $i <= 1_000_000; $i++) {
yield $i;
}
}
foreach (bigGenerator() as $n) {
// просто итерируем
}
echo "Генератор: " . (memory_get_usage() - $startMemory) / 1024 / 1024 . " MB\n";
Результат на моей машине:
Массив: 120 MB
Генератор: 0.5 MB
Iterator API
Генераторы — это быстро и просто. Но иногда нужно больше контроля: хранить состояние, управлять ключами или даже динамически менять источник данных.
Тогда в бой идёт Iterator API.
Пример: собственный итератор
class RangeIterator implements Iterator {
private int $start;
private int $end;
private int $current;
public function __construct(int $start, int $end) {
$this->start = $start;
$this->end = $end;
$this->current = $start;
}
public function current(): int {
return $this->current;
}
public function key(): int {
return $this->current;
}
public function next(): void {
$this->current++;
}
public function rewind(): void {
$this->current = $this->start;
}
public function valid(): bool {
return $this->current <= $this->end;
}
}
foreach (new RangeIterator(1, 5) as $num) {
echo $num . PHP_EOL;
}
Когда что использовать
Ситуация |
Что выбрать |
---|---|
Нужно просто отдать данные по мере запроса |
Генератор |
Нужно хранить внутреннее состояние или сложную логику |
Iterator API |
Потоковая обработка из файла/БД |
Генератор |
Множественные обходы коллекции с сохранением состояния |
Iterator API |
Реальный кейс из продакшена
Мы парсили API автопродаж, которое возвращало сотни тысяч записей.
Раньше мы собирали всё в массив — скрипт ел по 1–2 ГБ памяти.
После перехода на генератор:
function fetchCars(): Generator {
$page = 1;
do {
$data = apiRequest('cars', ['page' => $page]);
foreach ($data['items'] as $car) {
yield $car;
}
$page++;
} while (!empty($data['items']));
}
? Память упала с 2 ГБ до 10 МБ, время выполнения осталось почти тем же.
? Для «гиков»: как генераторы работают под капотом
1. Генератор — это объект
В PHP генератор — это объект класса Generator
, реализующий Iterator
и Traversable
.
Он умеет:
хранить текущее состояние функции;
приостанавливать выполнение на
yield
;возобновлять выполнение с того же места.
2. Жизненный цикл
Вызов функции-генератора не запускает её сразу — создаётся объект
Generator
.При первой итерации выполнение идёт до
yield
.yield
возвращает значение и «замораживает» функцию.Следующая итерация продолжает выполнение с того же места.
Когда функция завершается — генератор помечается как завершённый.
3. На уровне Zend Engine
Если скомпилировать функцию с yield
через VLD (Vulcan Logic Disassembler), мы увидим, что каждый yield
— это инструкция, которая:
сохраняет стек вызовов;
запоминает переменные;
возвращает значение в вызывающий код.
4. Разница с массивами
Массив создаёт все элементы в памяти.
Генератор хранит один текущий элемент (
zval
) и перезаписывает его.Поэтому можно обойти миллион элементов, используя пару сотен килобайт.
5. Пример бесконечного генератора
function counter(): Generator {
$i = 0;
while (true) {
yield $i++;
}
}
foreach (counter() as $num) {
if ($num > 5) break;
echo $num . PHP_EOL;
}
С массивом это невозможно — память просто закончится.
Benchmark results (1,000,001 rows)
Method |
Time |
Memory used |
Peak diff |
Rows |
---|---|---|---|---|
Array (eager) |
1.401s |
120 B |
395.92 MB |
1,000,001 |
Generator |
1.012s |
0 B |
0.00 MB |
1,000,001 |
Эти результаты показывают, что ленивые генераторы могут значительно сократить использование памяти при обработке больших наборов данных, таких как CSV.
Визуальные результаты
Пиковый расход памяти:

Время выполнения:

Исходный код
Вы можете попробовать всё сами — код, использованный в этой статье, имеет открытый исходный код:
? github.com/phpner/phpner-php-lazy-evaluation-demo
Включает в себя:
Тест CSV: массив (жадный) против генератора (ленивый)
Моделирование потока NDJSON
Профилирование памяти и времени
Инструменты с поддержкой CLI
Генератор примеров данных
Тесты PHPUnit
Вывод
Генераторы (
yield
) и Iterator API — must-have для оптимизации.Они позволяют обрабатывать миллионы записей без перегрузки памяти.
Генераторы — для простых случаев, Iterator API — для сложных.
Если вы ещё ими не пользовались — попробуйте в следующем проекте.
? А вы используете генераторы в продакшене? Поделитесь в комментариях своими кейсами!
DExploN
Почему генераторы ассоциируют всегда с экономией памяти, хотя никакую память сами по себе они не экономят?
Замените контракт
function
fetchCars():\Generator<Car>
На
function
fetchCars(int $ofset, int $limit): array<Car>
И получите такой же буст по экономии памяти, просто менее удобное использование.
Или вообще уберите метод и поднимите логику на уровень выше, т.е. сделайте ровно тоже самое только без обертки функции.
Опять же можно сделать свой итератор, который так же ходит по апи и не коллекционирует весь объем, просто это больше кода, а генератор хорошая удобная замена.
В итоге, когда разговариваешь с людьми про генераторы, то от них один ответ: он для экономии памяти, а так как память экономить не всегда приходится, так как крудошлепы, или просто есть другие методы экономии, то и генератор не понятно для чего (максимум расскажут пример с csv) и не используют. Хотя это просто побочный эффект одного из кейсов использования, который при желании можно решить и без генератора.
CuBeR_HeMuL
Генераторы нужны не только для экономии памяти, но, например, для экономии времени. Например, я хочу получить из базы миллион строк, если бы я сделал запрос с limit 1000000, то время получения результата было бы намного больше, чем если бы я сделал 10 запросов по 100000 строк, потому что экономятся ресурсы базы, ресурсы сети и, опять же, память. Все это экономит время (да, за счет того, что экономится память, но все же)