Конкурентное разрешение
Под Swoole один процесс-воркер обслуживает десятки запросов одновременно, и контейнер у них общий. Значит, контейнеру мало знать «этот класс сейчас собирается» — ему нужно знать, собирает ли его этот запрос или соседний. Ответы на эти два вопроса устроены по-разному, и от них зависит, увидите ли вы ошибку там, где ошибки нет.
Что такое «единица работы»
Единица работы — это один самостоятельный кусок работы: HTTP-запрос, задача из очереди, CLI-команда. Под FPM единица работы совпадает с процессом: пришёл запрос — процесс занят только им. Под Swoole это не так: процесс живёт часами, а единицей работы становится корутина — лёгкий поток выполнения, который Swoole заводит на каждый запрос и уничтожает по его окончании.
Разница решающая. Всё, что контейнер хранит в обычном свойстве, принадлежит процессу, то есть сразу всем запросам в полёте. Всё, что он хранит в контексте корутины, принадлежит одному запросу.
Проблема: пауза посреди сборки
Разрешение зависимости не мгновенно. Фабрика может открыть соединение с Redis, спросить
конфигурацию у базы, сходить по сети — а под SWOOLE_HOOK_ALL любая такая операция
приостанавливает корутину и отдаёт процессор другому запросу.
Представьте контейнер, который отмечает «класс собирается» в общем свойстве:
Корутина A (запрос 1) Корутина B (запрос 2)
────────────────────── ──────────────────────
make(OrderService)
отметил: OrderService собирается
фабрика открывает Redis ──пауза──▶
make(OrderService)
видит чужую отметку
✗ «Circular dependency detected»
◀── соединение готово
собрал, отметку снялЦикла нет ни одного, но второй запрос падает — просто потому, что он попросил тот же класс в неудачный момент. Такой отказ невоспроизводим в тестах и всплывает в проде несколько раз в сутки под нагрузкой.
Решение: стек разрешения принадлежит единице работы
Winter DI хранит стек разрешения там же, где живут request-scoped экземпляры — в контексте
корутины (Coroutine::getContext()), а вне корутины в обычном свойстве, где процесс и так
обслуживает одну единицу работы за раз.
Поэтому проверка цикла отвечает на правильный вопрос: «не встречал ли я этот класс в своей собственной цепочке разрешения?» — а не «не занят ли им кто-то ещё».
Корутина A: make(OrderService) → make(Billing) → make(OrderService) ← цикл, ошибка
Корутина B: make(OrderService) ← своя цепочка, всё в порядкеНастоящий цикл по-прежнему ловится, и сообщение называет всю цепочку — видно, какое звено разрывать:
ContainerException: Circular dependency detected while resolving
[App\OrderService] → [App\Billing] → [App\OrderService].Синглтон: второй ждёт, а не строит копию
Со стеком на единицу работы возникает второй вопрос: что если две корутины одновременно просят синглтон, которого ещё нет в кэше? Если каждая построит свой, один экземпляр затрёт другой в кэше, а проигравшая корутина весь запрос проработает с «сиротой» — объектом, которого в контейнере уже нет. Фабрика при этом отработает дважды: два соединения, два прогрева кэша.
Поэтому первая корутина строит, а остальные ждут её результат и получают тот же объект:
| Scope | Две корутины просят один класс одновременно |
|---|---|
transient |
Каждая строит свой экземпляр — это и есть контракт scope’а |
request |
У каждой свой экземпляр по определению, делить нечего |
singleton |
Первая строит, остальные ждут и получают тот же самый объект |
Ожидание бывает только один раз
Ждать приходится лишь на первом разрешении синглтона, пока кэш пуст. Дальше make()
возвращает готовый экземпляр первой же строкой, не доходя до этой машинерии. И ожидание ничего
не стоит по времени: ждущая корутина просыпается не позже, чем закончила бы собирать копию
сама.
Если фабрика упала с исключением, ожидающие не зависают: их будят, готового экземпляра в кэше нет, и каждая пробует собрать сама — то есть ошибку видит тот, кто её вызвал, а не случайная жертва.
Ждут только ту сборку, результат которой сохранится
make($abstract, $overrides) с непустыми overrides никогда не кэшируется, поэтому в
очередь за такой сборкой никто не встаёт: иначе ждущий проспал бы её целиком, не нашёл ничего
в кэше и всё равно собрал бы свой экземпляр — две сборки вместо одной.
Что это значит на практике
- Ошибка «Circular dependency» теперь всегда настоящая. Если вы её видите — в графе действительно есть цикл, и цепочка в сообщении показывает какой. Раньше её могла вызвать простая одновременность.
- Тяжёлую инициализацию можно держать в фабрике синглтона. Открыть пул, прогреть словарь, прочитать конфиг — всё это выполнится один раз на воркер, даже если на холодном старте в него прилетело сразу двадцать запросов.
- Ничего настраивать не нужно. Поведение включается само, когда код исполняется внутри корутины; под FPM и CLI работает прежняя однопроцессная логика.
Это не делает ваши сервисы потокобезопасными
Контейнер гарантирует, что синглтон собран один раз и корректно роздан. Что происходит с
его состоянием дальше — на вас: #[Singleton], в который пишут данные текущего запроса,
по-прежнему утечёт между параллельными запросами. Для пер-запросного состояния есть
#[Request].
Связанное
- Request-scope и Swoole — изоляция экземпляров по корутинам
- Жизненный цикл разрешения — где эти шаги стоят в конвейере
make() - Разрыв циклических зависимостей — что делать с настоящим циклом
- Scope’ы — три времени жизни и матрица безопасности