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

Типичная картина: FPM-воркер стартует с 20 МБ резидентной памяти, обрабатывает запрос, который читает большой конфиг-массив, и после ответа висит уже на 60 МБ. Код массив не менял — только читал в цикле. Через сотню запросов pm.max_children упирается в лимит памяти, ядро начинает свопить, время ответа прыгает с 40 мс до секунды. Причина — в том, как PHP считает ссылки и когда решает копировать данные. Copy-on-write должен экономить память, но под нагрузкой ломается раньше, чем вы успеваете его заметить.
Разбор актуален для PHP 7.x и 8.x — модель подсчёта ссылок и copy-on-write в этих ветках работает одинаково в принципиальных моментах. Мы смотрим только на механизм копирования массивов и строк; сборку циклических ссок (garbage collector) трогаем отдельной строкой в конце.
Как copy-on-write устроен внутри
Каждое значение в PHP — это структура zval. Для массивов, объектов и строк внутри лежит указатель на область данных, у которой есть счётчик ссылок — refcount. Когда вы присваиваете переменную другой переменной, PHP не копирует данные: он увеличивает refcount на единицу и обе переменные указывают на один и тот же массив.
Копирование срабатывает в момент записи. Как только одна из переменных, делящих данные, пытается изменить массив, PHP проверяет: если на данные ссылается больше одного владельца, движок делает полную копию (это и есть «separation»), меняет счётчики и пишет уже в свою версию. Отсюда название — copy-on-write, копирование при записи.
Copy-on-write бесплатен ровно до первой записи. Проблема в том, что «записью» PHP считает не только явное присваивание — иногда достаточно передать массив туда, где движок не может гарантировать, что его не изменят.
Есть два флага, которые определяют поведение zval при передаче:
- refcount — сколько владельцев у данных. Пока он равен 1, изменение идёт на месте без копии.
- is_ref — признак того, что значение связано по-настоящему через
&(PHP-reference). Такие значения copy-on-write не разделяет: они всегда делят одну область данных и copy-on-write к ним не применяется.
Термин: separation
Separation (отделение) — момент, когда PHP делает физическую копию разделяемых данных перед записью. Именно separation стоит денег: аллокация памяти и полное копирование содержимого массива. Одна операция изменения большого массива, у которого два владельца, — это копия всего массива целиком.
Где рвётся первым
1. Передача большого массива по значению и «случайная» запись
Вы передаёте массив на 200 тысяч элементов в функцию по значению. Пока функция только читает — copy-on-write работает, копии нет. Но стоит внутри написать $items['flag'] = true; или отсортировать через sort($items) — и происходит separation: полная копия массива в памяти воркера.
<?php
// PHP 8.x
function processItems(array $items): int {
// Пока только читаем — copy-on-write не копирует, работаем на общих данных
$total = 0;
foreach ($items as $row) {
$total += $row['price'];
}
// А вот здесь separation: массив копируется целиком,
// потому что refcount больше 1 (владелец снаружи ещё жив)
sort($items);
return $total;
}
$big = loadCatalog(); // 200_000 элементов, ~40 МБ
$sum = processItems($big); // после sort() в памяти две копии на пике
Пик памяти в момент sort() — двойной размер массива. Если таких вызовов несколько за запрос, воркер раздувается и не отдаёт память до конца запроса.
2. Ссылки в foreach, которые вы забыли снять
Классика, которая ломает copy-on-write надолго. После foreach ($arr as &$row) последний элемент остаётся связанным через reference. У этого элемента выставлен is_ref, и все дальнейшие операции с массивом, включая передачу в функции, ведут себя непредсказуемо: copy-on-write к is_ref-значениям не применяется, а лишние копии возникают в самых неожиданных местах.
<?php
// PHP 8.x
$rows = getRows();
foreach ($rows as &$row) {
$row['tax'] = $row['price'] * 0.2;
}
unset($row); // без этой строки последний элемент остаётся is_ref
// Если unset забыт — следующая обработка $rows даёт лишние копии
// и один и тот же элемент может «протечь» между итерациями
Правило простое: после foreach по ссылке всегда делайте unset($row). Забытый unset под нагрузкой — не косметика, а стабильный источник копий и трудноуловимых багов с данными.
3. Свойства объектов и разделяемые массивы
Когда вы читаете свойство-массив объекта в локальную переменную, а потом её меняете, срабатывает separation. Опасно, если объект переиспользуется между запросами (например, в долгоживущем воркере на RoadRunner или в очереди): изменение локальной копии заставляет копировать данные объекта на каждом обороте.
Как это измерить
Не гадайте по симптомам — снимайте цифры. Два инструмента дают ответ без профилировщика:
memory_get_usage(true)— резидентная память процесса с учётом аллокатора PHP. Ставьте замер до и после подозрительной операции.debug_zval_refcount()доступен не всегда; вместо него в отладке используйтеxdebug_debug_zval()из Xdebug — он показываетrefcountиis_refдля переменной. Точное имя и доступность зависят от версии Xdebug, сверьтесь с документацией расширения.
<?php
// PHP 8.x — грубый замер separation
$before = memory_get_usage(true);
$copy = $bigArray; // копии ещё нет, только refcount++
$copy['touched'] = 1; // здесь separation
$after = memory_get_usage(true);
printf("Прирост: %.2f МБ\n", ($after - $before) / 1048576);
Если прирост сопоставим с размером массива — значит, произошло полное копирование. Если близок к нулю — copy-on-write отработал, и данные остались общими.
Что с этим делать
| Симптом | Причина | Что делать |
|---|---|---|
| Пик памяти = двойной размер массива | Separation при изменении переданного по значению массива | Не мутируйте входной массив; возвращайте новый только нужный срез, а не весь массив |
| Данные «протекают» между итерациями | Забытый unset после foreach по ссылке | Всегда unset($row) после цикла по & |
| Воркер раздувается со временем | Изменение свойства-массива долгоживущего объекта | Работайте с копией явно, не мутируйте разделяемое состояние |
| Память не падает после запроса | Крупные структуры держатся до конца запроса | Освобождайте вручную через unset, дробите обработку на пакеты |
Отдельная стратегия для больших наборов — не держать всё в массиве. Генераторы (yield) отдают элементы по одному и не создают массив целиком, поэтому separation им не грозит: копировать нечего.
<?php
// PHP 8.x — вместо массива на 200k элементов
function readCatalog(string $path): \Generator {
$handle = fopen($path, 'r');
while (($line = fgets($handle)) !== false) {
yield json_decode($line, true); // один элемент за раз
}
fclose($handle);
}
$total = 0;
foreach (readCatalog('catalog.jsonl') as $item) {
$total += $item['price'];
}
// В памяти в любой момент один элемент, а не весь каталог
Граничные случаи, где интуиция подводит
- Строки тоже copy-on-write. Конкатенация в цикле (
$s .= $chunk;) при живом втором владельце строки даёт копии. Для сборки больших строк используйте буфер или массив с последующимimplode. - Immutable-структуры не спасают автоматически. Объект без сеттеров всё равно копирует внутренний массив при чтении-и-записи в локальную переменную.
- Долгоживущие воркеры меняют правила. В классическом FPM память освобождается по концу запроса, и разбухший воркер — проблема на десятки одновременных запросов. В RoadRunner или Swoole процесс живёт между запросами, и любая накопленная копия или забытая ссылка остаётся навсегда до рестарта воркера.
- Циклические ссылки — отдельная тема: их copy-on-write не касается, ими занимается сборщик мусора PHP. Здесь мы их не разбираем.
Что делать дальше
Начните с замера: обвесьте memory_get_usage(true) подозрительные функции, которые принимают большие массивы, и найдите точки, где прирост равен размеру данных. Это ваши separation. Для долгоживущих воркеров настройте перезапуск по лимиту памяти (в FPM — pm.max_requests, чтобы воркер периодически стартовал заново), а в проде смотрите на резидентную память процессов и частоту их рестартов. Если крупные наборы данных приходят из файла или базы — переводите обработку на генераторы, чтобы вопрос копирования снялся сам собой.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: PHP Manual: References Explained (Reference Counting Basics), PHP Internals: PHP 7 Virtual Machine / zval structure
Частые вопросы
У меня PHP 7.4, а не 8.x — механизм тот же?
Я добавил unset после foreach по ссылке, но память всё равно растёт. Что ещё смотреть?
Передавать большие массивы по ссылке (&) — это оптимизация?
Грозит ли это утечками в обычном PHP-FPM?
Как понять, сработало copy-on-write или произошла копия, без Xdebug?
Генераторы полностью решают проблему памяти?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.