-
Notifications
You must be signed in to change notification settings - Fork 12
Coroutines vs Callbacks
A companion to Coroutines as State Machines and
Folly to Coroutine Migration. The state-machine page shows what the compiler
builds; the migration page shows what changed in our I/O path. This one answers the narrower question an
engineer asks the first time they see a .thenValue chain collapse into linear co_await and shrug "same thing,
nicer syntax": is the coroutine version actually more correct, or just prettier? The answer is that it deletes
whole classes of bug by construction — and it's worth knowing exactly which ones, because the bugs it doesn't
delete are precisely the ones still biting us (the commit-thread hop, the sgs_keepalive rule, the ublk war
story).
A callback is a hand-written continuation; a coroutine is the compiler writing that same continuation for you.
When an async op can't return its result now, "what to do next" can't stay an implicit return address on the
stack — by the time the result arrives the stack is gone (we unwound back to the reactor). So "what to do next"
must be made explicit and heap-resident: some code (a lambda / function pointer) plus its captured state (a
control block). That is all a .thenValue is, and all a folly::Promise consumer is. A coroutine is the exact
same machine, generated: the frame is the control block, the resume index is the lambda, co_await is the split
point (see Coroutines as State Machines for the lowering).
So this is not "callbacks vs something fundamentally different." It is hand-assembled continuations vs compiler-generated continuations — identical control flow. The whole difference is what the type system and compiler can now discharge for you that you previously had to get right by hand, at every single call site.
"Callback" isn't one thing. Our before-code lived on the upper rung, so don't strawman it:
| Rung | Form | What it manages for you | What's still on you |
|---|---|---|---|
| 0 — raw completion callback |
start_async(..., [&](size_t n){ r = n; baton.post(); }), or C-style void* + fn ptr
|
nothing | control-block lifetime, the type cast, "fire exactly once", error plumbing |
| 1 — future / promise (Folly) |
Future<T> / .thenValue / folly::Promise
|
a real type (Try<T>), refcounted Core lifetime, an error channel (.thenError) |
RAII / scope / exceptions across the await; composition is still nested lambdas; a Core per stage |
| 2 — coroutine |
co_await in an async_result<T>
|
all of the above plus structured programming across the await | a small, new lifetime rule (see Limits) |
Folly futures already climbed most of rung 1 — they are typed, lifetime-managed, error-carrying callbacks. The
point below is not "Folly was raw void*." It's that even a polished rung-1 callback is still CPS: the
continuation is still a separate heap object in a separate lambda scope, so the three things that make code
reasonable — RAII, exceptions, lexical scope — still cannot cross the await. Coroutines close that last gap.
Here is the load-bearing claim. The dangerous obligations of CPS — free the control block exactly once, resume exactly once, forward the error, agree on the type, clean up on every path — are, in callback code, discharged at every async call site. In coroutine code they are discharged once: by the type system, by RAII, or inside a handful of awaitables.
| Obligation | Callback — you, at every site | Coroutine — discharged once by |
|---|---|---|
| Free the control block exactly once | intrusive refcount / manual delete (vol_interface_req + intrusive_ptr_release) |
the frame, freed by the machinery at completion |
| Agree on the payload type |
void* cast (rung 0) / Try<T> unwrap (rung 1) |
await_resume() -> T — a compile error if it disagrees |
| Invoke the continuation exactly once | you, on every path including errors and early-outs | the await_suspend/resume protocol, inside the awaitable
|
| Forward the error | every .thenValue / .thenError must remember to check-and-forward |
normal control flow: co_return std::unexpected(...) / throw |
| Clean up on every path | manual — no RAII across the await | destructors run on frame unwind (vol_io_guard) |
| Sequence N steps | nested lambdas (callback hell), a Core each |
linear co_await; one frame, more case labels |
So the surface area where you can get these wrong drops from O(async call sites) to O(awaitable
implementations) — and you write awaitables rarely (sisl::async::value_awaitable, when_all, the task
promise) but use them everywhere. Prove "resume exactly once" once, inside value_awaitable::complete, and it
holds compositionally for every co_await req->promise_ in the tree. That is the real reduction, and it is
structural, not visual: the compiler is now the thing maintaining invariants that used to be a comment and a
code review.
This is the part that is more than surface area — it's why the coroutine version is easier to reason about,
not just to read. Callbacks destroy the stack at each suspension, so they destroy the three tools you reason with;
coroutines restore all three across the co_await:
-
RAII.
vol_io_guardreleases the volume's in-flight count when the frame unwinds — on the happy path, an earlyco_return, or an exception — across every suspension in between. You cannot hold a scope guard across a.thenValue: the enclosing scope exited the moment you registered the continuation. That is precisely why the before-code needed a refcountedvol_interface_reqwith explicit add-ref/release instead of a stack guard — the refcount was a manual stand-in for the RAII the callback model couldn't give it. -
Exceptions / errors as ordinary control flow. The after-code forwards errors on the normal return path:
if (auto const wr = co_await rd()->async_write(new_blkids, data_sgs, nullptr); !wr) co_return std::unexpected(std::errc::io_error); // just a return — nothing to thread
The before-code threaded every error through a nested
.thenValuelambda, each of which had to remember to check-and-forward; drop one and the error silently vanishes. Withco_awaita dropped error is a droppedco_return— far harder to write by accident. -
Local sequential reasoning. The stretch between two
co_awaits reads and reasons exactly like synchronous code; you can apply ordinary "this, then that" logic to it. A callback graph has no "next statement" to reason about — only continuations registered elsewhere.
Coroutines structure control flow. They prove nothing about the interleaving of shared state, and they quietly add one new lifetime rule. Every one of these is a live concern in HomeBlocks:
-
A new lifetime obligation: dangling captures. Coloring traded "free the control block" for "don't reference a temp across a
co_await." That is the entire reason for thesgs_keepaliverule — a lazily-run task captures thesg_listby reference, so it must be heap-owned untilwhen_allruns it, or it dangles. Still a lifetime bug; just a more local and increasingly compiler-checkable one (-Wdangling-class analysis) than a control-block free scattered across error paths. -
Detached handles reopen the manual hazard. The guarantees hold for the structured path. The moment you
detail::detacha fire-and-forget task you are back to manual lifetime — and because tasks are lazy, dropping one instead of detaching it is a silent no-op, the dual of forgetting to fire a callback. -
Concurrency is still unstructured — and migration slightly widens this surface.
co_awaitis a yield barrier; only the code between two awaits is atomic on a reactor. Hold no invariant across aco_awaitthat another coroutine could violate while you're parked. Coroutines cannot prevent the ublk war story (index b-tree corruption from driving reactor-affine I/O on a foreign thread) or the commit-thread serialization point — those are shared-state/affinity bugs, orthogonal to control-flow structure. Worse, sticky affinity makes it easy to pile a fat tail onto the commit thread afterco_await req->promise_, because it reads like ordinary local code. The thing the syntax hides — which thread am I on now — is exactly the thing it can't prove for you.
Bottom line. Coroutines provably shrink the lifetime / sequencing / type / error-propagation surface — by factoring those obligations into the type system, RAII, and a few awaitables — and restore local reasoning across the await. They do not shrink the shared-state / threading surface; that is still on you, and it is where our hardest bugs are. Trust the compiler on control flow; keep reviewing affinity by hand.
Hand-written callback (Folly .thenValue / Promise) |
C++ coroutine (sisl::async) |
|
|---|---|---|
| Continuation is… | a heap object + a lambda you write | the frame + resume index the compiler writes |
| Control-block lifetime | manual refcount / delete, every path |
machinery frees the frame at completion |
| Payload type checked? |
void* cast (raw) or Try<T> unwrap |
await_resume() -> T, compile-time |
| "Resume exactly once" lives | at every call site | inside the awaitable (write once) |
| Error propagation | check-and-forward in each lambda |
co_return std::unexpected / throw |
| RAII / scope across the await | no — scope exited at registration | yes — destructors run on frame unwind |
| Compose N steps | nested lambdas, a Core each |
linear co_await, one frame |
| Surface area for the above | O(call sites) | O(awaitable implementations) |
| Shared-state / affinity safety | on you | still on you |
Read the Folly to Coroutine Migration write path with this lens and the diff stops being cosmetic:
- the refcounted
vol_interface_req→io_reqon the frame +vol_io_guardis "control-block lifetime → RAII across the await"; - the nested
.thenValue/.thenError→ linearco_await+co_return std::unexpectedis "error plumbing → ordinary control flow"; - the
sgs_keepaliveheap-own is the one obligation the trade added, not removed; - and the commit-thread hop + ublk war story are the surface coroutines don't touch — control flow got structured, affinity did not.