Search
Write a publication
Pull to refresh

Ленивые вычисления в PHP: как генераторы и итераторы экономят память и ускоряют код

Level of difficultyMedium
Reading time5 min
Views1.9K
Original author: Oleksandr Vasyliev

Вы когда-нибудь пытались загрузить в память 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

Примечание: когда генераторы действительно полезны

Если задача простая — один раз пройти CSV построчно, то хватит обычного while (fgetcsv(...)).

Генераторы становятся уместны, когда:

  • нужен ленивый поток (читать/запрашивать ровно столько, сколько потребитель возьмёт);

  • важна возможность остановиться раньше (экономия запросов к API или БД);

  • требуется композиция операций (filter, map, take) поверх любого источника.

Пример ленивой пагинации с ранней остановкой:

function fetchCars(): Generator {
    for ($page = 1; ; $page++) {
        $data = apiRequest('cars', ['page' => $page]);
        if (empty($data['items'])) break;
        foreach ($data['items'] as $car) yield $car;
    }
}

foreach (take(filter(fetchCars(), fn($c) => $c['price'] > 1_000_000), 100) as $car) {
    process($car); // лишние страницы не запрашиваются
}

Реальный кейс из продакшена

Мы парсили 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. Жизненный цикл

  1. Вызов функции-генератора не запускает её сразу — создаётся объект Generator.

  2. При первой итерации выполнение идёт до yield.

  3. yield возвращает значение и «замораживает» функцию.

  4. Следующая итерация продолжает выполнение с того же места.

  5. Когда функция завершается — генератор помечается как завершённый.

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 — для сложных.

  • Если вы ещё ими не пользовались — попробуйте в следующем проекте.

💬 А вы используете генераторы в продакшене? Поделитесь в комментариях своими кейсами!

Источник

Перевод оригинала на dev.to

Tags:
Hubs:
+3
Comments37

Articles