
Полная версия
PHP. Под капотом. Архитектура, память и за гранью кода
OPCache хранит в разделяемой памяти для каждого PHP-файла готовый к исполнению массив опкодов, таблицу функций и классов, определённых в этом файле, interned strings — все строки из кода, включая имена переменных, функций, классов и констант, — и метаданные: время модификации файла и контрольные суммы.
Механизм проверки актуальности кеша устроен элегантно. Когда приходит запрос, PHP берёт путь к файлу, ищет запись в shared memory, сравнивает время модификации файла с сохранённым. Если совпадает — файл не читается с диска вообще, опкоды валидны, едем дальше. Если не совпадает — файл перекомпилируется, и кеш обновляется. Этот механизм называется stat. При продакшен-деплое, когда все файлы меняются разом через атомарную замену директории, проверку stat можно отключить директивой opcache.validate_timestamps = 0. После этого OPCache никогда не проверяет файлы на диске, производительность становится максимальной, но любое изменение кода требует перезапуска PHP-FPM или принудительного сброса кеша.
Отдельного разговора заслуживают interned strings. Представьте, что в сотне файлов вашего приложения встречается строка «getUserById» — название метода. Без интернирования каждый файл хранил бы свою копию этой строки. С OPCache строка сохраняется один раз в общей таблице interned strings, и каждый опкод, который на неё ссылается, хранит не саму строку, а указатель на неё. Сравнение таких строк превращается в сравнение указателей — операция с константным временем вместо линейного. Это не микрооптимизация: на больших кодовых базах интернирование экономит мегабайты и десятки мегабайт оперативной памяти.
Теперь о технологии, которая появилась в PHP 7.4 и вывела производительность на принципиально новый уровень — Preloading. OPCache решает проблему повторной компиляции, но остаётся другая проблема: каждый процесс PHP-FPM имеет свой собственный кеш опкодов. Когда воркер перезапускается, его кеш пуст, и первый запрос снова платит цену компиляции сотен файлов фреймворка. Preloading устраняет эту проблему.
В php.ini указывается preload-скрипт. Этот скрипт выполняется один раз при старте мастер-процесса PHP-FPM, до того как создаются воркеры. Внутри скрипта вызывается opcache_compile_file для всех критически важных файлов — сущностей Doctrine, сервисов Symfony, контроллеров. Скомпилированные опкоды загружаются в разделяемую память OPCache и становятся доступны всем дочерним процессам. Воркеры рождаются с «горячим» кешем. Память, занятая опкодами, распределяется между всеми воркерами, что даёт экономию 30–50 процентов на каждом процессе.
У Preloading есть ограничения, которые нужно знать. Изменение любого preloaded-файла требует перезапуска PHP-FPM. Ошибка в таком файле может сделать сервер неспособным запуститься. Preloaded-классы нельзя переопределить через механизмы вроде class_alias — они буквально впечатываются в память. И нельзя preload'ить классы с побочными эффектами, такие как подключения к базе данных или чтение конфигов во время объявления класса.
Какие практические выводы мы можем сделать из всего этого для повседневной разработки? Во-первых, пишите чистый, читаемый код. OPCache полностью нивелирует стоимость пробелов, комментариев и длинных имён переменных. Лексический анализ — не узкое место, не оптимизируйте то, что и так бесплатно. Во-вторых, не вычисляйте константные выражения вручную. Шестьдесят секунд умножить на шестьдесят минут умножить на двадцать четыре часа будет вычислено на этапе компиляции и превратится в 86400, поэтому смело пишите выразительно — SECONDS_IN_A_DAY вместо магического числа. В-третьих, файловый автолоад напрямую влияет на производительность: каждый уникальный путь к файлу, загружаемому через автолоад, требует проверки в OPCache и потенциальной компиляции. Стандарт PSR-4 с жёсткой структурой директорий помогает OPCache эффективнее управлять кешем. В-четвёртых, Preloading требует дисциплины: загружайте только чистые определения классов и функций, а не код с побочными эффектами. И наконец, JIT — не серебряная пуля. Для типичного веб-приложения выигрыш небольшой, JIT раскрывается на вычислительных задачах.
Итак, Zend Engine — это не чёрный ящик, а отлаженный конвейер: исходный код проходит через лексер, превращаясь в токены, затем через парсер, строящий AST, затем через компилятор, генерирующий опкоды, и наконец исполняется на виртуальной машине. OPCache прерывает этот конвейер после компиляции, сохраняя опкоды в разделяемой памяти и делая повторные запросы практически бесплатными с точки зрения процессора. А Preloading идёт ещё дальше: он загружает опкоды в память до прихода первого запроса, приближая PHP к поведению «настоящих» долгоживущих приложений на Java или Go.
Теперь мы знаем, как PHP исполняет код. В следующей главе мы погрузимся в то, на чём он его исполняет — в строки, их бинарную безопасность, в то, почему strlen возвращает количество байтов, а не символов, и почему strpos может вернуть false, который при нестрогом сравнении равен нулю.
Глава 4. Строки, которые мы (не) знаем
Строки в PHP обманчиво просты. Мы используем их каждый день: конкатенируем, обрезаем, ищем подстроки, форматируем даты. Но за этим удобным фасадом скрывается инженерная конструкция, полная нюансов, незнание которых приводит к трудноуловимым багам. Тот факт, что функция strpos может вернуть false, который в нестрогом сравнении равен нулю — лишь вершина айсберга. Давайте нырнём глубже.
Начнём с фундаментального отличия PHP от языка C. В C строка — это последовательность байтов, заканчивающаяся нулевым байтом. Функция strlen в C считает байты, пока не встретит этот терминатор. Это означает, что C-строка принципиально не может содержать нулевой байт внутри себя — он будет воспринят как конец строки. PHP с самого начала пошёл другим путём. PHP-строки бинарно-безопасны. Они хранят свою длину явно, в поле len структуры zend_string, которую мы обсуждали во второй главе. Нулевой байт внутри строки — такой же легитимный символ, как и любой другой.
Почему это критически важно? Потому что PHP постоянно работает с бинарными данными. Изображения, загруженные через $_FILES. Содержимое зашифрованных данных. Сериализованные объекты. Результаты хэширования. Всё это бинарные строки с потенциальными нулевыми байтами внутри. Если бы PHP использовал C-строки, каждый такой случай приводил бы к усечению данных. Вы бы загрузили фотографию, а на диск записалась бы только её первая половина — до первого байта с нулевым значением. Катастрофа.
Кстати, если вы когда-нибудь будете писать расширение PHP на C и работать со строками через макросы ZSTR_LEN и ZSTR_VAL, никогда не используйте функцию strlen на результате ZSTR_VAL. Всегда берите длину из структуры zend_string. Это правило номер один для безопасной работы со строками на уровне расширений.
Давайте подробнее рассмотрим саму структуру zend_string. Она начинается со счётчика ссылок — сколько zval'ов в данный момент ссылаются на эту строку. Затем идёт длина строки в байтах — именно её возвращает функция strlen в PHP. Обратите внимание: длина хранится в байтах, а не в символах. Для строки «hello» это пять. Для строки «Привет» в кодировке UTF-8 это двенадцать, потому что каждая кириллическая буква занимает два байта. Затем идёт поле hash — предвычисленный хэш, изначально равный нулю и вычисляемый лениво при первом использовании строки в качестве ключа массива. И наконец, сами символы строки, после которых всегда располагается скрытый нулевой байт для совместимости с C-функциями. Этот нулевой байт не учитывается в длине, он существует только для того, чтобы можно было безопасно передать строку в C-библиотеку.
Ленивое хэширование — это одна из тех оптимизаций, которые работают незаметно, но дают ощутимый выигрыш. Когда вы используете строку как ключ массива, PHP нужно вычислить её хэш для поиска в хэш-таблице. Если строка используется как ключ многократно — а имена полей из базы данных именно таковы, они повторяются в каждом результате запроса — хэш вычисляется один раз и сохраняется в поле hash. Повторные использования получают хэш за константное время, без пересчёта.
Теперь поговорим о том, как PHP избегает копирования строк. Это настоящий шедевр ленивой оптимизации. Рассмотрим три сценария.
Сценарий первый: присваивание без изменений. Вы создаёте переменную a со строкой и присваиваете её переменной b. В памяти по-прежнему одна структура zend_string, просто её счётчик ссылок увеличился до двух. Никакого физического копирования данных не произошло. Два zval'а указывают на одну и ту же строку в куче.
Сценарий второй: модификация через оператор квадратных скобок. Вы присвоили b=b=a, а затем пишете b[0]=′Y′. В этот момент происходит так называемое разделение — SEPARATION. PHP видит, что счётчик ссылок строки больше единицы, и создаёт новую копию zendstring для переменной b. Теперь у переменной a своя строка «Hello» со счётчиком ссылок единица, а упеременной b своя строка «Yello» со счётчиком ссылок единица. Оригинальная строка не пострадала.
Сценарий третий: передача в функцию. Большинство строковых функций PHP возвращают новую строку, не модифицируя оригинал. Когда вы передаёте строку в функцию strtoupper, счётчик ссылок временно увеличивается при передаче аргумента, но функция создаёт новую строку в верхнем регистре и возвращает её. Оригинальная строка остаётся неизменной, её счётчик ссылок возвращается к исходному значению.
Отдельного разговора заслуживают interned strings, которые мы уже упоминали в контексте OPCache, но сейчас посмотрим на них с точки зрения строк. Когда PHP компилирует ваш код, он встречает множество строковых литералов: имена классов, функций, методов, констант, ключи массивов. Многие из них повторяются сотни раз в разных файлах. Механизм интернирования гарантирует, что одинаковые строки хранятся в памяти только один раз.
Работает это так. При компиляции строковый литерал проверяется по глобальной хэш-таблице интернированных строк. Если такая строка уже существует — используется существующий указатель на zend_string. Если нет — создаётся новый, добавляется в таблицу и помечается как интернированный. Интернированные строки никогда не уничтожаются сборщиком мусора, они живут до конца процесса. Они не используют счётчик ссылок в обычном смысле, а вместо этого имеют специальный флаг IS_INTERNED. И сравнение двух интернированных строк сводится к сравнению двух указателей — операция, которая выполняется за один такт процессора.
Это даёт колоссальную экономию памяти. В типичном приложении на Symfony имена вроде Symfony\Component\HttpFoundation\Request встречаются в аннотациях, конфигурациях и коде десятки раз. Без интернирования каждая копия занимала бы место. С интернированием — только одна.
Практический вывод: если вы динамически создаёте строки и используете их как ключи массива, они не интернируются. Интернируются только литералы времени компиляции. Но само понимание этого механизма помогает осознать, почему имена классов и методов не должны генерироваться динамически без крайней необходимости — вы теряете преимущества интернирования.
Теперь о боли, знакомой каждому PHP-разработчику. Функция strpos возвращает позицию подстроки — целое число. Если подстрока не найдена, возвращается false. Проблема в том, что ноль — валидная позиция, означающая, что подстрока найдена в самом начале строки. При нестрогом сравнении false равен нулю, поэтому условие if (strpos(string,string,substring)) не выполнится для подстроки, найденной в нулевой позиции.
Почему дизайн языка таков? Это историческая причина. В ранних версиях PHP не было исключений, а тип null появился далеко не сразу. Возврат false был единственным способом сигнализировать об ошибке для функций, которые могли вернуть любое целое число, включая ноль. Сегодня у нас есть правильные решения: использовать строгое сравнение с false через оператор !==, а в PHP 8 появились функции str_contains, str_starts_with и str_ends_with, которые возвращают булево значение и полностью устраняют этот класс ошибок. Это не просто синтаксический сахар — это устранение целого класса багов, которые десятилетиями жили в PHP-коде.
Поговорим о конкатенации. Когда вы пишете «Hello, » . name.«!»,можетпоказаться,чтоPHPсоздаётпромежуточнуюстрокудля«Hello,».name.«!»,можетпоказаться,чтоPHPсоздаётпромежуточнуюстрокудля«Hello,».name, а затем ещё одну для финального результата. На самом деле PHP оптимизирует цепочки конкатенации. Когда компилятор видит последовательность операторов точки в одном выражении, он вычисляет суммарную длину результирующей строки, выделяет одну структуру zend_string нужного размера и копирует части напрямую в новый буфер. Промежуточных строк не создаётся.
Но есть нюанс, который часто упускают. Если вы разбиваете конкатенацию на несколько отдельных операторов присваивания с оператором «точка-равно», каждый такой оператор — это отдельная операция модификации, и PHP создаёт промежуточную строку на каждом шаге. В горячих циклах это может быть заметно. Правило большого пальца: если вы собираете большую строку в цикле, складывайте части в массив и вызывайте implode в конце. Это даёт одно выделение памяти вместо многих. Либо пишите конкатенацию как одно выражение, если это возможно.
И ещё один важный момент о природе строк. PHP-строки — это последовательности байтов, а не символов. Функция strlen возвращает количество байтов. Для строк в кодировке ASCII это работает идеально: один символ равен одному байту. Но для UTF-8 русская буква «П» занимает два байта, и strlen для строки «Привет» вернёт двенадцать, а не шесть. Функции вроде strtoupper, strrev и substr работают на уровне байтов и сломают многобайтовую строку, если применить их к UTF-8 без должной осторожности.
Это не баг. Это прямое следствие бинарно-безопасной природы строк. PHP не знает и не хочет знать, что ваша строка — это текст в кодировке UTF-8. С точки зрения движка это просто последовательность байтов. Работа с многобайтовыми кодировками требует использования mb_ аналогов стандартных функций: mb_strlen, mb_strtoupper, mb_substr. В идеале — всегда использовать их, если вы работаете с текстом, который может содержать не-ASCII символы.
И напоследок — маленький, но показательный пример, как глубокое понимание строк помогает отлаживать действительно странное поведение. Попробуйте сравнить NaN со строкой «NAN». Константа NAN означает Not a Number, и по стандарту IEEE 754 она не равна ничему, включая саму себя. Когда вы приводите NAN к строке, вы получаете строку «NAN». Но когда вы сравниваете эту строку с NAN через нестрогое равенство, PHP приводит строку обратно к числу с плавающей точкой. Сравнение NAN == NAN всегда возвращает false. Поэтому NAN == «NAN» — это false. Не баг, а математика с плавающей точкой, которая неожиданно просачивается в строковые операции.
Итак, подведём итог. PHP-строки бинарно-безопасны: они хранят длину явно и могут содержать нулевые байты внутри. Копирование отложено: Copy-on-Write работает и для строк, физическое копирование происходит только при модификации. Интернирование экономит память для повторяющихся строковых литералов. Возврат false из строковых функций — историческое наследие, используйте строгое сравнение или современные функции. Конкатенация в одном выражении умна и не создаёт промежуточных строк, а конкатенация в цикле через точку-равно — создаёт. И наконец, strlen возвращает количество байтов, а не символов — для UTF-8 используйте mb_strlen.
Теперь мы знаем, как хранятся простые данные. В следующей главе мы перейдём к самой мощной и сложной структуре данных PHP — массивам, которые на самом деле являются упорядоченными хэш-таблицами. Узнаем, почему добавление элемента через квадратные скобки без ключа в разы быстрее, чем присваивание со строковым ключом, и как устроен packed array.
Глава 5. Массивы: Хэш-таблицы в маске
PHP-массив — это, пожалуй, самая перегруженная структура данных в истории программирования. Он одновременно является упорядоченным списком, когда вы пишете arr=[1,2,3]. Ассоциативным словарём, когда вы пишете arr = ['name' => 'Alice', 'age' => 30]. Стеком, когда вы используете array_push и array_pop. Очередью, когда вы вызываете array_shift и array_unshift. И даже множеством, когда вы пишете $arr = ['a' => true, 'b' => true] и проверяете наличие ключа через isset.
На собеседованиях часто спрашивают: «Как массив реализован внутри?» Правильный ответ звучит так: это упорядоченная хэш-таблица с двусвязной природой. Звучит сложно, но сейчас мы разберём это по косточкам, и вы поймёте не только устройство, но и то, как писать более производительный код.
Итак, внутри PHP-массив — это структура на C, которая называется zend_array или HashTable. В отличие от хэш-таблиц в большинстве других языков, где порядок элементов не гарантирован, PHP-массив всегда сохраняет порядок вставки. Как это достигается? За счёт того, что каждый элемент хранится в так называемом бакете, и все бакеты связаны в двусвязный список.
Представьте массив из трёх элементов: 'a' => 1, 'b' => 2, 'c' => 3. В памяти он выглядит как структура zend_array, внутри которой есть размер хэш-таблицы — всегда степень двойки, допустим восемь, количество элементов — три, и указатель на массив бакетов. Каждый бакет содержит ключ, значение в виде zval'а, хэш ключа и два указателя: на следующий бакет в порядке вставки и на предыдущий. Именно эта двусвязная природа — хэш-массив указателей для быстрого поиска плюс двусвязный список для сохранения порядка — и делает PHP-массив тем, чем он является. Вы добавили сначала 'a', потом 'b', потом 'c' — foreach пройдёт именно в этом порядке, даже если хэши ключей расположены в таблице иначе.
Теперь поговорим об анатомии бакета. Каждый элемент массива живёт в структуре, которая хранит сам zval со значением — это те самые 16 байт, которые мы обсуждали во второй главе, и для чисел значение лежит прямо здесь. Хранит хэш ключа — для числовых ключей это сам ключ, для строковых это предвычисленный хэш из структуры zend_string. Хранит указатель на строковый ключ, который равен null для числовых ключей. Хранит индекс следующего бакета в порядке вставки — это то, что позволяет foreach идти по порядку. И хранит индекс следующего бакета в цепочке коллизий — это техническая деталь, нужная, когда два разных ключа попадают в одну ячейку хэш-таблицы.
А теперь — самое интересное. До PHP 7 все массивы были честными хэш-таблицами, даже простой список [1, 2, 3]. Хранились полные бакеты, вычислялись хэши, поддерживались двусвязные списки. Это было расточительно. В PHP 7 появилась оптимизация под названием Packed Array — упакованный массив.
Идея проста и гениальна. Если вы создаёте массив с непрерывными целочисленными ключами, начинающимися с нуля — например, [1, 2, 3] или добавляете элементы через $arr[] без указания ключа — PHP создаёт не хэш-таблицу, а плотно упакованный вектор. Ключи не хранятся вообще, потому что они вычисляются из позиции. Хэш-таблица указателей не нужна, потому что индекс равен позиции. Доступ по индексу — это прямое смещение в памяти, операция за константное время без всяких хэшей и обходов коллизий.
Но есть действие, которое ломает эту оптимизацию. Если вы добавляете элемент с ключом 100 в массив, который до этого был упакованным, или присваиваете значение по строковому ключу, или делаете unset элемента в середине — массив перестаёт быть packed и превращается в обычную хэш-таблицу. PHP перестраивает внутреннее представление: создаёт хэш-таблицу, пересчитывает хэши, разрывает плотную упаковку. Хуже того, обратного пути нет. Не существует механизма автоматической переупаковки хэш-таблицы обратно в packed array.
Разница в производительности впечатляет. Упакованный массив занимает примерно в полтора-два раза меньше памяти. Итерация по нему на двадцать-тридцать процентов быстрее, потому что нет переходов по указателям, используется прямая адресация. Вставка нового элемента через $arr[] в упакованный массив на тридцать-пятьдесят процентов быстрее, чем присваивание по строковому ключу, потому что не нужно вычислять хэш и искать место в хэш-таблице.
Практический вывод: если вы работаете со списками — данные из базы данных, коллекции объектов — позвольте PHP хранить их как packed array. Не делайте unset в середине, если без него можно обойтись. Не добавляйте строковые ключи к спискам. Не начинайте индексацию с единицы, если можно с нуля. Каждое из этих действий превращает быстрый упакованный массив в более медленную и прожорливую хэш-таблицу.
Отдельный интересный случай — функция array_values. Если вы всё-таки сделали unset в середине массива и хотите вернуть его в упакованное состояние, вызов array_values создаст новый массив с переиндексацией с нуля, и этот новый массив снова будет packed. Но сам вызов array_values делает полную копию массива, что стоит O(n) памяти и времени. Это оправдано, только если массив будет многократно использоваться в дальнейшем.
Теперь о том, как происходит поиск элемента. Когда вы пишете $arr['some_key'], PHP должен найти бакет с этим ключом. Сначала вычисляется или берётся готовый хэш ключа. Затем определяется индекс в хэш-таблице как хэш по модулю размера таблицы. Если по этому индексу уже есть другой бакет с другим ключом — это коллизия, и PHP идёт по цепочке коллизий, сравнивая хэши, а при совпадении хэшей — сравнивая ключи побайтово. Когда ключ найден, возвращается указатель на zval внутри бакета.
Для packed array весь этот процесс сводится к одной операции: индекс равен ключу, бакет равен элементу массива по этому индексу. Никаких хэшей, коллизий, сравнений. Вот почему packed arrays быстрее.
Несколько слов о внутреннем указателе массива. Каждый массив в PHP имеет внутренний указатель, который используется функциями current, next, prev, reset, end. Этот указатель живёт в структуре zend_array и указывает на текущий бакет в двусвязном списке. Важно знать, что foreach не полагается на внутренний указатель. Он создаёт собственную копию указателя при старте цикла. Это значит, что если вы прервали foreach через break, внутренний указатель массива не сдвинулся. И это же объясняет, почему вложенные циклы по одному и тому же массиву работают корректно — каждый foreach работает со своим состоянием итерации.
И последний важный аспект — Copy-on-Write для массивов. С массивами он работает так же, как со строками, но есть критически важное уточнение. Когда вы делаете b=b=a для массива, счётчик ссылок массива увеличивается, но физического копирования не происходит. Когда вы затем модифицируете $b, происходит разделение: PHP создаёт полную копию массива. Ключевое слово — полную. Если у вас массив из миллиона элементов, и вы меняете один из них, PHP скопирует весь миллион. Не существует частичного Copy-on-Write для массивов.
Из этого следует практическая рекомендация: если функция действительно должна модифицировать большой массив, передавайте его по ссылке. При передаче по ссылке счётчик ссылок не увеличивается, и копирования не происходит. Либо модифицируйте массив до того, как создали на него вторую ссылку.
Когда массивов стоит избегать? PHP-массив — это швейцарский нож, но у швейцарского ножа есть цена. Каждый бакет занимает тридцать с лишним байт плюс сам zval плюс ключ. Массив из ста тысяч чисел может занимать четыре-шесть мегабайт. Для сравнения, SplFixedArray, если вам нужен только числовой доступ без строковых ключей, хранит только zval'ы подряд и экономит около половины памяти. SplObjectStorage эффективнее для хранения объектов с быстрым поиском. А расширение Data Structures предоставляет векторы, множества и словари с более эффективными реализациями от PHP-сообщества.
Итак, подведём итог. PHP-массив — это упорядоченная хэш-таблица с двусвязным списком бакетов для сохранения порядка вставки. Packed array — оптимизация для непрерывных целочисленных ключей с нуля, дающая до пятидесяти процентов прироста скорости и двукратную экономию памяти. Unset в середине ломает packed array и превращает его в хэш-таблицу необратимо без вызова array_values. Copy-on-Write работает для массивов, но при модификации копируется весь массив целиком. Внутренний указатель живёт в массиве, но foreach его не использует. А когда нужна максимальная производительность, стоит присмотреться к специализированным структурам данных вместо универсального массива.









