
Полная версия
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сострокойиприсваиваетееёпеременнойaсострокойиприсваиваетееёпеременнойb. В памяти по-прежнему одна структура zend_string, просто её счётчик ссылок увеличился до двух. Никакого физического копирования данных не произошло. Два zval'а указывают на одну и ту же строку в куче.
Сценарий второй: модификация через оператор квадратных скобок. Вы присвоили b=b=a, а затем пишете b[0]=′Y′.Вэтотмоментпроисходиттакназываемоеразделение—SEPARATION.PHPвидит,чтосчётчикссылокстрокибольшеединицы,исоздаётновуюкопиюzendstringдляпеременнойb[0]=′Y′.Вэтотмоментпроисходиттакназываемоеразделение—SEPARATION.PHPвидит,чтосчётчикссылокстрокибольшеединицы,исоздаётновуюкопиюzendstringдляпеременнойb. Теперь у переменной aсвоястрока«Hello»сосчётчикомссылокединица,аупеременной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=[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 в середине, если без него можно обойтись. Не добавляйте строковые ключи к спискам. Не начинайте индексацию с единицы, если можно с нуля. Каждое из этих действий превращает быстрый упакованный массив в более медленную и прожорливую хэш-таблицу.
Конец ознакомительного фрагмента.
Текст предоставлен ООО «Литрес».
Прочитайте эту книгу целиком, купив полную легальную версию на Литрес.
Безопасно оплатить книгу можно банковской картой Visa, MasterCard, Maestro, со счета мобильного телефона, с платежного терминала, в салоне МТС или Связной, через PayPal, WebMoney, Яндекс.Деньги, QIWI Кошелек, бонусными картами или другим удобным Вам способом.









