Couroutines#
Warning
This page is for amongoc developers and the documented components and
behavior are not part of any public API guarantees.
The C++ implementation of amongoc can use C++ coroutines to implement
asynchronous control flow.
For simple operations, prefer to compose nanosenders directly. For more complex control flow, using coroutines enhances maintainability and readability.
Important
Be sure to read the Memory Allocation section below, which specifies requirements on the parameters of coroutines.
Coroutine Types#
amongoc_emitter as a Coroutine#
The C API type amongoc_emitter is valid as a coroutine function return type.
The coroutine state will be allocated and stored within the
amongoc_emitter::userdata, and the coroutine will be destroyed when the
generated emitter is destroyed.
Important
Be sure to read the The Explicit Ramp End section!
Coroutines created from amongoc_emitter are lazily started only when the
emitter is attached to a handler and the operation is launched with
amongoc_start().
From within an amongoc_emitter coroutine, one of the following can be returned
with co_return:
An
emitter_result– allows returning both anamongoc_statusand a boxed result value.A
unique_box– resolves with statusamongoc_okayand the given value as the result value.An
amongoc_status– the result value will beamongoc_nil.A
std::error_code– this will be converted usingamongoc_status::from(), and the result value will beamongoc_nil.nullptror literal0– will construct an emitter result withamongoc_okayandamongoc_nil. This return kind is “mere success”, akin to returningvoid.
The Explicit Ramp End#
TL;DR
An amongoc_emitter coroutine must await amongoc::ramp_end exactly once
before doing anything.
The coroutine ramp is a special construct generated by the compiler and is the portion of code evaluated immediately when a coroutine function is called, and continues until the first suspend point in the coroutine. The compiler will insert coroutine ramp code automatically for all coroutines. This is the code that initializes the promise and copies the coroutine arguments into the coroutine state.
If a coroutine is set to std::suspend_always at its
promise_interface::initial_suspend() point, the coroutine ramp is entirely
hidden. The amongoc_emitter coroutine is different from the co_task
coroutine in that it is eager starting. That is: It’s initial suspend point is
std::suspend_never, meaning it does not start suspended. An amongoc_emitter
coroutine must explicitly suspend exactly once before it attempts to call
any other coroutines or perform any asynchronous operations. This should be
performed by awaiting the special amongoc::ramp_end object exactly once.
The purpose of this design is so that an amongoc_emitter coroutine may make
copies of its arguments before suspending and returning control to the caller.
This is useful to capture arguments by-value that were passed by reference from
the caller. For example:
amongoc_emitter do_something(mlib_allocator a, const char* some_string) {
// We want to take a copy of `some_string` from the caller to persist it
// in the coroutine state.
auto s = amongoc::string(some_string, a);
co_await amongoc::ramp_end;
// The caller may have freed `some_string`, but we are okay because we have
// a copy in `s`
// Do the rest of the asynchronous operation...
}
The amongoc::ramp_end will suspend the coroutine and resume to the caller.
This allows us to effectively add our own custom code to the coroutine ramp.
Refer to the implementation of CRUD operations for clear examples of using
ramp_end.
Note
It is safe for an amongoc_emitter to throw an exception or return a value
before the ramp_end.
Exception Handling with amongoc_emitter#
If an amongoc_emitter coroutine throws an exception, the following will happen:
If the exception is derived from
std::system_error, the error code will be given toamongoc_status::from()and the resulting status object will be the result status of the emitter, with anamongoc_nilresult value.Otherwise, if the exception type is derived from an
amongoc::exception, then theexception::status()will be the result status of the emitter.Otherwise, if the exception type is
std::bad_alloc, the emitter will resolve with generic cateogry andENOMEM.Otherwise, the program will terminate. Don’t let this happen!
amongoc::co_task as a Coroutine#
-
template<typename T>
class co_task# This is a dedicated C++ coroutine return type. It is move-only. Using a
co_taskas a coroutine is more efficient than usingamongoc_emitter, but is not part of the public API, so its usage is limited to internal interfaces only.-
using result_type = result<T, std::exception_ptr>#
-
nanosender auto as_sender() &&#
Convert the coroutine to a nanosender. It will send a
result_typeobject.
-
using result_type = result<T, std::exception_ptr>#
Exception Handling with co_task#
If a co_task \(A\) is awaited within another co_task coroutine \(B\) and the
coroutine associated with \(A\) throws an exception \(x\), then \(x\) will be
re-thrown within \(B\) when \(A\) is co_await’d.
When used as a nanosender (i.e. with co_task::as_sender() ), if the
underlying coroutine throws an unhandled exception, then the sent result
object will contain a std::exception_ptr for that exception.
Awaitable Types#
Within an amongoc_emitter coroutine or a co_task coroutine, any type that
meets nanosender is valid for co_await-ing (this includes
unique_emitter itself, since it implements the nanosender interface).
When awaiting a nanosender \(S\), a special receiver will be connected to \(S\)
that will resume the parent coroutine. This will schedule the coroutine to be
resumed by \(S\) when it invokes the attached receiver.
The result type from the co_await on the nanosender will be the
sends_t of that nanosender. When awaiting a unique_emitter, this will be an
emitter_result.
Exception Throwing#
nanosenders, unlike P2300 senders, do not have a distinct error channel. For
that reason, co_await-ing a nanosender will never throw an exception.
Instead, error information must be transmitted through the nanosender’s result
type.
Memory Allocation#
C++ coroutines support customizing the allocation of the coroutine’s state.
Coroutines based on amongoc_emitter and co_task will refuse to use the
default operator new, and require that a mlib::allocator is
provided to the coroutine.
For this reason, a co_task or amongoc_emitter coroutine must accept as its
first parameter one of:
A pointer to
amongoc_loop(which is assumed to never benullptr!) – Themlib::allocatorwill be obtained from the event loop.A
mlib::allocatordirectly.An
mlib_allocator, which will be converted to amlib::allocator.Any type which supports
get_allocatorwith an allocator that is convertible to amlib::allocator.
If this requirement is not met, then the coroutine will fail to compile when
attempting to resolve the operator new for the coroutine.
Example
Note that the C++ coroutine machinery handles this transparently, so the parameter need only be present, not necessarily used within the coroutine itself:
co_task<int> add_numbers(allocator<> /* unnamed */, int a, int b) {
co_return a + b;
}
In the above, event though the mlib::allocator parameter is unnamed and
unused within the coroutine body, it will still be used by the coroutine’s
promise to allocate memory for the coroutine state.
Allocation Failure#
If allocation fails for a co_task coroutine, then the coroutine function will
immediately throw without returning a co_task object. If allocation fails for
an amongoc_emitter coroutine, then the returned emitter will be from
amongoc_alloc_failure().
Parameter Lifetimes#
An important thing to remember about coroutines is that the parameters are
captured by their declared type. This means that reference parameters are
captured by reference, including reference-like types (e.g. std::string_view
and bson_view).
Because of these capture semantics, care should be taken that reference-like parameters outlive the coroutine body for the duration that they are used. This can be done in one of three ways:
Capture only using value types. This means that
std::string_viewandconst std::string&should be passed asstd::stringinstead.Document the lifetime requirements of reference-like parameters. This places the onus on the user, and is often less than ideal.
Create a shim function that copies arguments by-value before calling the real coroutine:
emitter resolve_addr(const char* address) { return _co_resolve(std::string(address)); } static emitter _co_resolve(std::string s) { co_await do_stuff(s); }
Note that this is a non-issue for coroutines that are immediately
co_await’d in their caller’s scope, since the lifetime of the arguments
is guaranteed to be at least the lifetime of the coroutine itself:
co_task<int> use_string(std::string const& s) {
co_await do_stuff(s);
co_return 0;
}
co_task<int> outer_co() {
co_await use_string("I am a string");
}
In the above, a temporary std::string is passed to use_string and the
reference parameter will be bound to that temporary. This is safe here, because
the coroutine for use_string is immediately awaited and is guaranteed to
complete before the temporary string is destroyed.
Coroutines Versus Nanosenders#
It is reasonable to ask when to use coroutines versus using nanosenders
directly. It may be tempting to use coroutines always, since they are easier
to write and read than a pipeline of then and
let closures.
The following drawbacks of coroutines over pure nanosenders might be considered:
A coroutine often requires requires dynamic memory allocation, unless the compiler can perform allocation elision, which is still a very fragile optimization. A composed nanosender will often require no dynamic memory allocation at all!
However, this allocation requirement is not usually a problem for
amongoc_emittercoroutines, since they would need to dynamically allocate storage anyway if they would need to use nanosenders that wouldn’t fit inside of anamongoc_box.Reference parameter lifetime can be tricky to deal with. This is managable, but requires care.
A
amongoc_emittercreated from a coroutine will require slightly more memory than anamongoc_emittercreated from an equivalentnanosender.
When to Use Coroutines#
It should also be considered that coroutines will astronomically improve maintainability in the face of non-trivial control flow, such as looping, branching, recursion, and error propagation.
In general, prefer coroutines for high-level constructs that require non-trivial control flow.
When to Use Nanosenders#
The pure nanosender APIs should be used for very small building-blocks and
high-traffic APIs, since they are guaranteed to be non-allocating.
Coroutine Machinery in amongoc#
This section will be a crash-course on C++20 coroutine machinery and how it is
implemented in amongoc.
See also
For a more detailed explanation of how coroutines operate, see the cppreference page about C++ coroutines.
C++ coroutines give a large amount of flexibility to the author in terms of how they are scheduled and how they communicate with their surrounding context.
Triggering Coroutine Magic#
The C++ coroutine machinery is not triggered by the signature of the function,
but by the presence of a coroutine control keyword within the function body
(i.e. co_await, co_return, or co_yield). A function is
not a coroutine unless it uses a coroutine keyword, even if the return type of
the function is a coroutine type. For example:
co_task<int> this_is_not_a_coroutine() {
return this_is_a_coroutine();
}
co_task<int> this_is_a_coroutine() {
co_return 42;
}
In the above, this_is_not_a_coroutine is a regular function that happens to
return a co_task object.
When the compiler sees a coroutine control keyword, it transforms the function
definition into a coroutine function. It uses std::coroutine_traits to look
up the promise of the coroutine.
Support Concepts#
-
template<typename A>
concept awaiter# Note
This is not a real library concept, and is only for illustrative purposes
An awaiter is used at a
co_awaitexpression to control the behavior of the thatco_awaitexpression and the (possible) suspension of the enclosing coroutine.-
template<typename P>
auto await_suspend(std::coroutine_handle<P> suspender)# - Template Parameters:
P – The promise type of the coroutine that is being suspended.
When the parent coroutine suspends, it will call
await_suspend()with a handle to the coroutine that is being suspended. This give the awaiter the opportunity to reschedule the coroutine at some point in the future. Ifawait_suspend()returns a new coroutine handle \(R\), then \(R\) will be resumed after the enclosing coroutine suspends (this is known as symmetric transfer). Ifawait_suspend()returnsvoid, then control returns to the resumer of the coroutine.
-
auto await_resume()#
When the coroutine is resumed, the
await_resume()function will determine the result of theco_awaitexpression that uses the awaiter.
-
bool await_ready()#
If the
await_readyfunction returnstrue, then the coroutine will skip the call ofawait_suspend()and immediately callsawait_resume()to obtain theco_awaitresult without ever suspending the coroutine.
-
template<typename P>
-
template<typename A>
concept awaitable# An object \(A\) is awaitable if:
It has a member
operator co_await()that returns anawaiterThere is a non-member
operator co_await(A)that returns anawaiterThe object \(A\) is itself a valid
awaiter
When a
co_awaitexpression appears in a coroutine body, the compiler will attempt to obtain anawaiteraccording to the above rules.
- coroutine handle#
The handle of a coroutine is a pointer-like type accessed using
std::coroutine_handle<P>, where the template parameterPis the promise type of the coroutine, orvoidto represent a handle to a coroutine with an unknown promise type.The coroutine handle allows one to resume or destroy a coroutine. If the template argument is non-
void, then the promise object can also be obtained via the coroutine handle.
Promise Types#
- coroutine promise#
The coroutine promise implements the primary control surface for a C++ coroutine.
When a coroutine function is called, a promise object will be created automatically in an unspecified storage location (usually dynamically allocated beside the coroutine state, but may be elided). The promise will live as long as the coroutine is alive, and will be destroyed when the coroutine is destroyed. A promise object is never moved nor copied (its address is always stable).
amongocuses two main promise types:emitter_promiseandco_task<T>::promise_type.
A coroutine promise should implement the following interface:
-
template<typename P>
concept promise_interface# Note
This is not a real library concept, and is only to describe the coroutine promise interface.
-
get_return_object()#
This function will be called to get the object that is returned when a coroutine function is initially called. In the following:
my_coroutine_obj do_stuff() { co_await do_other_stuff(); }
my_coroutine_obj o = do_stuff();
The promise’s
get_return_object()is responsible for constructing themy_coroutine_objobject returned whendo_stuff()is first called.
-
static get_return_object_on_allocation_failure()#
This is required if the promise implements custom memory allocation in a way that doesn’t throw.
This will be called instead of
get_return_object()if the customoperator new()returns a null pointer.
-
void unhandled_exception()#
This function is invoked as-if within a
catchblock that encloses the entire coroutine body. It allows the coroutine to handle exceptions that escape without being handled.
-
awaiter auto initial_suspend()#
Must return an
awaiterthat acts as the initial suspend point for the coroutine. This happens before any code within the coroutine body executes. This is usuallystd::suspend_alwaysorstd::suspend_never.
-
awaiter auto final_suspend()#
Must return an
awaiterthat acts as the final suspend point for the coroutine. This happens after all code within the coroutine body executes. It runs after anyco_returnstatement or after an unhandled exception escapes.If the coroutine does not suspend at its final suspend point, then the coroutine will be immediately destroyed by the runtime and all outstanding coroutine handles are invalidated (for this reason, it is most common to always suspend at the final suspend point).
-
void return_value(auto &&x)#
This function is invoked when a
co_returnstatement is executed in the coroutine body. The parameterxis the operand to theco_returnstatement.
-
promise_interface(auto&&... args)#
-
void *operator new(std::size_t n, auto&&... args)#
-
void operator delete(void *p, std::size_t n)#
Implements dynamic memory allocation and construction for the coroutine state and promise.
nspecifies the minimum number of bytes required for the coroutine state. The argumentsargsare the arguments that are given when the coroutine function was invoked. This allowsoperator new()and the promise constructor to have access to any arguments passed to the coroutine, allowing for allocator injection and behavior customization.Important
At time of writing, GCC has a bug if the coroutine function is a non-static member function and the promise has customized
operator new(). C++ requires that the object of the member function is passed as the first argument inargs, but GCC 14 itself will crash if it encounters this situation.
-
auto get_stop_token()#
-
auto get_allocator()#
Note
These functions are not part of the standard C++ coroutine interface, but are used by
amongocto transmit stop tokens and allocators between coroutines andnanosenders.Returns the stop token and the allocator associated with the coroutine.
-
get_return_object()#
-
class coroutine_promise_allocator_mixin#
A mixin base class for promise types that implements dynamic memory allocation according to Memory Allocation.
-
struct emitter_promise : coroutine_promise_allocator_mixin#
Implements
promise_interfacefor coroutines with anamongoc_emitterdeclared return type. Only the notable members are documented below.-
unique_handler fin_handler#
This is the handler that is attached to the emitter during
amongoc_emitter_connect_handler(). This starts out as a null handler until the emitter is connected.
-
handler_stop_token get_stop_token() const#
Returns the stop token associated with
fin_handler.
-
static amongoc_emitter get_return_object_on_allocation_failure() noexcept#
Returns an emitter from
amongoc_alloc_failure().
-
void unhandled_exception() noexcept#
Implements a conversion from an unhandled exception type to an
amongoc_statusvalue that will be sent tofin_handler. Only a subset of exception types are supported, and other exceptions will cause the program to terminate.
-
awaiter auto final_suspend() noexcept#
During final suspension, the handler
fin_handlerwill be completed with the final result value for the coroutine.
-
unique_handler fin_handler#
-
template<typename T>
struct co_task<T>::finisher_base# Abstract base class that implements the behavior when a
co_taskcompletes. This allows for aco_taskto act as ananosenderor as anawaitable.-
virtual std::coroutine_handle<> on_final_suspend() = 0#
Called after final suspension of the coroutine. The returned coroutine will be resumed after the
co_taskcompletes.
-
virtual in_place_stop_token stop_token() = 0#
Obtian the stop token for use with the coroutine.
-
virtual std::coroutine_handle<> on_final_suspend() = 0#
-
template<typename T>
struct co_task<T>::promise_type : coroutine_promise_allocator_mixin# Implements the
promise_interfaceforco_taskcoroutines.-
finisher_base *_finisher#
A pointer to a concrete
finisher_baseobject that tells the coroutine how to execute.
-
in_place_stop_token get_stop_token()#
Obtains a stop token via
_finisherfinisher_base::stop_token()
-
awaiter auto final_suspend()#
Implements the final suspend point. Final suspension simply returns the coroutine handle of
finisher_base::on_final_suspend()from_finisher. The behavior of_finisherdepends on whether the task is used as ananosenderor as anawaitable:As a
nanosender, the finisher will invoke the receiver attached to the nanosender, passing it theresult_typeobject for the coroutine.As an
awaitable, the finisher will resume the coroutine that is awaiting theco_task, at which point theco_awaitwill either throw an exception or return the successful return value from theco_task.
-
finisher_base *_finisher#
Awaiting a Nanosender#
nanosender await is performed by a non-member operator co_await() that
is constrained on the nanosender concept. It returns a nanosender_awaiter.
-
template<nanosender S>
class nanosender_awaiter# Implements an
awaiterobject for ananosender\(S\).-
bool await_ready() const#
Returns
trueif-and-only-if the underlying nanosender is known to complete synchronously.
-
sends_t<S> await_resume() noexcept#
Returns the result value that was sent by the enclosed nanosender
S. This causesco_awaiton ananosenderto returnsends_tof the nanosender type.
-
template<typename Promise>
void await_suspend( - std::coroutine_handle<Promise> suspender,
Handles suspension. An internal
nanoreceiver_of<sends_t<S>>\(R\) is created, which, when invoked, will callresume()on thesuspendercoroutine. The wrapped nanosender \(S\) isconnectedto the receiver \(R\) and the resultingnanooperationis launched immediately. The operation state is stored within the awaiter.Queries on the receiver \(R\) are forwarded to the promise of
suspender. This exposes the stop token and allocator of the enclosing coroutine through the receiver \(R\), and is required for cancellation to work.
-
bool await_ready() const#
Awaiting a co_task#
Awaiting on a co_task calls a member function operator co_await on the
co_task. This returns a co_task<T>::awaiter object.
-
template<typename T>
class co_task<T>::awaiter# Implements an
awaiterobject for aco_task-
bool await_ready() const#
Always returns
false(co_taskcoroutines are lazy and never complete immediately).
-
T await_resume()#
Obtain the result of the awaited coroutine. If the awaited coroutine threw an exception, this function will re-throw that same exception.
-
template<typename P>
std::coroutine_handle<> await_suspend( - std::coroutine_handle<P> suspender,
Suspends the parent coroutine and immediately launches the awaited coroutine.
As with
nanosender_awaiter, the stop token fromPwill be passed through to the enclosingco_task
-
bool await_ready() const#