Память: подсчёт ссылок, 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отдаёт элементы лениво и держит в памяти один элемент вместо всего массива».