Package · di

Concurrent resolution

Under Swoole a single worker process serves dozens of requests at once, and they share one container. So it is not enough for the container to know that a class “is being built” — it has to know whether this request is building it or a neighbouring one. Those two questions are answered differently, and the difference decides whether you see an error where there is no error.

What a “unit of work” is

A unit of work is one self-contained piece of work: an HTTP request, a queue job, a CLI command. Under FPM the unit of work is the process — a request arrives and the process is busy with nothing else. Under Swoole that stops being true: the process lives for hours, and the unit of work becomes a coroutine, a lightweight thread Swoole creates per request and destroys when it ends.

The distinction decides everything. Whatever the container keeps in a plain property belongs to the process, meaning to every request in flight at once. Whatever it keeps in the coroutine’s context belongs to one request.

The problem: pausing mid-build

Resolving a dependency is not instantaneous. A factory may open a Redis connection, read configuration from a database, call over the network — and under SWOOLE_HOOK_ALL any of those suspends the coroutine and hands the processor to another request.

Picture a container that marks “this class is being built” in a shared property:

text
Coroutine A (request 1)                Coroutine B (request 2)
──────────────────────                 ──────────────────────
make(OrderService)
marks: OrderService building
factory opens Redis ────pause───▶
                                     make(OrderService)
                                       sees somebody else's mark
                                       ✗ "Circular dependency detected"
◀─── connection ready
built, mark cleared

There is no cycle anywhere, yet the second request fails — only because it asked for the same class at an unlucky moment. That kind of failure never reproduces in tests and shows up in production a few times a day under load.

The fix: the resolution stack belongs to the unit of work

Winter DI keeps the resolution stack where request-scoped instances already live — in the coroutine context (Coroutine::getContext()) — and off that path in a plain property, where the process handles one unit of work at a time anyway.

The cycle check therefore answers the right question: “have I met this class in my own resolution chain?” rather than “is anybody else busy with it?”.

text
Coroutine A: make(OrderService) → make(Billing) → make(OrderService)   ← a cycle, throws
Coroutine B: make(OrderService)                                        ← its own chain, fine

A genuine cycle is still caught, and the message names the whole chain, so you can see which link to break:

text
ContainerException: Circular dependency detected while resolving
[App\OrderService] → [App\Billing] → [App\OrderService].

Singletons: the second one waits instead of building a copy

With a per-unit stack a second question appears: what if two coroutines ask for a singleton that is not cached yet? If each built its own, one instance would overwrite the other in the cache, and the losing coroutine would spend the rest of its request holding an orphan — an object the container no longer knows about. The factory would also run twice: two connections, two cache warm-ups.

So the first coroutine builds and the others wait for its result and receive that same object:

Scope Two coroutines ask for the same class at once
transient Each builds its own instance — that is the scope’s contract
request Each has its own by definition; there is nothing to share
singleton The first builds it, the rest wait and get that very object

Waiting happens once, ever

Waiting only occurs on the first resolution of a singleton, while the cache is empty. After that make() returns the ready instance on its very first line, never reaching this machinery. And the wait costs nothing: a waiting coroutine wakes no later than it would have finished building the copy itself.

If the factory throws, waiters do not hang: they are woken, find no instance in the cache, and each tries to build it — so the error reaches whoever caused it rather than a random victim.

Only a build whose result is kept is worth waiting for

make($abstract, $overrides) with non-empty overrides is never cached, so nobody queues behind such a build: a waiter would sleep through the whole thing, find an empty cache, and build its own copy anyway — two builds instead of one.

What this means in practice

  • A “Circular dependency” error is now always real. If you see one, the graph truly has a cycle and the chain in the message shows which. Before, plain concurrency could produce it.
  • Heavy initialisation can live in a singleton factory. Opening a pool, warming a dictionary, reading configuration — each happens once per worker, even if twenty requests hit a cold worker simultaneously.
  • There is nothing to configure. The behaviour engages by itself when the code runs inside a coroutine; under FPM and CLI the previous single-process logic applies.

This does not make your services thread-safe

The container guarantees a singleton is built once and handed out correctly. What happens to its state afterwards is on you: a #[Singleton] that stores the current request’s data still leaks across concurrent requests. Per-request state belongs in #[Request].