Copy-on-write в PHP: почему массив живёт дольше, чем вы думали
Скрипт держит 400 МБ вместо ожидаемых 200, хотя вы «ничего не копировали». Разбираем, как PHP решает, когда дублировать данные, и почему foreach по ссылке взрывает память.

Вы читаете CSV в массив на 2 миллиона строк, передаёте его в функцию, а memory_get_usage() показывает не 200 МБ, а 400. Внутри функции вы «ничего не меняли» — только прошли foreach. Дело в том, что PHP не копирует данные при передаче, пока их не изменят. А изменение может произойти там, где вы его не ждёте — например, при итерации по ссылке или при доступе к элементу через &. Разберём, как работает copy-on-write в PHP 8.x, где именно он срабатывает и почему счётчик памяти прыгает без явного присваивания.
Как PHP хранит значения: zval и refcount
Любое значение в PHP описывается структурой zval. Скалярные типы (int, bool, float) хранятся прямо в zval и копируются целиком — там copy-on-write не нужен. Сложные типы (строки, массивы, объекты) хранят указатель на выделенную в куче структуру, а рядом лежит счётчик ссылок.
Когда вы пишете $b = $a для массива, PHP не дублирует массив. Оба имени указывают на одну и ту же структуру, а её счётчик ссылок 1 увеличивается на единицу. Физическое копирование откладывается до первой записи — это и есть copy-on-write, CoW.
Присваивание массива в PHP — операция за O(1). Дорогой становится первая же модификация копии: тогда PHP разворачивает весь массив в память заново.
Что происходит при записи
Как только вы меняете $b — например, $b[] = 1 — PHP проверяет счётчик. Если на структуру ссылается больше одного имени, она отделяется: создаётся полная копия, счётчик оригинала уменьшается, и запись идёт уже в свежий массив. Этот момент называют separation (отделение).
<?php
// PHP 8.3
$source = range(1, 1_000_000);
echo memory_get_usage() . "\n"; // база
$copy = $source; // refcount++, копии нет
echo memory_get_usage() . "\n"; // почти не вырос
$copy[0] = 42; // separation: массив дублируется целиком
echo memory_get_usage() . "\n"; // вырос примерно вдвое
Третий вывод покажет скачок: изменение одного элемента заставило PHP скопировать весь миллион.
Где CoW ломается незаметно
Проблема не в том, что копирование происходит. Проблема в том, что оно происходит там, где вы его не ждали, и удваивает пик памяти на большом массиве.
foreach по ссылке
Самая частая ловушка. Если пройти массив как foreach ($data as &$row), PHP обязан сделать структуру изменяемой на месте — значит, отделить её от всех остальных ссылок. Один foreach по ссылке на массиве, который где-то ещё используется, тянет за собой полную копию.
<?php
// PHP 8.3
function sumFirst(array $rows): int {
$total = 0;
// ссылка НЕ нужна для чтения, но провоцирует separation
foreach ($rows as &$row) {
$total += $row['price'];
}
unset($row); // висячая ссылка — отдельная беда
return $total;
}
Здесь ссылка не нужна: вы только читаете $row['price']. Уберите & — и копирования не будет, потому что чтение счётчик не трогает.
Передача в функцию и возврат
Передача массива по значению сама по себе копию не создаёт: аргумент — это ещё одна ссылка на ту же структуру. Копия появится, только если внутри функции вы модифицируете аргумент. То есть чистая функция, которая массив читает и возвращает число, память не удваивает.
А вот если функция принимает массив и что-то в нём меняет, а снаружи он ещё жив — получите полную копию на время работы функции.
Явная ссылка &
Как только на значение навешивается настоящая ссылка ($b = &$a), CoW отключается для этой связки: обе переменные обязаны видеть изменения друг друга, поэтому отделять нечего и копирование не спасает. Zval помечается как «is_ref», и любые дальнейшие присваивания копируют данные сразу, теряя оптимизацию.
Как проверить, происходит ли копирование
Гадать не нужно — есть инструменты, которые показывают счётчик ссылок и факт разделения.
- debug_zval_refcount через xdebug. Функция
xdebug_debug_zval('varname')печатает refcount и признак is_ref для переменной. Требует установленного Xdebug 3.x. - memory_get_usage() до и после. Грубо, но честно: замерьте память перед операцией и сразу после. Скачок вдвое на большом массиве — верный признак separation.
- memory_get_peak_usage(true). Показывает пик с учётом реально запрошенной у ОС памяти. Полезно ловить кратковременные удвоения, которые обычный
memory_get_usageуже не видит после сборки мусора.
Точное имя и поведение отладочных функций Xdebug сверяйте в документации вашей версии расширения — API между Xdebug 2 и 3 менялся.
Сравнение подходов на большом массиве
| Операция над массивом на 1 млн элементов | Копия структуры | Пик памяти |
|---|---|---|
$b = $a без последующей записи | нет | ×1 |
| Передача в функцию, только чтение | нет | ×1 |
foreach ($a as $v) (по значению) | нет | ×1 |
foreach ($a as &$v) при живой второй ссылке | да | ×2 |
Запись $b[0] = x в копию | да | ×2 |
$b = &$a (жёсткая ссылка) | особый режим is_ref | зависит от дальнейших операций |
Практические правила
- Не ставьте
&вforeach, если только читаете элементы. Ссылка нужна лишь когда вы меняете сам массив внутри цикла. - После
foreach ... as &$rowвсегда делайтеunset($row): висячая ссылка на последний элемент — источник трудноуловимых багов при повторном использовании переменной. - Если функция обрабатывает большой массив и результат ей нужен как новый — возвращайте новый массив, а не мутируйте вход. Мутация входа при живой внешней ссылке гарантирует копию.
- Для потоковой обработки миллионов строк используйте генераторы (
yield) вместо загрузки всего массива — тогда вопрос CoW не встаёт, потому что весь набор в памяти не лежит.
Где подход всё равно ломается
CoW не работает для объектов так, как для массивов. Объекты в PHP передаются и присваиваются по идентификатору (handle): $b = $a для объекта означает, что оба имени указывают на один и тот же экземпляр, и изменение через $b видно через $a. Копия объекта делается только через clone, и то поверхностная. Если вы рассчитываете на copy-on-write при работе с массивами объектов, помните: копируется массив-контейнер, а объекты внутри остаются общими.
Ещё один граничный случай — вложенные массивы. Separation при записи копирует верхний уровень; вложенные массивы отделяются только при записи именно в них. Это экономит память, но усложняет предсказание пика, если вы правите глубоко вложенные структуры выборочно.
Что мерить и что настроить дальше
На проде смотрите на memory_get_peak_usage(true) в обработчиках, которые грузят большие выборки — именно пик, а не текущее значение, упирается в memory_limit. Если пик стабильно вдвое выше ожидаемого размера данных, ищите скрытое separation: foreach по ссылке, мутацию аргумента или лишний &. Следующий шаг — перевести тяжёлые операции чтения на генераторы и профилировать память через Xdebug или Blackfire на реальном объёме данных, а не на тестовых десяти строках.
Дефектный акт по фото: Осмотр по фотографиям: классификация дефектов, ссылки на нормы, требования к устранению. Готовый акт в Word.
Спросить за 80 ₽Источники: PHP Manual: Reference Counting Basics, PHP Manual: References Explained
Частые вопросы
У меня PHP 7.4, а не 8.x — copy-on-write работает так же?
Я убрал & из foreach, а память всё равно растёт. Что ещё смотреть?
Передача массива в функцию замедляет скрипт из-за копирования?
Помогает ли unset() освободить память сразу?
Как объекты ведут себя при копировании, если не как массивы?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.