Coroutine API¶
See also
For complete class and member documentation, see the API Reference.
C++20 coroutine primitives for asynchronous task execution. All classes are in the dftracer::utils::coro namespace.
For usage examples and task scheduling, see Pipeline Guide and Task System.
Note
GCC 12 may corrupt large coroutine frames at -O2 and above, especially
when frames contain references, string_view, or captured lambdas. The
project mitigates this by heap-allocating per-task state in a
shared_ptr (or unique_ptr) and capturing only the smart pointer in
coroutine lambdas, instead of capturing complex state by value. New
coroutines should follow the same pattern; see coroutine-caveats.md at
the repo root for the full discussion.
graph TD
Coro["Coro\nfire-and-forget primitive"]
CoroTask["CoroTask<T>\nawaitable task result"]
SpawnFuture["SpawnFuture<T>\nresult of CoroScope::spawn()"]
JoinHandle["JoinHandle\nstructured join barrier"]
Channel["Channel<T>\nasync producer/consumer queue"]
Producer["ChannelProducer / ProducerGuard\nproducer lifetime management"]
Generator["Generator<T>\nsynchronous lazy sequence"]
AsyncGenerator["AsyncGenerator<T>\nasynchronous lazy sequence"]
Yield["yield() / maybe_yield()\ncooperative scheduling"]
CoroTask --> Coro
SpawnFuture --> CoroTask
JoinHandle --> Coro
Channel --> Producer
CoroTask --> Channel
Coro --> Yield
CoroTask --> Yield
AsyncGenerator --> CoroTask
Generator --> Coro
Coro¶
Lightweight fire-and-forget coroutine type with no return value.
Coro is the internal execution primitive used by the runtime. Unlike CoroTask<T>, Coro:
Has no return value (communicate through channels)
Has no continuation chain (flat scheduling)
Integrates with JoinHandle for structured concurrency
Automatically manages its own lifetime via release() semantics
Users typically interact with Task and CoroScope instead of using Coro directly.
JoinHandle¶
Stack-allocated join barrier for coordinating multiple Coro instances.
Uses a thread-safe counter pattern to synchronize completion of a group of coroutines. When all tracked coroutines complete, the awaiter is resumed via symmetric transfer.
Stack-bound lifetime: JoinHandle is non-copyable, non-movable, and must outlive all tracked coroutines.
Usage example:
JoinHandle jh;
jh.track(coro1);
jh.track(coro2);
// ... enqueue coroutines to executor ...
co_await jh.join(); // suspends until all tracked coroutines complete
SpawnFuture¶
Future returned by CoroScope::spawn() for all coroutines (both void and typed).
spawn() always returns SpawnFuture<T> (where T is void for void coroutines).
The future is awaitable and suspends the caller until the spawned coroutine completes,
then returns the typed result. It uses a lock-free shared state (SharedState<T>) with one
heap allocation per spawn.
For fire-and-forget usage, the return value can simply be discarded.
Awaitable interface:
await_ready()- Returns true if result is already availableawait_suspend()- Registers the awaiter to be resumed on completionawait_resume()- Returns the result or re-throws any exceptionis_done()- Check if the spawned coroutine has completed without blockingdetach()- Prevent automatic resumption (used by when_any)
Usage examples:
// Await a typed spawn directly:
int result = co_await scope.spawn([](CoroScope& s) -> CoroTask<int> {
co_return 42;
});
// Or capture the future for later:
SpawnFuture<int> future = scope.spawn([](CoroScope& s) -> CoroTask<int> {
co_return 42;
});
int result = co_await future;
// Await a void spawn:
co_await scope.spawn([](CoroScope& s) -> CoroTask<void> {
// caller suspends until this completes
co_return;
});
// Fire-and-forget (discard the SpawnFuture):
scope.spawn([](CoroScope& s) -> CoroTask<void> {
co_return;
});
Yield Primitives¶
Control coroutine scheduling and timeslice behavior.
yield() unconditionally suspends and re-enqueues the coroutine on the executor.
maybe_yield() conditionally yields only if the current thread’s timeslice has been exceeded.
This is a low-cost operation (~25ns clock read) when the timeslice is not exceeded.
Timeslice management:
reset_timeslice()- Reset the current thread’s timeslice clock to nowtimeslice_exceeded()- Check whether the current thread has exceeded its timesliceset_timeslice_duration()- Set the timeslice duration for the current threadget_timeslice_duration()- Get the timeslice duration for the current threadDEFAULT_TIMESLICE- Default timeslice duration (10ms)
Usage example:
for (auto& item : large_dataset) {
process(item);
co_await maybe_yield(); // Yield only if timeslice exceeded
}
// Custom timeslice configuration
set_timeslice_duration(std::chrono::milliseconds(5));
for (auto& chunk : process_chunks()) {
process_chunk(chunk);
co_await maybe_yield(); // Yields after 5ms instead of 10ms
}
CoroTask¶
User-facing coroutine task type with awaitable interface and combinators.
CoroTask<T> supports:
Awaiting to get the result of type T
Composing with channel operations
Exception propagation
Integration with the task scheduler
Usage example:
CoroTask<int> compute_value() {
co_return 42;
}
CoroTask<void> use_value() {
int result = co_await compute_value();
// result == 42
co_return;
}
Channel¶
Thread-safe producer-consumer queue for streaming data between tasks.
Channel<T> supports bounded capacity, async send/receive, and producer tracking. Multiple producers can register themselves, and the channel automatically closes when the last producer exits (via ProducerGuard RAII).
Features:
Bounded or unbounded capacity
Async send() and receive() with awaitable interface
ProducerGuard RAII for automatic close on producer exit
Producer pre-registration for CoroScope spawn patterns
Thread-safe waiters queue for back-pressure coordination
Usage example:
auto channel = make_channel<int>(100);
// Producer: channel->producer() increments the producer count
// immediately, then .guard() adopts the slot for RAII cleanup.
auto task = make_task(
[ch = channel->producer()](CoroScope& scope) mutable
-> CoroTask<void> {
auto guard = ch.guard();
for (int i = 0; i < 10; ++i) {
co_await ch.send(i);
}
// ~ProducerGuard auto-releases; channel closes when last exits
co_return;
}, "Producer");
// Consumer: receive until channel closes
while (auto value = co_await channel->receive()) {
std::cout << *value << "\n"; // value is std::optional<int>
}
Multiple producers pattern:
auto channel = make_channel<Chunk>(0);
// channel->producer() increments the count eagerly on the caller's
// thread, so the channel never transiently sees zero producers
// while coroutines are still being scheduled.
for (std::size_t i = 0; i < 4; ++i) {
scope.spawn([ch = channel->producer(),
i](CoroScope& s) mutable -> CoroTask<void> {
auto guard = ch.guard();
for (auto chunk : read_my_chunks(i)) {
co_await ch.send(std::move(chunk));
}
// ~ProducerGuard releases the slot; channel closes when all exit
});
}
ChannelProducer / ChannelConsumer¶
ChannelProducer<T> and ChannelConsumer<T> are lightweight capture-safe
handles obtained from channel->producer() and channel->consumer(). They
are the idiomatic way to hand a channel to a coroutine lambda: the handle can be
captured by value into a spawn/make_task closure and (for shared_ptr
channels) keeps the channel alive for as long as the coroutine runs.
ChannelProducer increments the producer count eagerly in its constructor, so
the channel never transiently observes zero producers while coroutines are still
being scheduled. Inside the coroutine body, call guard() once to adopt the
pre-registered slot as a ProducerGuard (RAII); the channel closes when the
last producer’s guard is destroyed. send(item) forwards to the channel.
ChannelConsumer is copyable and exposes receive() (an awaitable that
yields std::optional<T>, empty once the channel is closed and drained).
auto channel = make_channel<Chunk>(0);
// Producer side: capture the handle, adopt the guard, send, then exit.
scope.spawn([ch = channel->producer()](CoroScope& s) mutable
-> CoroTask<void> {
auto guard = ch.guard();
for (auto chunk : read_chunks()) {
co_await ch.send(std::move(chunk));
}
}); // ~ProducerGuard releases the slot
// Consumer side: receive until the channel closes.
scope.spawn([ch = channel->consumer()](CoroScope& s) -> CoroTask<void> {
while (auto item = co_await ch.receive()) {
process(*item); // item is std::optional<Chunk>
}
});
Generator¶
Synchronous lazy sequence generator using co_yield.
Usage example:
Generator<int> fibonacci(int n) {
int a = 0, b = 1;
for (int i = 0; i < n; ++i) {
co_yield a;
auto next = a + b;
a = b;
b = next;
}
}
// Lazy iteration - only computes values as needed
for (int fib : fibonacci(10)) {
std::cout << fib << " "; // 0 1 1 2 3 5 8 13 21 34
}
AsyncGenerator¶
Asynchronous lazy sequence generator for async iteration.
Allows coroutines to produce a sequence of values asynchronously.
Use co_await gen.next() to await the next value.
Usage example:
AsyncGenerator<std::string> read_lines(const std::string& path) {
auto fd = co_await io::async_open(path.c_str(), O_RDONLY);
std::string line;
while (co_await io::async_readline(fd, line)) {
co_yield line;
}
}
// Async iteration - each line read asynchronously
auto gen = read_lines("data.txt");
while (auto line = co_await gen.next()) {
process(*line);
}
when_all¶
Wait for all awaitables to complete.
Suspends until all provided awaitables have completed, then returns their results as a tuple.
Usage example:
// Race multiple tasks and wait for all to complete
auto [result_a, result_b, result_c] = co_await when_all({
compute_async_a(),
compute_async_b(),
compute_async_c()
});
// Or with a vector of awaitables
std::vector<CoroTask<int>> tasks;
for (int i = 0; i < 10; ++i) {
tasks.push_back(compute_async(i));
}
auto results = co_await when_all(std::move(tasks));
// results is std::vector<int>
when_any¶
Race multiple awaitables, return first to complete.
Suspends until at least one awaitable completes, then returns the index and result of the first one.
Usage example:
// Race three I/O operations, use whichever completes first
auto result = co_await when_any({
io::async_read(cache_fd, buf, len),
io::async_read(disk_fd, buf, len),
io::async_read(network_fd, buf, len)
});
// result.index tells which completed first
switch (result.index) {
case 0:
std::cout << "Cache hit\n";
break;
case 1:
std::cout << "Local disk\n";
break;
case 2:
std::cout << "Network fetch\n";
break;
}
process(result.result);
Heterogeneous when_all and when_any¶
The variadic overloads of when_all and when_any accept awaitables of
different types. Return types are deduced automatically: when_all returns a
std::tuple of each awaitable’s result type, and when_any returns a
WhenAnyTupleResult with index-based get<N>() access to the winning
result. void results map to std::monostate in both cases.
Heterogeneous when_all:
// Wait for tasks returning different types
auto f_int = scope.spawn([](CoroScope&) -> CoroTask<int> {
co_return 42;
});
auto f_str = scope.spawn([](CoroScope&) -> CoroTask<std::string> {
co_return std::string("hello");
});
// Returns std::tuple<int, std::string>
auto [num, text] = co_await when_all(std::move(f_int), std::move(f_str));
Void handling in when_all:
// Void results map to std::monostate in the tuple
auto f_void = scope.spawn([](CoroScope&) -> CoroTask<void> {
co_return;
});
auto f_int = scope.spawn([](CoroScope&) -> CoroTask<int> {
co_return 99;
});
// Returns std::tuple<std::monostate, int>
auto [_, val] = co_await when_all(std::move(f_void), std::move(f_int));
Heterogeneous when_any:
// Race tasks returning different types
auto f_int = scope.spawn([](CoroScope&) -> CoroTask<int> {
co_return 42;
});
auto f_str = scope.spawn([](CoroScope&) -> CoroTask<std::string> {
co_return std::string("hello");
});
// Returns WhenAnyTupleResult - use get<N>() for index-based access
auto result = co_await when_any(std::move(f_int), std::move(f_str));
// result.index tells which awaitable completed first (0-based).
// get<N>() works correctly even when types repeat.
if (result.index == 0) {
int val = result.get<0>();
} else {
std::string val = result.get<1>();
}
result.cancel_remaining();
Overload resolution:
The correct overload is selected automatically via requires constraints:
All arguments share the same type -> homogeneous (vector-based) overload, returning
std::vector<T>orWhenAnyResult<T>.Arguments have different types -> heterogeneous overload, returning
std::tuple<...>orWhenAnyTupleResult<...>(withget<N>()access).
No explicit template arguments are needed; the compiler resolves the overload based on the argument types.
AsyncMutex¶
Lock-free async mutex for coroutines. Ownership is not tied to any thread - a coroutine holding the lock can migrate freely. Waiting coroutines suspend without blocking the OS thread and are resumed in approximate FIFO order.
Used by the reorganization pipeline for serializing writes to shared
ChunkWriter instances across parallel event routing coroutines.
#include <dftracer/utils/core/coro/async_mutex.h>
AsyncMutex mutex;
// Manual lock/unlock
co_await mutex.lock();
co_await writer.write_line(data);
mutex.unlock();
// RAII release via AsyncMutexGuard (recommended). Acquire the lock,
// then adopt the AsyncMutex into a guard that unlocks on scope exit.
{
co_await mutex.lock();
AsyncMutexGuard guard(mutex);
co_await writer.write_line(data);
} // ~AsyncMutexGuard calls mutex.unlock()
// Non-blocking try_lock
if (mutex.try_lock()) {
// acquired
mutex.unlock();
}
API surface:
lock()- returnsAsyncMutexLockOperation(awaitable); acquires without suspending when uncontended, otherwise suspends until the lock is free.unlock()- release the lock and resume the next waiter (approx. FIFO).try_lock()- non-blocking attempt; returnstrueon acquisition.
AsyncMutexGuard is a move-only RAII wrapper (constructed from an already
locked AsyncMutex&) whose destructor calls unlock(). It does not acquire
the lock itself - co_await mutex.lock() first.
TimeoutAwaitable¶
Timeout awaitable for use with when_any.
Allows racing a task against a timeout to implement deadline-based cancellation.
Usage example:
using namespace std::chrono_literals;
auto& timer_service = executor->get_timer_service();
auto result = co_await when_any({
slow_operation(),
timeout(5s, &timer_service)
});
if (result.index == 1) {
std::cerr << "Operation timed out\n";
} else {
process(result.result);
}
Promise Types¶
The framework defines the coroutine promise types directly; users rarely name
them, but they are the machinery behind the awaitable types above. Every promise
routes its frame allocation through ObjectPool to avoid per-coroutine
operator new calls.
PromiseBase (task.h) is the shared base of the CoroTask<T> promise.
It holds the continuation handle, the owning Executor / Scheduler, an
optional cancellation token, and a pointer to the root promise used for
task-graph accounting. CoroTask’s promise adds typed result storage on top
(via return_value / return_void).
CoroPromise (coro.h) is the promise type for Coro (fire-and-forget).
It carries the join-group counter and continuation pointers used by
JoinHandle, the owning Executor, a TaskIndex, and a released
flag that tells the final-suspend awaiter to schedule deferred frame
destruction. It has no result slot - Coro communicates through channels.
// You write ordinary coroutine functions; the compiler picks the promise:
CoroTask<int> typed(); // promise derives from PromiseBase, stores int
Coro fire(); // promise is CoroPromise, no return value
FireAndForget¶
Minimal self-destroying coroutine type used internally by the when_all /
when_any wrappers. Its promise never suspends (std::suspend_never at both
initial and final suspend), returns void, and calls std::terminate() on an
unhandled exception (wrappers are written to never throw). Like the other
promises, its frame is allocated from ObjectPool.
It has no user-facing awaitable interface; it exists so a wrapper coroutine can
run to completion and free its own frame without anyone awaiting it. Prefer
Coro or SpawnFuture for application-level fire-and-forget work.
CompletionLatch¶
Single-word atomic that resolves the suspend-vs-complete race for a group
awaitable: the awaiter setting its “suspended” bit and a child setting the
“completed” bit both fetch_or into one atomic, and whichever side observes
the other’s bit already set is the one that resumes the awaiting coroutine.
Because both operate on a single modification order, there is no lost or double
wakeup.
Two members return true when this side won the race and must resume the
continuation:
bool on_suspended() noexcept; // awaiter has suspended
bool on_completed() noexcept; // a child has completed
The free function resume_continuation(Executor*, std::coroutine_handle<>)
resumes the continuation through the executor when present (else inline),
guarding a null or already-done handle. This is an internal building block for
when_all / when_any; application code does not use it directly.
CompletionState¶
Reusable base bundling the state that when_all and when_any share: the
awaiting coroutine handle, its Executor, and a CompletionLatch. Its
mark_suspended_and_check_completion() is called from await_suspend after
deciding to suspend; if the latch reports completion already happened, it
resumes the continuation.
Two derived states implement the two combinators:
WhenAllCompletionState- all-of accounting; resumes only after every child completes, recording the first exception observed (on_one_complete()/on_exception()).WhenAnyCompletionState- first-of accounting; resumes on the first child to complete (on_first_complete()).
These are internal to the when_all / when_any implementations; use the
combinators, not these states, from application code.