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:
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 clearedThere 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?”.
Coroutine A: make(OrderService) → make(Billing) → make(OrderService) ← a cycle, throws
Coroutine B: make(OrderService) ← its own chain, fineA genuine cycle is still caught, and the message names the whole chain, so you can see which link to break:
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].
Related
- Request scope and Swoole — per-coroutine instance isolation
- Resolution lifecycle — where these steps sit in the
make()pipeline - Breaking circular dependencies — what to do about a real cycle
- Scopes — the three lifetimes and the safety matrix