Пакет · di

Конкурентное разрешение

Под Swoole один процесс-воркер обслуживает десятки запросов одновременно, и контейнер у них общий. Значит, контейнеру мало знать «этот класс сейчас собирается» — ему нужно знать, собирает ли его этот запрос или соседний. Ответы на эти два вопроса устроены по-разному, и от них зависит, увидите ли вы ошибку там, где ошибки нет.

Что такое «единица работы»

Единица работы — это один самостоятельный кусок работы: HTTP-запрос, задача из очереди, CLI-команда. Под FPM единица работы совпадает с процессом: пришёл запрос — процесс занят только им. Под Swoole это не так: процесс живёт часами, а единицей работы становится корутина — лёгкий поток выполнения, который Swoole заводит на каждый запрос и уничтожает по его окончании.

Разница решающая. Всё, что контейнер хранит в обычном свойстве, принадлежит процессу, то есть сразу всем запросам в полёте. Всё, что он хранит в контексте корутины, принадлежит одному запросу.

Проблема: пауза посреди сборки

Разрешение зависимости не мгновенно. Фабрика может открыть соединение с Redis, спросить конфигурацию у базы, сходить по сети — а под SWOOLE_HOOK_ALL любая такая операция приостанавливает корутину и отдаёт процессор другому запросу.

Представьте контейнер, который отмечает «класс собирается» в общем свойстве:

text
Корутина A (запрос 1)                  Корутина B (запрос 2)
──────────────────────                 ──────────────────────
make(OrderService)
отметил: OrderService собирается
фабрика открывает Redis ──пауза──▶
                                     make(OrderService)
                                       видит чужую отметку
                                       ✗ «Circular dependency detected»
◀── соединение готово
собрал, отметку снял

Цикла нет ни одного, но второй запрос падает — просто потому, что он попросил тот же класс в неудачный момент. Такой отказ невоспроизводим в тестах и всплывает в проде несколько раз в сутки под нагрузкой.

Решение: стек разрешения принадлежит единице работы

Winter DI хранит стек разрешения там же, где живут request-scoped экземпляры — в контексте корутины (Coroutine::getContext()), а вне корутины в обычном свойстве, где процесс и так обслуживает одну единицу работы за раз.

Поэтому проверка цикла отвечает на правильный вопрос: «не встречал ли я этот класс в своей собственной цепочке разрешения?» — а не «не занят ли им кто-то ещё».

text
Корутина A: make(OrderService) → make(Billing) → make(OrderService)   ← цикл, ошибка
Корутина B: make(OrderService)                                        ← своя цепочка, всё в порядке

Настоящий цикл по-прежнему ловится, и сообщение называет всю цепочку — видно, какое звено разрывать:

text
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].

Связанное