Производительность¶
Роутер событий делает больше, чем классический диспетчер: сопоставляет шаблоны, подставляет параметры, изолирует ошибки, замеряет каждого слушателя и возвращает отчёт. Всё это стоит времени. Вопрос в том, сколько — и где эта цена заметна, а где нет.
Ниже — честное сравнение с symfony/event-dispatcher: где events-router быстрее, где медленнее и почему.
Как мерили¶
Бенчмарк лежит в репозитории, benchmarks/bench.php, запускается одной командой:
- 50 событий с точными именами, по 4 слушателя на каждое — 200 слушателей. Это территория Symfony: шаблонов в нём нет, только точные имена.
- Слушатели пустые: меряется сам диспетчер, а не работа внутри слушателей.
- OPcache включён, как на проде. JIT выключен, как на большинстве продакшен-серверов.
- Каждый сценарий прогревается и прогоняется пять раз, в таблице — медиана.
Результат на Apple M4, PHP 8.2, symfony/event-dispatcher 7.4. Все цифры — время одной операции.
Запрос PHP-FPM¶
Каждый запрос собирает диспетчер заново и рассылает несколько событий:
| Что меряем | symfony/event-dispatcher | events-router | events-router, кэш маршрутов |
|---|---|---|---|
| подготовка: диспетчер с 200 слушателями, без рассылок | 31 мкс | 643 мкс | 2,4 мкс |
| весь запрос: подготовка + 3 рассылки | 38 мкс | 1 340 мкс | 21 мкс |
Весь запрос с кэшем — в 1,8 раза быстрее Symfony, без кэша — в 35 раз медленнее.
Рассылки внутри запроса стоят по-разному. У Symfony три рассылки — это 38 − 31 ≈ 7 мкс, примерно по 2,3 мкс: первая рассылка события сортирует его слушателей. У events-router с кэшем — 21 − 2,4 ≈ 18 мкс, примерно по 6 мкс: первая рассылка события в процессе собирает для него план — находит маршруты и создаёт их объекты. Мы выигрываем на подготовке (~28 мкс) и проигрываем на каждом новом событии в запросе (~3,7 мкс). Значит, итог в нашу пользу, пока за запрос рассылается не больше 7 разных событий. Если разных событий больше, Symfony выйдет вперёд и в запросе. Повторная рассылка того же события в запросе стоит 1,6 мкс.
Воркер¶
Долгоживущий процесс собирает диспетчер один раз и рассылает одни и те же события снова и снова:
| Что меряем | symfony/event-dispatcher | events-router |
|---|---|---|
| повторная рассылка события с 4 слушателями | 0,69 мкс | 1,6 мкс |
Здесь Symfony быстрее в 2,3 раза. Кэш маршрутов на эту цифру не влияет: он ускоряет только подготовку.
На своей машине цифры будут другими. Соотношения — примерно такими же.
Запрос PHP-FPM: быстрее Symfony¶
Обычный PHP-сайт живёт запросами. На каждый запрос приложение собирается заново: создаётся диспетчер, регистрируются слушатели, рассылается несколько событий, всё выбрасывается. Именно эта сборка и стоит дороже всего.
Symfony на каждом запросе вызывает addListener() 200 раз — это 31 мкс. events-router с кэшем маршрутов почти ничего не делает и тратит 2,4 мкс: таблица маршрутов лежит в PHP-файле, OPcache отдаёт её из общей памяти без копирования, а объекты маршрутов создаются только для тех, что совпали с событием. Файл маршрутов не выполняется, классы слушателей не загружаются.
Без кэша картина обратная: каждый listen() разбирает шаблон, компилирует регулярное выражение и загружает класс слушателя, чтобы проверить его. Это 0,6 мс на подготовку 200 маршрутов и ещё столько же на сборку таблицы при первой рассылке — нормально для разработки, где важнее, чтобы ошибка в маршруте всплыла сразу. Для прода включайте кэш.
Воркер: Symfony быстрее на микросекунду¶
В долгоживущем процессе — воркере очереди — сборка происходит один раз, и остаётся только стоимость повторных рассылок. Здесь Symfony быстрее: 0,69 мкс против 1,6 мкс на событие с четырьмя слушателями.
Разница — около микросекунды, и это цена того, что events-router делает, а Symfony нет. Примерная раскладка по отдельным замерам:
| Что | Сколько стоит |
|---|---|
время работы каждого слушателя (getDuration() в отчёте) |
~0,2 мкс |
отчёт о рассылке (DispatchReport) |
~0,3 мкс |
| проверка имени события и неизменяемое событие | ~0,1 мкс |
| поиск по таблице маршрутов с шаблонами | ~0,1 мкс |
Микросекунда на событие — это миллион событий, чтобы набежала секунда. Реальный слушатель ходит в базу, отправляет письмо или пишет в очередь: это сотни микросекунд и миллисекунды. На фоне такой работы разница между диспетчерами не видна.
Что сделано, чтобы было быстро¶
- Поиск без перебора. Маршруты с точным именем лежат в хэш-таблице, как в Symfony. Маршруты с шаблонами разложены по литеральному началу: у
shop.order.{order_id}.paidэтоshop.order, и регулярное выражение проверяется только у тех, чьё начало совпало с топиком. - План рассылки на топик. Для каждого топика один раз собирается план: какие слушатели, с какими middleware и параметрами. Повторная рассылка того же события берёт готовый план. Сборка плана — основная цена первой рассылки события в процессе (~6 мкс против 1,6 мкс у повторной).
- Короткий путь. Если у слушателей нет middleware и параметров маршрута, а событие нельзя остановить через PSR-14, на каждого слушателя остаются только вызов и замер времени.
- Ленивый отчёт. Рассылка записывает только время и отклонения: кто упал, кто пропущен. Объекты
ListenerReportсоздаются, когда вы вызываетеgetListeners(). - Кэш как массив. Таблица в кэше — данные, а не код с объектами. Её не нужно разбирать, а OPcache держит её в общей памяти для всех процессов.
Шаблоны¶
В Symfony нет шаблонов топиков, поэтому сравнивать здесь не с чем. В events-router поиск по шаблону стоит одной проверки регулярного выражения на каждый маршрут, чьё литеральное начало совпало с топиком. Результат запоминается: повторная рассылка события order.42.paid не проверяет шаблоны заново.