Память: подсчёт ссылок, copy-on-write и генераторы

Блок вопросов уровня senior: почему присваивание массива дёшево, зачем нужен сборщик мусора и как прочитать файл на гигабайт.

Copy-on-write — приём, при котором копия данных создаётся не в момент присваивания, а в момент первой записи. Пока обе переменные только читают, физически они делят одну область памяти.

Вопрос 1: «Как PHP освобождает память?»

Что проверяет интервьюер

Знаете ли вы, что в PHP два механизма, а не один. Основной — подсчёт ссылок (refcounting): у каждого значения есть счётчик, сколько переменных на него указывают; как только счётчик обнуляется, память освобождается немедленно. Дополнительный — сборщик циклов, который решает проблему взаимных ссылок, недостижимых из кода, но с ненулевым счётчиком.

<?php
class Node { public ?Node $ref = null; }

$a = new Node();
$b = new Node();
$a->ref = $b;
$b->ref = $a;

unset($a, $b);
echo gc_collect_cycles(), "\n";

Вывод:

2

Два объекта ссылаются друг на друга. После unset() из кода к ним не добраться, но счётчики ссылок остались равны единице — refcounting сам их не удалит. Именно такие «мусорные циклы» собирает алгоритм, который запускается автоматически, когда внутренний буфер набирает порог (по умолчанию 10 000 подозрительных значений), или вручную через gc_collect_cycles().

Вопрос 2: «Что происходит при $b = $a;, если $a — массив на миллион элементов?»

<?php
$a = ['раз', 'два', 'три'];
$b = $a;
$b[] = 'четыре';
echo count($a), " ", count($b), "\n";

$c = &$a;
$c[] = 'четыре';
echo count($a), " ", count($c), "\n";

function pushValue(array $arr): void { $arr[] = 'x'; }
function pushRef(array &$arr): void { $arr[] = 'x'; }

$d = [1];
pushValue($d);
echo count($d), "\n";
pushRef($d);
echo count($d), "\n";

Вывод:

3 4
4 4
1
2

Правильный ответ на вопрос: копирования не происходит. PHP лишь увеличивает счётчик ссылок, и обе переменные указывают на один массив. Настоящее копирование случится в момент, когда одна из них попытается что-то записать — это и есть copy-on-write. Поэтому передача больших массивов в функции по значению дешева при чтении и дорога при модификации.

Ссылка & — другое: она делает две переменные разными именами одной и той же ячейки, и copy-on-write отключается. Ссылки нужны редко; в современном коде их обычно заменяют возвратом нового значения или объектами (объекты и так передаются по идентификатору — переменная хранит не сам объект, а его хэндл).

Вопрос 3: «Чем опасен foreach по ссылке?»

Классическая задача-ловушка, которую любят давать «на бумажке»:

<?php
$arr = [1, 2, 3];
foreach ($arr as &$v) {
    $v *= 2;
}
foreach ($arr as $v) {
}
print_r($arr);

Вывод:

Array
(
    [0] => 2
    [1] => 4
    [2] => 4
)

Ожидали [2, 4, 6]. Разгадка: после первого цикла $v осталась ссылкой на последний элемент. Второй цикл на каждой итерации присваивает значение в $v, то есть переписывает третий элемент: сначала 2, потом 4, потом сам себя. Лечится одной строкой — unset($v); сразу после цикла по ссылке.

Вопрос 4: «Как обработать файл, который не помещается в память?»

Ответ — генераторы. Функция с yield не выполняется целиком, а возвращает объект Generator, который отдаёт значения по одному и запоминает место остановки. В памяти в каждый момент лежит одна строка, а не весь набор.

<?php
function readRows(int $n): Generator {
    for ($i = 1; $i <= $n; $i++) {
        echo "  генератор отдаёт $i\n";
        yield $i;
    }
    return "готово";
}

$gen = readRows(3);
foreach ($gen as $row) {
    echo "обработали $row\n";
}
echo $gen->getReturn(), "\n";

Вывод:

  генератор отдаёт 1
обработали 1
  генератор отдаёт 2
обработали 2
  генератор отдаёт 3
обработали 3
готово

Обратите внимание на порядок строк: вычисление и обработка чередуются — это и есть ленивость. В реальном коде так читают CSV на несколько гигабайт:

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

foreach (csvRows('orders.csv') as $row) {
    // память не растёт, сколько бы строк ни было в файле
}

Ограничения генератора тоже стоит назвать: его нельзя перемотать назад (rewind() после начала обхода бросит исключение), нельзя обойти дважды и нельзя узнать количество элементов через count().

Типичные ошибки кандидатов

  • Говорят «в PHP есть сборщик мусора», не упоминая, что основной механизм — подсчёт ссылок, а GC добирает только циклы.
  • Считают, что $b = $a для массива сразу копирует данные — и «оптимизируют» код ссылками там, где copy-on-write и так всё делает.
  • Не могут объяснить результат [2, 4, 4] в задаче с foreach по ссылке.
  • Думают, что объекты в PHP передаются по ссылке. Передаётся значение-идентификатор объекта; $obj = new Foo() внутри функции не изменит переменную снаружи.
  • Возвращают из функции массив на миллион строк вместо генератора и упираются в memory_limit.

Как ответить кратко

«Память освобождается подсчётом ссылок: обнулился счётчик — значение удалено сразу. Отдельный сборщик циклов добирает объекты, ссылающиеся друг на друга. Присваивание массива не копирует данные — работает copy-on-write, копия появляется при первой записи; ссылка & это поведение отключает и после foreach по ссылке переменную надо unset. Для больших наборов данных использую генераторы: yield отдаёт элементы лениво и держит в памяти один элемент вместо всего массива».

Проверьте себя
1. Какой механизм в PHP является основным для освобождения памяти?
AПодсчёт ссылок: значение удаляется, как только счётчик обнулился
BПериодическая сборка мусора по таймеру
CРучное освобождение через unset() — иначе память не вернётся
DОсвобождение только в конце запроса
2. Что произойдёт при $b = $a, если $a — большой массив, и никто не изменяет данные?
AМассив будет скопирован целиком в момент присваивания
BКопия не создаётся: увеличивается счётчик ссылок, копирование произойдёт при первой записи
CБудет создана ссылка, и изменения $b станут видны в $a
DPHP выбросит предупреждение о нехватке памяти
3. Почему после foreach ($arr as &$v) и последующего обычного foreach массив [1,2,3] превращается в [2,4,4]?
AПотому что первый цикл удвоил только два первых элемента
BПотому что foreach по ссылке сбрасывает внутренний указатель массива
CПотому что $v осталась ссылкой на последний элемент и второй цикл перезаписывал его
DПотому что PHP кэширует последнее значение цикла