async¶
The C++23 coroutine vocabulary. Namespace core::async, directory src/core/async/, target
core::async (header-only).
Status
StopToken, and contour's Task, cancellation and combinators (src/coro at
6777ff05, Task A5 of the
implementation plan)
exist. Generator moved to base in Task A5b: core::async::Generator
would read as an asynchronous, co_await-able stream, and it is a synchronous one, needing
only the standard library. Task B1 merged fastcached's executors and ownership rules into the
rest (src/FastCache/Async at 0708dd54): ParkedWork, DetachedTask, syncRun,
IExecutor, ResumeOn, ThreadPoolExecutor and AsyncQueue, and an awaiter that owns the
task it awaits.
StopToken¶
<core/async/StopToken.hpp> has core::async::StopToken, StopSource, StopCallback<F> and
NoStopState, the vocabulary of cooperative cancellation.
- They are
std::stop_token,std::stop_source,std::stop_callback<F>andstd::nostopstatewhere the standard library defines__cpp_lib_jthread, and otherwisecore::async::detail::StopTokenFallback,StopSourceFallback,StopCallbackFallback<F>andNoStopStateFallback.NoStopStateis aconstexprobject ofstd::nostopstate_tor ofNoStopStateFallback. contour's copy (src/coro/Cancellation.hppat6777ff05) aliasedstd::and refused to compile otherwise. - The fallback is live wherever libc++ before 20 is used without
-fexperimental-library, which gates<stop_token>there. That covers emsdk 3.1.56's libc++ 17, FreeBSD 15's base Clang 19, and AppleClang 17 (measured in CI). It runs with real threads on all of them but the first, so it is production code, not a WebAssembly shim. core-cpp adds no compile flag to its consumers. The configure log names the branch a toolchain takes, when core-cpp's tests are built:[core-cpp] async: StopToken is std::stop_token, or... is core-cpp's fallback. - The fallback has the standard semantics.
request_stop()returns true exactly once, and that call runs every registered callback once, on the requesting thread, before it returns. A callback constructed on a stopped token runs in its constructor, is never registered, and so its destructor waits for nothing.~StopCallbackderegisters a registered callback, and waits while it runs on another thread, but not when it is called from inside that callback.stop_possible()is false for a token without a stop state, and for one whose sources are all gone without a request; it reads the source count before the stop flag, so a stop requested just before the last source goes never reads as impossible. Copies share state. Its members carry the standard's names (request_stop,stop_requested,stop_possible,get_token,callback_type), so code compiles against either branch. - Under single-threaded WebAssembly the fallback keeps plain state: no atomics, no lock and no wait, since a running callback always runs on the calling thread there. Elsewhere a mutex guards its callback list, and no lock is held while a callback runs, so a callback may request stop again or register another callback.
- The choice is read from
<version>, which the header includes first, so every translation unit makes the same one. CORE_ASYNC_FORCE_STOP_TOKEN_FALLBACKselects the fallback where the standard library has<stop_token>. It changes what everyTaskpromise holds, so it must be defined the same way in every translation unit of a program. The test binarycore-cpp-async-fallback-test(ctestcore-cpp.async-fallback) builds the module's tests with it, so the fallback is tested on every platform, under ThreadSanitizer too.
Task, cancellation and combinators¶
Imported from contour's src/coro at 6777ff05, with coro:: renamed core::async:::
Task<T>(<core/async/Task.hpp>) is a lazy coroutine producing one value, or none forTask<void>. It starts suspended, so aco_awaitattaches its continuation before the body runs, and its final suspension transfers to the awaiting coroutine (symmetric transfer). A task is awaited, or driven throughhandle(), once. The awaiter owns the task it awaits:operator co_awaitis rvalue-qualified and moves the frame out of theTaskvalue, so a named local awaited withstd::moveis empty afterwards and the awaiter destroys the frame at the end of theco_awaitexpression. Ownership therefore runs downward through a chain, which is what lets an executor free an abandoned one from its root.release()hands the frame to the caller instead. The promise holds aStopToken, inherited from the awaiting coroutine when a task is awaited, and aunownedRoot(below).result()of a root task requires bothdone()and an owned frame —done()is also true for a default-constructed, moved-from or released task — and a task owning no frame is refused with astd::logic_errorrather than answered with a default-constructedT, soTneed not be default-constructible. That throw reports a precondition violation, not a recoverable error: it is an assertion that survives a Release build, whereassertwould hand back a silently wrong value, and it is not to be caught — acatcharoundresult()would make the empty state a supported path rather than a call to fix.- Symmetric transfer keeps
co_awaits from growing the stack only where the compiler makes the transfer a tail call. Clang and MSVC do at every optimisation level. GCC does only when it optimises sibling calls, and WebAssembly has no tail calls without-mtail-call. Measured at 100000 awaits that complete synchronously, with an 8 MiB stack: - GCC 14.3 and 15, a nested chain (a task awaiting a task awaiting a task ...): overflows at
-O0,-Ogand-O1; passes at-O2and-O3. - GCC 14.3 and 15, a loop in one coroutine awaiting tasks that complete at once: overflows at
-O0; passes at-Ogand above. Consecutive synchronous completions pile up stack until the coroutine really suspends, which is what a read loop over buffered data does. - emsdk 3.1.56 under node, the nested chain: exceeds node's call stack.
Task_test.cpp skips its deep-chain case under Emscripten without -mtail-call, and on GCC
unless the build says the level gives the tail call: GCC defines no macro for the level
(__OPTIMIZE__ is 1 at -Og and -O1 too, where the chain overflows and takes the whole
binary with it), so src/core/async/CMakeLists.txt reads the last -O off the build's own
flags, defines CORE_ASYNC_SYMMETRIC_TRANSFER_IS_TAIL_CALL only at -O2 or better, and says
which it decided in the configure log. gcc-release keeps the case, gcc-debug and any build
outside the presets skip it. The fix is tracked in
core-cpp#15. Task B1 does not close or
narrow it: whether the transfer is a tail call is a property of the compiler's sibling-call
optimisation and of WebAssembly's tail-call support, and the ownership graft changed who owns a
frame, not how final_suspend transfers control. Nothing in this repository measures the depth at
which the chain overflows, so treat "unchanged" as an argument from what was edited rather than
as a number anybody took: the measurements above are the ones that exist, and the deep-chain case
is a pass/fail at 100000 awaits, not a bisection of the limit.
The teardown of a completed chain is not a tail call and does not need to be: each level's
awaiter destroys the child it owns at the end of its own co_await expression, by which time
that child's awaiter has already destroyed its own, so the chain is released one frame at a time,
at no depth. A chain destroyed before it completes is different, and is new in Task B1 — before
it, a root Task freed only its own frame. It is torn down by plain recursion, one stack
frame per level, with no tail call to collapse it and no compiler flag that changes that. So the
same 100000-deep chain that survives a completion at -O2 would be freed recursively if it were
abandoned instead, and the depth at which that overflows is likewise unmeasured.
Task_test.cpp covers the property at ordinary depth and says so explicitly — its abandonment
case "never has one" deep — so nothing here would notice a regression in the depth itself.
- detail::UniqueCoroHandle<Promise> (<core/async/UniqueCoroHandle.hpp>) is the move-only owner
of a coroutine handle that Task and the combinators' child runners share.
- <core/async/Cancellation.hpp> has OperationCancelled, which a cancelled frame throws to
unwind through ordinary RAII, and thisCoroStopToken(), an awaitable yielding the awaiting
coroutine's token without suspending it (a default token where the promise has none). contour's
copy also aliased std::stop_token and friends, which are now <core/async/StopToken.hpp>.
- <core/async/Awaitable.hpp> has the concepts Awaiter (await_ready, await_suspend,
await_resume), HasStopToken (a promise whose stopToken() yields a StopToken) and
CarriesUnownedRoot (a promise that carries the root of an await chain nobody owns). Every
templated await_suspend in the module reads the awaiting promise through them, so what a
promise must offer is stated in one place; Awaitable_test.cpp asserts all three over the
module's own types and over the near misses.
- whenAll(tasks...) (<core/async/WhenAll.hpp>) starts every Task<void> and resumes the
awaiting coroutine once all have finished. Each child inherits the awaiting coroutine's token.
It does not cancel siblings when one throws: the first escape — a cancellation included — is
rethrown once every child has finished.
- whenAny(tasks...) (<core/async/WhenAny.hpp>) resolves to
std::optional<std::size_t>: the index of the first Task<void> to complete, or
std::nullopt where none did (an empty input, or every child unwound cancelled). The winner
requests stop on a child StopSource shared by the others, which must unwind on
OperationCancelled — a child that swallows its cancellation and returns has, as far as the
race can tell, completed. The awaiting coroutine resumes only once every child has finished, so
the frames it owns outlive them. Cancelling the awaiting coroutine's own token cancels every
child through a StopCallback, and whenAny then throws OperationCancelled — but only if no
child completed. A cancellation that arrives after one did cannot undo it, and
.agent/rules/async-and-net.md is explicit that bytes a receive already took win; the winner is reported and the flow decides.
The race state is held by shared_ptr, and every call into it that can run foreign code holds
a reference for that call's duration. Requesting stop runs the children's stop callbacks, and a
runtime awaitable resumes its coroutine from inside one: the losers unwind there and then, the
last transfers to the awaiting coroutine, and the awaiter — with the child source whose
request_stop() is still on the stack — is destroyed before that request returns. Keeping a
stop state alive across one's own request_stop() is the caller's job, and neither
std::stop_source nor the fallback promises to do it.
- Both are written over one runner, one join state and one awaiter (<core/async/Join.hpp>, all of
it core::async::detail, and a public header for the same reason UniqueCoroHandle.hpp is). A
policy supplies the one step that differs — what a child finishing does to the shared state —
together with the token each child observes and the bridge, if any, from the awaiting flow's own
token. whenAll latches nothing and takes the parent's token; whenAny latches the first child
to complete, requests stop on its own child source and arms the parent bridge. What escaped
a child's task is recorded once, in the runner promise, which is also what tells a cancelled
child from a failed one.
Changes from contour's copy, besides the namespace:
- No
NOLINT: the coroutine hooks are exempt through.clang-tidy'sIgnoredRegexp. - Two locals of
whenAll's andwhenAny's final awaiters are renamed, because they shadowed a member of the enclosing promise (-Wshadow, part of core-cpp's warning set);WhenAny_test.cpp'sManualEventinitialises its pointer member (cppcoreguidelines-pro-type-member-init), and its two helpers that only a case compiled off Windows uses are compiled off Windows too (-Wunused-functionon clang-cl).Task_test.cppskips its deep-chain case under Emscripten without-mtail-calland on GCC below-O2(above). - Task B1 collapsed
whenAll's andwhenAny's ~200 lines of near-identical runner, state and awaiter intoJoin.hpp, and with themmakeWhenAllRunner's try/catch, which recorded what escaped a child a second time after the promise already had. - The Phase A gate's third pass (Task A11):
whenAny's race state is reference-counted rather than a member of the awaiter, a child that completed beats a cancellation that follows, the result is astd::optionalrather than adetail::SIZE_MAXsentinel, the variadic overloads require rvalues, the runner promise classifies what escaped its task instead of the body swallowing it, and the parent→child cancel bridge is a named functor rather than aStopCallback<std::function<void()>>. Cancellation.hppno longer defines the stop-token aliases, nor refuses to compile without__cpp_lib_jthread.- contour's
src/coro/test_main.cppis not imported: every test binary linkscore::testing_main. Itssrc/coro/testing/SuppressWindowsDialogs.hppwas merged intocore::testingin Task A1.
AsyncQueue_test.cpp, Awaitable_test.cpp, ParkedWork_test.cpp, StopToken_test.cpp,
Task_test.cpp, WhenAll_test.cpp and WhenAny_test.cpp run in core-cpp.async and again, over
the StopToken fallback, in core-cpp.async-fallback; ThreadPoolExecutor_test.cpp joins them
wherever the build has threads. Every case that counts a coroutine frame does so with a sentinel
rather than leaving it to LeakSanitizer, because a leak only a sanitizer reports is a red once in
N runs and reads as a flake.
core-cpp.async-link-smoke is a third binary, StopTokenLinkSmoke.cpp, which links core::async
alone with the fallback forced: the link a consumer makes, which the other two hide by linking
core::testing_main. Task_test.cpp and
WhenAny_test.cpp do not compile their cases that propagate an exception out of a coroutine
frame on Windows, where contour found that throwing through a coroutine frame crashes the Catch2
harness (an MSVC coroutine-unwind interaction that also affects std::generator).
WhenAll_test.cpp's exception cases have no such guard, and pass with cl and clang-cl.
Ownership, executors and queues¶
From fastcached's src/FastCache/Async at 0708dd54, with FastCache:: renamed core::async::
and Detail:: detail:::
DetachedTask(<core/async/DetachedTask.hpp>) is a coroutine started for its effects: its body runs to its first suspension on construction and its frame frees itself at the end, so nobody holds a handle. An exception escaping it terminates the process, because there is no caller to hand it to. Its promise answersstopToken()with a never-stopped token, so aTaskawaited from it takes the ordinary inheritance path. It is the one coroutine shape in the module that nothing owns, which is what makes the next entry answerable.ParkedWork(<core/async/ParkedWork.hpp>) is what a coroutine hands an executor: the handle toresume, and — only where this chain belongs to nobody — the chain root toabandonif it is never resumed. The two are different questions, and the second has a safe default:IExecutor::submit(std::coroutine_handle<>)borrows, so an executor may not free what it merely holds.detail::parkedWorkForderives the answer from the parking coroutine's own promise (detail::unownedRootOf), anddetail::Parkedis the container entry that ownsabandonfor as long as it holds it:resume()disowns and resumes in one expression, and a handle it declines to resume is freed rather than dropped. It is the ROOT and never the parked frame, because ownership in aTaskchain runs downward. Origin: fastcached#1025.syncRun(task)andsyncRunWith(task, retrieve)(<core/async/SyncRun.hpp>) drive a task to completion on the calling thread. The task must be self-driving; one still suspended after its resume has no result to read, and destroying its frame there tears down storage whatever parked it still points into, sosyncRunthrowsstd::logic_errorinstead.syncRunWithtakes the park back first, while the frame is alive, and throws afterwards — a task its retriever did not wake has its frame deliberately leaked rather than freed under something that points into it.IExecutor(<core/async/IExecutor.hpp>) is somewhere a suspended coroutine can be handed to be resumed:submit(std::coroutine_handle<>)andsubmit(ParkedWork), both callable from any thread. Every class deriving from it saysusing IExecutor::submit;, because a derived class that re-declares one overload of a name hides every other overload of it (fastcached#1041). Both halves are pure: an executor that queues work has to state what it does about work it never runs.co_await ResumeOn { executor }(<core/async/ResumeOn.hpp>) continues the awaiting coroutine wherever that executor runs things.ThreadPoolExecutor(<core/async/ThreadPoolExecutor.hpp>) is anIExecutorover a fixed set of threads, for work that blocks — a loop multiplexes coroutines that suspend, and is the wrong answer for a job that occupies its thread for seconds. It does not bound admission. It never abandons work: its queue is drained even while stopping, and a handle submitted afterstop()is resumed inline on the calling thread rather than dropped, because an unresumed coroutine never frees its frame. fastcached's 92-line.cppis inlined here:core::asyncis an INTERFACE target, and a compiled body would change what every consumer links.AsyncQueue<T>(<core/async/AsyncQueue.hpp>) is a queue one coroutine parks on and any thread pushes to, replacing a mutex, a condition variable and a deque at the boundary between a producing thread and a consuming coroutine.push()andclose()never resume the consumer inline; they hand its handle to the executor, because a producer commonly pushes while holding a lock of its own.AsyncQueueOptionsbounds it and says which end overflow sacrifices (DropOldest,DropNewest);push()reports whether the item was admitted and how many it displaced.co_await queue.pop()resolves tostd::optional<T>— a value, orstd::nulloptmeaning the queue closed — and is stop-aware: a cancel from the awaiting flow's own token throwsOperationCancelled, while an item already queued and aclose()both answer first. The queue owns no coroutine frame and cannot, so an owner observes its consumer finishing before destroying it;~AsyncQueueasserts that no waiter is left, andhasWaiter()lets a test assert it in a release build too.
unownedRoot is what ties these together. It is a member of every promise in the module, set at
each await_suspend from the awaiting coroutine's own, and non-empty exactly where the chain
bottoms out in a DetachedTask. whenAll's and whenAny's runners carry it too: a runner is a
coroutine type of its own between a detached root and the task that parks, and one that did not
carry the answer would make every park underneath a combinator read as somebody owns this.
Conventions¶
From contour's src/coro/README.md at 6777ff05, as far as it still holds:
core::asyncincludes nothing but the standard library (and links what that needs: Threads, above).core::netis the layer that knows about sockets, and neither depends on anything above it in the module table.- Coroutine parameters are values, never references: a reference dangles once the coroutine
suspends. A pointer is a value, and the tests' drivers take pointers to locals that outlive
them. A coroutine lambda's closure is a temporary destroyed once the
Taskis created, so a body that resumes later reads its captures through a danglingthis: write a free function. - The awaiter and promise hooks (
await_ready,await_suspend,await_resume,initial_suspend,final_suspend,return_value, ...) are named by the language. They stay non-static instance methods: a staticinitial_suspendorfinal_suspendmakes the compiler-generatedpromise.hook()call tripreadability-static-accessed-through-instance. - A cancelled frame unwinds by throwing
OperationCancelled; a runtime awaitable throws it fromawait_resumewhen its token hasstop_requested(). - The README's provenance note made contour's copy canonical over endo's and fastcached's. This copy takes that role: a fix is made here, released and re-vendored, never made in a consumer's copy.
Depends on no other core-cpp module, and on Threads: the StopToken fallback synchronises its
stop state with a std::mutex, a std::condition_variable and std::this_thread::get_id(), so
target_link_libraries(app PRIVATE core::async) has to carry pthread wherever that is a library of
its own. core-cpp.async-link-smoke is that link, made with the fallback forced and nothing else
on the line. Under single-threaded Emscripten the fallback keeps plain state and the module links
nothing. Under WebAssembly everything builds except
ThreadPoolExecutor.hpp, which refuses to compile without threads and is in no FILE_SET there;
where libc++ has no
<stop_token> without its experimental library, StopToken is the fallback. See
Coroutines and lifetimes.