
Полная версия
PHP. Под капотом. Архитектура, память и за гранью кода

PHP. Под капотом. Архитектура, память и за гранью кода
ГлаваГлава 1. Жизненный цикл запроса
С чего обычно начинают изучение PHP? С echo «Hello, World!»;. Синтаксис прост, порог входа низкий — написал, обновил страницу, увидел результат. Но в этом удобстве прячется иллюзия, которая формирует неверное представление о том, как язык работает на самом деле. Давайте сразу, с первой же страницы, разберёмся с тремя популярными мифами.
Первый миф звучит так: «PHP — это интерпретатор, который читает ваш код строка за строкой и сразу выполняет». Это неправда. PHP — транслятор в байт-код. Ваш index.php проходит несколько стадий: лексический анализ превращает его в поток токенов, парсер собирает из токенов абстрактное синтаксическое дерево, а компилятор генерирует из этого дерева опкоды — низкоуровневые инструкции для Zend Engine. Интерпретируется именно байт-код, а не исходный текст. А с подключением OPCache этот байт-код кешируется в разделяемой памяти, и при повторных запросах PHP вообще не читает ваши файлы с диска. В этом смысле PHP ближе к Java с её HotSpot-компиляцией, чем к Bash.
Второй миф: «PHP умирает после каждого запроса». Тоже не совсем верно. Умирает не процесс, а контекст запроса. В случае PHP-FPM процесс живёт долго и обслуживает сотни или тысячи запросов. Существует фаза инициализации модуля, которая выполняется один раз при старте процесса. Существует фаза инициализации запроса, которая срабатывает для каждого HTTP-запроса. И существует фаза завершения запроса, после которой процесс не исчезает, а возвращается в пул ожидания и ждёт следующего. Если вы пишете расширение на C, вы обязаны знать эти фазы. Если вы архитектор приложения — должны учитывать, что в долгоживущих режимах возможны утечки между запросами.
Третий миф: «PHP — это только для HTTP». На самом деле между Zend Engine и внешним миром находится слой абстракции под названием SAPI — Server Application Programming Interface. HTTP-запросы через Apache с mod_php или через Nginx с PHP-FPM — это лишь самые распространённые варианты. Вы можете запустить PHP из командной строки, встроить его в другой сервер или использовать для асинхронных демонов. SAPI — это просто способ взаимодействия, и он сменный.
Теперь, когда мы разобрались с мифами, давайте пройдём весь путь — от нажатия пользователем Enter в браузере до возврата готового HTML.
Представьте архитектуру PHP как трёхслойную конструкцию. Наверху находится SAPI — слой, который общается с внешним миром: принимает запрос, передаёт его на обработку и возвращает ответ. Он не знает, как парсить PHP, он только организует вход и выход. Ниже находится Zend Engine — сердце языка, которое компилирует и исполняет код. И в самом низу — расширения, от PDO и JSON до cURL и ваших собственных, написанных на C.
Когда мы говорим о веб-сервере, чаще всего используется PHP-FPM с SAPI FastCGI. Там есть мастер-процесс, который управляет пулом воркеров. Каждый воркер — это долгоживущий процесс, внутри которого крутится бесконечный цикл: принять запрос, выполнить, отдать ответ, ждать следующий.
Теперь посмотрим на жизнь этого воркера глазами разработчика C-расширения. При старте процесса происходит фаза MINIT — Module Initialization. Она выполняется один раз. В этот момент расширения регистрируют классы, константы, ini-директивы, выделяют глобальные ресурсы. Здесь можно создать подключение к разделяемой памяти, которое будет жить всё время существования процесса. Главное — не забыть освободить эти ресурсы в фазе MSHUTDOWN, иначе получим классическую утечку памяти.
Затем для каждого HTTP-запроса выполняется RINIT — Request Initialization. Здесь создаются суперглобальные массивы вроде GETиGETи_SERVER, открываются подключения к базе данных. Всё, что создано в RINIT, должно быть уничтожено в RSHUTDOWN — Request Shutdown. PHP предоставляет для этого менеджер памяти с маркерами, но расширения должны корректно подчищать за собой. Если какая-то библиотека забывает освободить память после каждого запроса, воркер будет утекать по мегабайту в час, пока не съест всю доступную RAM.
После RINIT начинается исполнение скрипта. Файл читается, компилируется в опкоды и выполняется на виртуальной машине Zend Engine. Затем RSHUTDOWN: вызываются деструкторы объектов, закрываются файловые дескрипторы, сбрасываются буферы вывода, а постоянные соединения к базе данных возвращаются в пул, но не закрываются. И в конце жизни процесса, при плавном перезапуске PHP-FPM, выполняется MSHUTDOWN — освобождение глобальных ресурсов, зарегистрированных в MINIT.
Здесь кроется важнейшая архитектурная особенность PHP, которую называют «shared-nothing» или «ничего общего». Каждый запрос стартует с чистого листа. Переменные, созданные в одном запросе, недоступны в другом. Это не ограничение языка, а осознанный инженерный выбор. Его преимущества огромны: нет гонок за состоянием между запросами, утечка памяти в одном запросе изолирована, масштабирование тривиально — запросы без состояния можно свободно раскидывать по пулу процессов. Оборотная сторона: нельзя хранить состояние в памяти PHP между запросами, для этого нужны Redis, Memcached или APCu. И каждый запрос платит цену инициализации, хотя OPCache и Preloading эту цену драматически снижают.
И здесь мы приходим к важному инженерному следствию: любая технология, которая пытается сделать PHP долгоживущим приложением — будь то Swoole, RoadRunner или ReactPHP — должна вручную эмулировать RSHUTDOWN, чтобы предотвратить пересечение состояний разных запросов. Без этого ваш «асинхронный PHP» превратится в генератор трудновоспроизводимых багов.
Теперь о том, как PHP превращает ваш код в исполняемые инструкции. Это четырёхстадийный конвейер. Сначала лексический анализ: файл читается и превращается в поток токенов — T_OPEN_TAG, T_ECHO, T_LNUMBER и так далее. Увидеть токены можно командой token_get_all. Лексер очень быстр, это конечный автомат, он не аллоцирует сложные структуры.
Затем синтаксический анализатор строит из токенов AST — абстрактное синтаксическое дерево. Именно здесь обнаруживаются синтаксические ошибки. До PHP 7 парсер генерировал опкоды напрямую, без явного AST. Появление AST позволило делать более качественные сообщения об ошибках, создавать инструменты статического анализа вроде PHPStan и Psalm, а также проводить оптимизации на уровне дерева до генерации опкодов.
Третья стадия — компиляция AST в опкоды. Компилятор обходит дерево и генерирует линейную последовательность инструкций для виртуальной машины. Здесь происходит свёртка констант: если вы напишете 60 * 60 * 24, PHP вычислит 86400 на этапе компиляции и положит готовое число в опкод. Здесь же разрешаются имена функций и классов, создаются таблицы обработчиков исключений.
И наконец — выполнение на виртуальной машине. Это классический цикл: прочитать опкод, выполнить его обработчик, перейти к следующему. Каждый опкод оперирует zval'ами — 16-байтными контейнерами значений, которые мы подробно разберём в следующей главе. С PHP 8.0 виртуальная машина получила JIT-компилятор, который отслеживает «горячие» участки кода и транслирует их напрямую в машинный код процессора.
Теперь — OPCache, компонент, без которого современный PHP немыслим. Без кеширования все четыре стадии выполнялись бы для каждого файла при каждом запросе. Для приложения на Symfony с двумя сотнями подключаемых файлов это означало бы сотни тысяч операций парсинга и компиляции в секунду. OPCache решает эту проблему радикально: он сохраняет результат компиляции в разделяемой памяти и переиспользует его. Кешируются не просто опкоды, а целые таблицы функций и классов, определённых в файле, а также interned strings — строки, которые повторяются в коде: имена классов, методов, констант, ключи массивов.
Идея interned strings проста и гениальна. Вместо того чтобы хранить строку «getUserById» в сотне разных мест, она хранится один раз в общей таблице, а все опкоды ссылаются на неё по указателю. Сравнение таких строк сводится к сравнению указателей — O(1) вместо O(n). На больших кодовых базах это экономит мегабайты оперативной памяти.
Механизм проверки актуальности кеша работает так: PHP берёт путь к файлу, ищет запись в shared memory, сравнивает время модификации файла с сохранённым. Если совпадает — файл даже не читается с диска. При продакшен-деплое, когда все файлы меняются разом через атомарную замену директории, проверку можно отключить директивой opcache.validate_timestamps = 0. После этого OPCache вообще не трогает диск, но любое изменение кода требует перезапуска PHP-FPM или сброса кеша.
А теперь — технология, которая поднимает PHP на новый уровень: Preloading. Даже с OPCache у каждого воркера изначально пустой кеш. Первый запрос к новорождённому воркеру платит цену компиляции сотен файлов. Preloading решает эту проблему. В php.ini указывается скрипт, который выполняется один раз при старте мастер-процесса PHP-FPM. Этот скрипт вызывает opcache_compile_file для всех критически важных классов — сущностей Doctrine, сервисов Symfony, контроллеров. Скомпилированные опкоды загружаются в разделяемую память OPCache и становятся доступны всем дочерним процессам. Воркеры рождаются с «горячим» кешем фреймворка. Экономия памяти может достигать 30-50% на воркер, потому что опкоды хранятся в shared memory, а не дублируются для каждого процесса.
У Preloading есть цена: изменение любого preloaded-файла требует перезапуска PHP-FPM. Ошибка в таком файле может сделать сервер неспособным запуститься. Нельзя preload'ить классы с побочными эффектами — подключения к базе данных, чтение конфигов во время объявления класса. Только чистые определения.
Итак, мы разобрали полный жизненный цикл. PHP-FPM воркер не умирает после запроса — умирает только контекст. PHP — не интерпретатор, а транслятор в байт-код. OPCache — не просто кеш файлов, а сложная система с разделяемой памятью и интернированием строк. Shared-nothing — это не баг, а архитектурный выбор, дающий изоляцию ценой невозможности хранить состояние в памяти процесса.
В следующей главе мы спустимся на уровень ниже и посмотрим, как PHP хранит ваши переменные в памяти, что такое zval и почему после PHP 7 ваши массивы стали занимать в два-три раза меньше места, а серверы начали держать втрое больше одновременных соединений.
Глава 2. Модель памяти и Zend Memory Manager
В первой главе мы говорили о времени жизни PHP-процесса. Теперь давайте поговорим о пространстве — о том, как PHP хранит ваши переменные, почему оператор присваивания «=» почти никогда не означает копирования данных и за что мы должны быть благодарны разработчикам PHP 7.
Начнём с атомарной единицы всего. В основе системы типов PHP лежит структура на C, которая называется zval — сокращение от Zend Value. Всё, что вы создаёте в своём коде — числа, строки, массивы, объекты, ресурсы — в какой-то момент становится zval'ом внутри рантайма. Это контейнер, который несёт в себе значение и метаинформацию о нём.
До PHP 7 этот контейнер был, прямо скажем, громоздким. На 64-битных системах один zval занимал 48 байт. Представьте: чтобы хранить целое число 42, PHP выделял в куче 48 байт. Миллион переменных — 48 мегабайт только на контейнеры, даже если внутри лежат единицы. Умножьте это на сотню одновременных запросов к WordPress или Drupal — и вот они, гигабайты оперативной памяти, утекающие сквозь пальцы.
Разработчики PHP 7 сделали две революционные вещи. Во-первых, они уменьшили размер zval'а до 16 байт. Достигнуто это было за счёт радикальной переработки архитектуры: значение и тип были объединены, счётчик ссылок встроен прямо в структуру, а для простых типов — целых чисел и чисел с плавающей точкой — значение стало храниться прямо внутри zval'а, а не в отдельном блоке кучи.
Во-вторых, для целых и дробных чисел перестала выделяться память в куче вообще. Переменная $a = 42 теперь занимает ровно 16 байт и может располагаться на стеке или внутри составной структуры. На реальных приложениях — WordPress, Drupal, Symfony — потребление памяти снизилось на 50–70 процентов. Сервер, который задыхался при тысяче одновременных соединений, теперь держал три тысячи без добавления оперативной памяти.
Для составных типов — строк, массивов, объектов — zval хранит не само значение, а указатель на отдельную структуру в куче. Рассмотрим это на примере строки, потому что со строками мы работаем постоянно.
В PHP 7 строка представлена отдельной структурой, которая называется zend_string. Сам zval содержит указатель на неё, тип и счётчик ссылок. Структура zend_string устроена интереснее, чем кажется. В ней есть поле refcount — количество zval'ов, которые ссылаются на эту строку. Есть поле len — длина строки в байтах, и это критически важно: PHP-строки бинарно-безопасны, они могут содержать нулевой байт внутри, и длина хранится явно, а не вычисляется поиском завершающего нуля, как в языке C. Есть поле hash — предвычисленный хэш строки, изначально ноль, вычисляется лениво при первом использовании строки в качестве ключа массива. И есть сам массив символов, после которого всегда идёт скрытый нулевой байт для совместимости с C-функциями.
Ключевое следствие из этой архитектуры: когда вы пишете b=b=a для строки, физического копирования данных не происходит. Копируются только zval'ы — 16 байт. Оба zval'а указывают на одну и ту же структуру zend_string в куче, а её счётчик ссылок увеличивается до двух. Память под строку не выделяется заново.
Это подводит нас к, пожалуй, самому важному механизму производительности PHP — Copy-on-Write, или копирование при записи. PHP не копирует данные, пока вы их не изменяете. Классический пример: создаём переменную aсострокой«Hello,World!»,затемприсваиваемaсострокой«Hello,World!»,затемприсваиваемb = a.Впамятипо−прежнемуоднастрока,простонанеёссылаютсядваzval′а.Теперьменяемa.Впамятипо−прежнемуоднастрока,простонанеёссылаютсядваzval′а.Теперьменяемb — присваиваем ей другое значение. В этот момент создаётся новая строка в куче, счётчик ссылок старой строки уменьшается до единицы, и bначинаетуказыватьнановуюстроку.bначинаетуказыватьнановуюстроку.a продолжает ссылаться на старую. Никакого лишнего копирования не произошло.
С массивами Copy-on-Write работает аналогично, но с важным уточнением. Когда вы делаете b=b=a для массива, refcount массива увеличивается, но физического копирования хэш-таблицы не происходит. Однако при модификации — например, $b[] = 4 — происходит так называемое разделение, SEPARATE. PHP обнаруживает, что refcount больше единицы, и создаёт полную копию массива. Что критически важно: копируется весь массив целиком, даже если вы меняете всего один элемент. Если у вас массив из миллиона записей, изменение одного элемента приведёт к выделению памяти под миллион элементов и копированию их всех. Не существует частичного Copy-on-Write для массивов. Именно поэтому передача массива по ссылке иногда оправдана: если функция действительно должна модифицировать массив, передача по ссылке не увеличивает счётчик ссылок, и копирования не происходит.
Теперь поговорим о сборке мусора. Счётчик ссылок великолепно справляется с простыми сценариями: создали переменную — refcount = 1, присвоили другой переменной — refcount = 2, удалили первую — refcount = 1, удалили вторую — refcount = 0, память освобождена. Но есть ситуация, перед которой счётчик ссылок бессилен: циклические ссылки.
Классический пример: создаём два объекта, aиaиb. Присваиваем a−>ref=a−>ref=b и b−>ref=b−>ref=a. Теперь каждый объект ссылается на другой. Делаем unset(a)иunset(a)иunset(b). Счётчик ссылок каждого объекта не равен нулю, потому что они ссылаются друг на друга. Из пользовательского кода они уже недоступны, но память не освобождена. Это утечка.
До PHP 5.3 такие утечки были фатальными для долгоживущих скриптов — демонов, очередей, long-running процессов. Решением стал циклический сборщик мусора, Cycle Collector. Он работает по интересному алгоритму. Все потенциальные «корни» — zval'ы с флагом возможности цикла — помещаются в специальный буфер. Когда буфер заполняется, по умолчанию до десяти тысяч элементов, запускается сборщик. Он моделирует уменьшение счётчиков: вычитает единицу из refcount для каждой ссылки внутри графа. Если после этого «симуляции» refcount какого-то объекта становится равным нулю, значит, объект жив только за счёт циклических ссылок. Сборщик помечает его как мусор и освобождает память.
Эту механику можно отключить функцией gc_disable(). Для скриптов с миллионами объектов, где вы абсолютно уверены в отсутствии циклических ссылок, это даёт прирост производительности, потому что исчезают накладные расходы на поддержку буфера GC. Большинству приложений этого делать никогда не нужно — цена ошибки слишком высока.
И последняя важная часть этой главы — собственный менеджер памяти PHP, Zend Memory Manager. PHP не использует системный malloc для каждого выделения байта. Вместо этого у него есть собственный аллокатор. Он запрашивает у операционной системы большие блоки памяти — сегменты — через mmap или malloc, а затем нарезает их на мелкие куски под zval'ы, строки, массивы. Освобождённые блоки не возвращаются немедленно операционной системе, а кешируются для повторного использования.
Это решает сразу несколько проблем. Системный аллокатор может фрагментировать память — PHP-аллокатор переиспользует блоки одинакового размера и не страдает от внешней фрагментации. Выделение памяти из собственного пула быстрее системного вызова, потому что не нужно каждый раз переходить в режим ядра. И самое важное для модели «запрос-очистка»: при завершении запроса PHP знает, какая память была выделена в течение этого запроса, и может одномоментно освободить её всю, даже если какое-то расширение или неаккуратный код «забыли» освободить отдельные блоки. Это не панацея — память, помеченная как постоянная, живёт дольше одного запроса, — но это мощная страховка от большинства утечек.
Итак, мы спустились на уровень, где PHP перестаёт быть магией. Zval — это 16-байтный контейнер, который для чисел хранит значение прямо внутри, а для сложных типов — указатель на структуру в куче. Реформа PHP 7 упаковала zval и встроила простые значения, сократив потребление памяти вдвое и более. Copy-on-Write предотвращает копирование данных при присваивании — копия создаётся только при модификации. Счётчик ссылок освобождает память, когда zval больше никому не нужен. Циклический сборщик разруливает ситуацию с перекрёстными ссылками. А собственный менеджер памяти эффективно управляет кучей, заточенный под модель «один запрос — одна очистка».
Теперь у нас есть понимание того, где и как живут данные. В следующей главе мы посмотрим, как Zend Engine превращает ваш PHP-код в эти самые zval'ы и опкоды, и как OPCache хранит их между запросами, чтобы не перекомпилировать одно и то же на каждый чих.
Глава 3. Zend Engine и OPCache
В первой главе мы сказали, что PHP — это не интерпретатор, а транслятор в байт-код. Во второй — разобрали, как этот байт-код оперирует данными в памяти. Теперь пришло время заглянуть в самое сердце машины и проследить весь путь: от исходного кода, который вы пишете в редакторе, до исполнения на виртуальной машине. А затем — понять, как OPCache позволяет не проделывать эту работу заново на каждом запросе.
Представьте конвейер. На вход подаётся PHP-файл. На выходе — выполненные инструкции. Между этими точками четыре стадии, и каждая достойна отдельного разговора.
Первая стадия — лексический анализ, или токенизация. Лексер читает исходный файл символ за символом и группирует их в осмысленные «слова» — токены. Каждый токен это пара: тип плюс значение. Если вы напишете простой код — объявить переменную $a, присвоить ей число 42 и вывести через echo — лексер выдаст поток токенов: открывающий тег, переменная, пробел, оператор присваивания, число, точка с запятой, ключевое слово echo, снова переменная, снова точка с запятой. Комментарии и пробелы — тоже токены, просто они будут отброшены на следующей стадии.
Важно понимать: лексер не проверяет синтаксис. Если вы напишете бессмыслицу вроде $a = ; — лексер честно выдаст токены «переменная», «оператор присваивания», «точка с запятой». То, что это синтаксически неверно, обнаружит только следующая стадия. Лексер — это конечный автомат, он очень быстр и не аллоцирует сложных структур. С точки зрения производительности он никогда не является узким местом, поэтому не пишите «оптимизированный PHP без пробелов и комментариев» в надежде ускорить выполнение. OPCache сделает всю работу один раз, а читаемость кода важнее экономии микросекунд на токенизации.
Вторая стадия — синтаксический анализ, результатом которого становится AST, абстрактное синтаксическое дерево. Парсер читает поток токенов и строит иерархическую структуру, отражающую смысл программы. Именно здесь a=;вызоветфатальнуюошибку:парсерожидаетвыражениепослеоператораприсваивания,австречаетточкусзапятой.ДлянашегопростогокодаASTбудетпредставлятьсобойсписокинструкций:первая—присваиваниепеременнойa=;вызоветфатальнуюошибку:парсерожидаетвыражениепослеоператораприсваивания,австречаетточкусзапятой.ДлянашегопростогокодаASTбудетпредставлятьсобойсписокинструкций:первая—присваиваниепеременнойa значения 42, вторая — echo переменной $a.
До PHP 7 парсер генерировал опкоды сразу, без явного построения AST. Это делало язык менее гибким. Появление отдельной стадии AST позволило улучшить сообщения об ошибках, создавать инструменты статического анализа, такие как PHPStan и Psalm, а также проводить оптимизации на уровне дерева до того, как будут сгенерированы опкоды.
Третья стадия — компиляция. Компилятор обходит AST и генерирует линейную последовательность инструкций для виртуальной машины. Каждый узел дерева транслируется в один или несколько опкодов. Опкод — это низкоуровневая инструкция, состоящая из кода операции и операндов, которые ссылаются на zval'ы. Для нашего кода получится примерно такая последовательность: присвоить переменной aзначение42,вывестипеременнуюaзначение42,вывестипеременнуюa, вернуть null.
На этой стадии происходит несколько важных вещей. Во-первых, разрешение имён: переменные, функции, классы связываются с их внутренними представлениями. Во-вторых, свёртка констант: если вы напишете 60 умножить на 60 умножить на 24, PHP вычислит 86400 прямо на этапе компиляции и положит в опкод готовое число. В-третьих, генерируются таблицы обработчиков исключений для try/catch. Результат компиляции — это массив опкодов, который называется op_array, для каждого файла, каждой функции и каждого метода.
Четвёртая стадия — исполнение на виртуальной машине. Виртуальная машина Zend Engine работает в классическом цикле: прочитать следующий опкод, выполнить его обработчик, перейти к следующему. У каждого опкода есть свой обработчик, написанный на C. Обработчик ZEND_ECHO берёт значение операнда, преобразует в строку и отправляет в буфер вывода. Обработчик ZEND_ASSIGN берёт два операнда и записывает второй в первый с учётом Copy-on-Write и счётчиков ссылок. Обработчик ZEND_ADD складывает два числа и сохраняет результат в третий zval.
С PHP 8.0 виртуальная машина получила JIT-компилятор — Just-In-Time. Он отслеживает «горячие» участки кода, которые исполняются особенно часто, и транслирует их напрямую в машинный код процессора x86 или ARM, минуя интерпретацию опкодов. Это даёт заметный прирост на вычислительных задачах — математических расчётах, обработке изображений, машинном обучении на PHP. На типичных веб-приложениях выигрыш скромнее, потому что основное время выполнения уходит на ожидание базы данных и сетевые операции, а не на вычисления.
Теперь перейдём к OPCache — компоненту, без которого современный production-grade PHP просто немыслим. Без кеширования все четыре стадии выполнялись бы для каждого файла при каждом запросе. Приложение на Symfony с двумя сотнями подключаемых файлов требовало бы сотен тысяч операций парсинга и компиляции в секунду. Это безумие, и OPCache его останавливает.









