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

- -
- 100%
- +
Теперь мы знаем, как PHP хранит данные всех типов. В следующей главе мы перейдём к объектам — как они на самом деле живут в памяти, почему вызов метода экземпляра заметно быстрее статического вызова и что такое Late Static Binding на уровне опкодов.
Глава 6. Объекты и классы «изнутри»
Если массивы — это швейцарский нож PHP, то объекты — его скелет. Современный PHP-код на девяносто процентов состоит из классов, интерфейсов, трейтов и взаимодействия объектов. Но как объект выглядит с точки зрения Zend Engine? Почему вызов метода через экземпляр заметно быстрее статического вызова? И как на самом деле работает Late Static Binding, которым мы пользуемся в сервис-контейнерах каждый день?
Начнём с class entry — структуры, которая представляет класс в памяти. Когда PHP компилирует ваш код и встречает ключевое слово class, он создаёт так называемый zend_class_entry. Это «чертёж» класса, содержащий всю метаинформацию о нём. Имя класса хранится как interned string, то есть как указатель на строку в общей таблице интернированных строк, которую мы обсуждали в главе о строках. Тип класса — обычный класс, интерфейс или трейт. Указатель на родительский класс. Массив интерфейсов, которые класс реализует. Таблица свойств по умолчанию — тех, что объявлены прямо в классе. Хэш-таблица методов — сердце класса, где ключом является имя метода, а значением — указатель на функцию. Таблица статических свойств. Таблица констант. И наконец, таблица обработчиков объекта — object handlers, которую мы подробно разберём ниже.
Ключевой момент, который часто упускают из виду: class entry создаётся один раз при компиляции и живёт в памяти всё время жизни процесса. Точнее, в разделяемой памяти OPCache, если включён preloading. Когда вы пишете new User(), PHP не копирует class entry — он просто сохраняет указатель на него в создаваемом объекте.
До PHP 7.4 каждый запрос компилировал классы заново. Вернее, опкоды брались из OPCache, но сам class entry собирался при каждом выполнении инструкции class. С появлением preloading class entry создаётся один раз при старте сервера и остаётся в разделяемой памяти. Это даёт тройную экономию: времени компиляции, потому что класс уже полностью разобран; памяти, потому что class entry один на все воркеры; и времени разрешения зависимостей, потому что родительские классы и интерфейсы уже связаны друг с другом.
Теперь посмотрим на экземпляр. Когда вы пишете $user = new User(), PHP создаёт структуру, которая называется zend_object. Это не чертёж, а конкретный объект. Что он содержит? Указатель на class entry — общий для всех экземпляров. Таблицу обработчиков объекта — её мы вскоре разберём. Хэш-таблицу свойств — как объявленных в классе, так и динамических. И несколько служебных флагов, включая защитные флаги для магических методов __get и __set.
Самое важное, что нужно понять про объекты: zend_object не содержит методов. Методы живут в class entry, и они общие для всех экземпляров. Объект хранит только указатель на класс и свои индивидуальные свойства. Когда вы вызываете user−>getName(),PHPизобъектаполучаетуказательнаclassentry,вхэш−таблицеметодовнаходитметодgetNameивызываетнайденнуюфункцию,передаваяuser−>getName(),PHPизобъектаполучаетуказательнаclassentry,вхэш−таблицеметодовнаходитметодgetNameивызываетнайденнуюфункцию,передаваяthis — указатель на zend_object — в качестве первого параметра. Именно так ключевое слово $this оказывается доступным внутри метода.
Это объясняет интересный феномен производительности: вызов метода через экземпляр примерно на десять-пятнадцать процентов быстрее статического вызова, даже если внутри статического метода вы вручную передаёте $this как параметр. Причина в том, что статический вызов должен сначала разрешить класс — найти его class entry по строковому имени, что может включать автозагрузку, — и только потом искать метод. Вызов через экземпляр уже имеет готовый class entry, пропуская всю цепочку разрешения имени.
Теперь о свойствах. PHP различает два типа свойств объекта: объявленные, или declared properties, и динамические, или dynamic properties.
Объявленные свойства — это те, что явно описаны в классе: public string name=′′,privateintname=′′,privateintid = 0. Они хранятся в таблице свойств по умолчанию внутри class entry. При создании объекта PHP копирует значения по умолчанию в таблицу свойств экземпляра и размещает их в упакованном массиве со смещениями, известными на этапе компиляции. Доступ к объявленным свойствам очень быстрый: PHP знает точное смещение свойства в таблице и обращается к нему напрямую, без поиска по имени.
Динамические свойства создаются, когда вы присваиваете значение свойству, которого нет в объявлении класса: $user->something = 'value'. В этом случае PHP создаёт запись в отдельной хэш-таблице динамических свойств объекта. Доступ к таким свойствам требует хэширования ключа и поиска — существенно медленнее, чем доступ к объявленным свойствам. Начиная с PHP 8.2 динамические свойства по умолчанию запрещены, и это правильное решение: явное объявление свойств не только улучшает безопасность типов, но и делает объекты легче и быстрее.
Практический совет: всегда объявляйте свойства явно. Это даёт более быстрый доступ благодаря прямому смещению вместо поиска по хэшу, меньший расход памяти из-за отсутствия дополнительной хэш-таблицы для динамических свойств, совместимость с инструментами статического анализа и типобезопасность.
Теперь об одной из самых элегантных архитектурных особенностей PHP — таблицах обработчиков объектов, или object handlers. Каждый zend_object и каждый zend_class_entry содержат указатель на структуру zend_object_handlers, в которой лежат указатели на функции для всех операций с объектом. Чтение свойства — обработчик read_property. Запись свойства — обработчик write_property. Получение метода — get_method. Вызов метода — call_method. Проверка существования свойства, удаление свойства, получение конструктора и многие другие.
Это именно тот механизм, который позволяет работать «магическим» методам и специальным классам. Когда вы пишете $obj->prop, PHP вызывает обработчик handlers->read_property(obj, key). Для обычного объекта этот обработчик читает declared properties или dynamic properties. Но если класс объявляет магический метод __get, его таблица обработчиков заменяется на специальную версию, где обработчик read_property вызывает ваш PHP-метод __get вместо прямого доступа к памяти.
Та же логика работает для __set, __call, __callStatic и других магических методов. Это мощный механизм, но у него есть цена. Вызов через __get может быть в три-пять раз медленнее прямого доступа к объявленному свойству, потому что каждый доступ превращается в полноценный вызов функции с поиском по имени, проверками прав доступа и переключением контекста.
Для разработчиков расширений на C это точка расширения: вы можете создать свой обработчик read_property, который, например, лениво загружает свойство из базы данных при первом обращении. Именно так работают ORM вроде Doctrine — они создают прокси-объекты с переопределёнными обработчиками.
Теперь о наследовании и поиске метода. PHP поддерживает одиночное наследование с цепочкой разрешения методов. Когда вы вызываете $obj->doSomething(), PHP ищет метод doSomething в class entry объекта. Если не находит — поднимается в родительский класс и ищет там. Если не находит и там — идёт выше, пока не достигнет корня иерархии. Если метод не найден нигде, вызывается __call, если он определён.
Важный момент: цепочка разрешается один раз при компиляции, и указатель на функцию сохраняется в таблице методов. При выполнении поиск по цепочке наследования не производится заново — метод уже привязан к конкретному слоту. Это означает, что наследование само по себе не добавляет накладных расходов при вызове метода — все разрешения сделаны заранее.
Что касается интерфейсов: когда класс реализует интерфейс, PHP проверяет соответствие сигнатур методов во время компиляции. Во время выполнения интерфейс влияет только на проверку типа через instanceof и на приведение типов. Вызов метода, объявленного в интерфейсе, идёт через ту же таблицу методов класса — никаких дополнительных накладных расходов нет.
Теперь разберём Late Static Binding — механизм, появившийся в PHP 5.3 и решивший проблему наследования статических методов. Классический пример: у нас есть класс Parent с методом who, который возвращает имя класса, и методом test, который вызывает who. Если мы наследуем класс Child и переопределяем who, вызов Child::test() через self::who() всегда вернёт Parent, а через static::who() вернёт Child — имя класса, из которого был начат вызов.
Как это работает на уровне движка? Ключевые слова self, parent и static компилируются в разные опкоды. Вызов self::who() превращается в опкод ZEND_INIT_STATIC_METHOD_CALL с указанием конкретного class entry родительского класса. Этот вызов всегда пойдёт в Parent::who(). Вызов static::who() компилируется в тот же опкод, но с флагом ZEND_ACC_LSB. При выполнении PHP смотрит не на класс, где метод определён, а на «вызывающий класс» — caller class, который передаётся неявно через контекст вызова.
Вызывающий класс — это класс, в контексте которого был начат вызов. Он передаётся через структуру zend_execute_data, которая представляет текущий фрейм стека вызовов. При каждом вызове метода этот указатель обновляется. Именно так static:: знает, кого вызывать на самом деле. Это объясняет, почему Late Static Binding не бесплатен с точки зрения производительности: PHP должен хранить и передавать дополнительный контекст для каждого вызова, где потенциально может быть static::. На практике накладные расходы незначительны — меньше одного процента, — но сам механизм не сводится к простому указателю на функцию, это динамическое разрешение.
Поговорим о клонировании. Когда вы пишете b=cloneb=clonea, PHP создаёт новый zend_object и копирует declared properties и dynamic properties из оригинала. Но есть нюанс: свойства-объекты клонируются поверхностно. Указатели на вложенные объекты просто копируются, и после клонирования обе переменные ссылаются на один и тот же вложенный объект. Если у вас есть объект User с полем address, которое содержит объект Address, то после клонирования и оригинал, и клон будут ссылаться на один и тот же Address.
Метод __clone() существует именно для решения этой проблемы. На уровне движка clone вызывает обработчик clone_obj из таблицы handlers. По умолчанию он делает поверхностную копию, но если класс определяет __clone(), этот метод вызывается после копирования, и вы можете вручную клонировать вложенные объекты, создавая глубокую копию. Если вы работаете с объектами, содержащими ресурсы — файловые дескрипторы, подключения к базе данных, открытые сокеты — всегда определяйте __clone(), чтобы явно обработать дупликацию или закрытие ресурсов.
Несколько слов о слабых ссылках. С PHP 7.4 появился класс WeakReference, а с PHP 8.0 — WeakMap. Проблема, которую они решают, стара как мир: вы хотите хранить объекты в кеше, но не хотите предотвращать их сборку мусора. Обычная ссылка увеличивает счётчик ссылок, и объект никогда не будет удалён, пока существует кеш. WeakReference позволяет ссылаться на объект, не увеличивая счётчик ссылок. Когда на объект больше нет «сильных» ссылок, он может быть собран сборщиком мусора, и WeakReference вернёт null.
На уровне движка Weak Reference хранит «слабый» указатель на zend_object. Zend Engine отслеживает такие указатели и, когда объект уничтожается, обнуляет их. Это делается через механизм zend_weakrefs, интегрированный со сборщиком мусора. Применений у этой техники много: кеширование без утечек памяти, отслеживание созданных объектов без предотвращения их сборки, реализация паттерна Observer, где наблюдатель не должен удерживать субъект.
И наконец — конструкторы и деструкторы. Конструктор __construct() вызывается после создания zend_object, но до возврата результата new в пользовательский код. В этот момент $this уже полностью сформирован, и можно выбрасывать исключения — объект будет корректно уничтожен. PHP не вызывает родительский конструктор автоматически, это обязанность разработчика.
Деструктор __destruct() вызывается, когда счётчик ссылок объекта достигает нуля, либо во время завершения запроса — фазы RSHUTDOWN, — если объект всё ещё жив. Здесь кроется критически важный нюанс. При завершении запроса деструкторы вызываются в неопределённом порядке. Вы не можете полагаться на то, что связанный объект ещё жив в момент вызова вашего деструктора. Если объект A ссылается на объект B, и B уже удалён, вызов метода B из деструктора A приведёт к ошибке. Более того, исключения, выброшенные в деструкторе, ведут к фатальной ошибке. Поэтому код деструктора всегда должен быть обёрнут в try/catch и не должен зависеть от состояния других объектов.
Подведём итог этой насыщенной главы. Class entry — это общий «чертёж» класса, который живёт в разделяемой памяти и не копируется при создании экземпляров. zend_object — это конкретный экземпляр с указателем на класс и таблицей свойств. Объявленные свойства хранятся по смещениям и доступны быстро, динамические свойства — в хэш-таблице и медленнее. Object handlers — это таблица функций, которая позволяет реализовать магические методы и специальное поведение, но платить за это производительностью. Late Static Binding реализован через передачу вызывающего класса в контексте вызова. Weak References позволяют ссылаться на объект, не предотвращая его сборку мусора. А деструкторы не гарантируют порядок вызова при завершении запроса и не должны содержать код, который может упасть.
Теперь у нас есть полное понимание всех типов данных PHP: числа, строки, массивы, объекты. Мы знаем, как они живут в памяти. В следующей главе мы перейдём к продвинутому владению — асинхронности, корутинам, Fibers и тому, как PHP справляется с вызовами, которые не укладываются в классическую модель «запрос-ответ».
Глава 7. Асинхронность, которую мы заслужили
Долгие годы фраза «асинхронный PHP» звучала как оксюморон. Модель «один запрос — один процесс — один ответ» встроена в ДНК PHP-FPM. Но мир изменился. Чаты, real-time уведомления, долгоживущие соединения, микросервисы с тысячами одновременных запросов — всё это требует иного подхода. PHP ответил на этот вызов двумя принципиально разными архитектурными решениями: событийным циклом и корутинами. Давайте разберём, как они работают, чем отличаются и когда что выбирать.
Начнём с базового вопроса: почему PHP-FPM не создан для асинхронности. Вспомним первую главу — модель PHP-FPM предельно проста. Один процесс обрабатывает один запрос. Внутри запроса всё выполняется синхронно: читаем из базы данных, ждём ответа, получаем данные, рендерим шаблон, отдаём ответ. Процесс заблокирован всё время, пока ждёт базу данных, файловую систему или ответ от внешнего API.
Это не недостаток. Это осознанный архитектурный выбор, который даёт нам простоту — нет гонок за состоянием, нет блокировок, нет взаимных исключений. Даёт надёжность — процесс упал и тут же перезапустился, никакого накопления ошибок. Даёт прозрачность — профилирование, отладка и логирование работают прямолинейно и предсказуемо.
Но за эту простоту мы платим ресурсами. Если двести процессов одновременно ждут ответа от медленного API — они все заняты, все потребляют память, и ни один не делает полезной работы. Это классическая проблема C10K — десять тысяч конкурентных соединений. Асинхронность решает её радикально: один процесс может обслуживать сотни и тысячи одновременных соединений, переключаясь между ними, пока одни ожидают ввода-вывода.
Первый подход к асинхронности в PHP — это событийный цикл, Event Loop. Философия ReactPHP и Amp строится на бесконечном цикле, который делает три вещи. Смотрит, на каких I/O-сокетах — базы данных, HTTP-запросы, файлы — появились данные. Вызывает соответствующий обработчик, callback. Если данных нигде нет — спит микросекунды, не блокируя процесс.
Вот как выглядит неблокирующий HTTP-сервер на ReactPHP. Вы создаёте экземпляр Event Loop, создаёте HTTP-сервер, который на каждый запрос возвращает ответ, вешаете сервер на сокет и запускаете цикл. Здесь нет Apache, нет Nginx, нет PHP-FPM. Это чистый PHP-процесс, который держит сокет и обрабатывает запросы, не порождая отдельный процесс или поток на каждый запрос.
На уровне операционной системы событийный цикл использует системные вызовы — epoll на Linux, kqueue на macOS, IOCP на Windows. Эти системные вызовы позволяют одному потоку эффективно ждать событий на тысячах файловых дескрипторов одновременно. PHP-расширения libevent и libev предоставляют обёртку над этими вызовами. ReactPHP использует встроенную функцию stream_select как запасной вариант, но с расширением ext-event получает производительность на порядок выше.
Главное ограничение событийного цикла — фундаментально и непреодолимо: весь код должен быть неблокирующим. Если вы вызовете file_get_contents или PDO::query внутри обработчика — вы заблокируете весь цикл, и все остальные соединения встанут колом. Нужно использовать асинхронные аналоги: для HTTP-запросов — реактивный браузер вместо file_get_contents, для баз данных — асинхронные клиенты MySQL или обёртки над mysqli_poll, для файлов — асинхронную файловую систему.
Эта ловушка блокировки коварна тем, что код выглядит невинно. Одна строчка file_get_contents для чтения большого файла — и весь сервер с сотней пользователей замерзает на несколько секунд. Правильная альтернатива — асинхронное чтение, которое возвращает промис и не блокирует цикл.
До PHP 8.1 асинхронный код на PHP страдал от ада колбэков или требовал сложной машинерии с генераторами, как в Amp v2. Вы не могли просто написать «получить данные от API, обработать их, вернуть результат» — вы были вынуждены разрывать логику на колбэки или использовать yield в генераторах, что делало код трудным для чтения и отладки.
Появление Fibers — волокон — в PHP 8.1 изменило всё. Fibers — это низкоуровневый примитив, похожий на корутины. Волокно позволяет приостановить выполнение функции в произвольной точке и возобновить позже. Создаётся волокно с функцией-колбэком, затем вызывается метод start, который выполняет код до первого вызова Fiber::suspend. В этот момент управление возвращается в точку вызова start, а волокно замирает, сохраняя всё своё состояние — локальные переменные, позицию в коде, стек вызовов. Позже метод resume продолжает выполнение ровно с того места, где волокно было приостановлено.
Сами по себе волокна не делают ввод-вывод асинхронным. Это строительный блок, поверх которого библиотеки вроде Amp v3 и ReactPHP могут построить удобный интерфейс. С волокнами разработчики пишут код, который выглядит как синхронный, а работает как асинхронный. Вы пишете «получить данные по HTTP», и эта строчка приостанавливает волокно, не блокируя процесс. Когда данные приходят, волокно возобновляется, и выполнение продолжается со следующей строки.
Под капотом это работает так. Когда выполнение доходит до асинхронного HTTP-запроса, библиотека Amp внутри делает три вещи: регистрирует HTTP-запрос в Event Loop, вызывает Fiber::suspend, сохраняя контекст выполнения, и возвращает управление в Event Loop. Когда данные приходят, Event Loop вызывает Fiber::resume с полученными данными. С точки зрения разработчика — обычный последовательный код. С точки зрения времени выполнения — эффективная асинхронность без единого колбэка.
Теперь поговорим о Swoole — технологии, которая идёт гораздо дальше ReactPHP и Amp. Swoole — это PHP-расширение, которое не просто добавляет Event Loop, оно заменяет SAPI целиком. Swoole запускает PHP как долгоживущий демон с корутинами на уровне C и встроенным HTTP-сервером. Вы создаёте HTTP-сервер, вешаете на него обработчик запросов и запускаете — и этот сервер обрабатывает тысячи запросов в одном процессе, переключаясь между корутинами.
Swoole даёт корутины на C — переключение происходит на уровне расширения, быстрее, чем в пользовательском коде. Даёт асинхронный HTTP-клиент, асинхронный Redis и MySQL — всё работает без колбэков, внутри корутин. Даёт воркеры и таймеры для многопоточности и периодических задач. Модель Swoole близка к Go или Node.js, но на PHP.
Но у Swoole есть и подводные камни. Нет изоляции запросов — принцип shared-nothing сломан, глобальное состояние течёт между запросами. Возможны утечки памяти, если фреймворк или библиотека не рассчитаны на долгоживущий процесс — они накапливают память с каждым запросом. Многие расширения несовместимы: Xdebug, некоторые профайлеры не работают с Swoole. И есть так называемый bus factor — документация и поддержка сильно зависят от китайской компании, которая разрабатывает Swoole. Тем не менее, это самое производительное решение для PHP: бенчмарки показывают десятикратный прирост пропускной способности по сравнению с PHP-FPM.
Конец ознакомительного фрагмента.
Текст предоставлен ООО «Литрес».
Прочитайте эту книгу целиком, купив полную легальную версию на Литрес.
Безопасно оплатить книгу можно банковской картой Visa, MasterCard, Maestro, со счета мобильного телефона, с платежного терминала, в салоне МТС или Связной, через PayPal, WebMoney, Яндекс.Деньги, QIWI Кошелек, бонусными картами или другим удобным Вам способом.



