Uni20
Asynchronous tensor and runtime library.
Loading...
Searching...
No Matches
C++ Coroutine Primer (For Uni20 Async)

This is a short primer for contributors who have not used C++20/23 coroutines before. It explains just enough to be productive in src/uni20/async/ and tests/async/.

What a Coroutine Is (In Practice)

A coroutine is a function that can suspend and later resume. In C++ that happens when the function body uses co_await, co_yield, or co_return.

For Uni20 async code you will mostly see:

  • co_await: suspend until some async precondition is satisfied
  • co_return: finish the coroutine

When a coroutine suspends, its state (locals, parameters, current line) lives in a coroutine frame allocated by the compiler/runtime. It does not live on the caller's stack.

What AsyncTask Means

In Uni20, an AsyncTask is the move-only owner of a coroutine handle.

Key consequences:

  • Before admission, the AsyncTask owns the initially suspended coroutine and the scheduler has no ownership or registration for it. A scheduler pointer in the promise is only the route for a later activation.
  • Scheduling consumes the AsyncTask and transfers ownership to one runnable scheduler activation.
  • While the coroutine body is running, there is no external owning task token.
  • When you co_await an awaitable in this system, Uni20 passes ownership of the current task to that awaitable. The awaitable either reschedules the task later, or transfers control to a nested task.
  • A coroutine suspended on a buffer, resource, or external event is owned by that awaiter. It is not retained by the scheduler while it waits.

This is why Uni20 awaiters take the promise-neutral internal BasicTask ownership token rather than directly retaining raw std::coroutine_handle<> values. The debug task registry records state and dependency metadata but does not participate in frame ownership.

Suspension registration cannot throw

An awaiter that accepts BasicTask or TaskFactory in await_suspend(...) must declare that operation noexcept. The argument transfers ownership of the already-suspended coroutine into the awaiter. If registration threw after moving that ownership, the runtime could not generally recover every ownership claim or undo externally visible waiter registration.

This restriction applies only after ownership transfer begins. A composite awaiter may still perform fallible preparation before acquiring a TaskFactory. Failures discovered when an operation later resumes belong in await_resume(), where normal coroutine exception propagation remains valid.

Nested Coroutine Tasks

An AsyncTask may directly co_await another AsyncTask. An unbound inner coroutine inherits the outer coroutine's scheduler. An inner coroutine already bound to the same scheduler runs through symmetric transfer; one bound to a different scheduler is submitted there and returns the suspended outer continuation to its original scheduler when it finishes. This shared-runtime routing is implemented behavior, not a future CUDA facility.

AsyncTask and CudaTask are distinct initial-admission types with distinct promise types. Their promises derive from the common TaskPromiseBase, and all suspended tasks use the same promise-neutral TaskHandle identity and BasicTask ownership machinery. A CPU scheduler therefore cannot initially admit a CudaTask, while buffer awaiters, rescheduling, and continuations remain shared. Promise type identifies the task kind declared by the coroutine; the scheduler recorded in the common promise routes each later activation.

An AsyncTask can await a CudaTask already bound to a CUDA scheduler. The CUDA child runs there and the parent returns to its own scheduler after the child finishes. A live coroutine does not change concrete task type; explicit scheduler migration changes only the non-owning route of an exclusively owned, suspended task. The internal mechanism is implemented; a user-facing migration awaiter remains future work.

See Scheduler Routing and Task Domains for the task admission and continuation contracts.

What co_await on a Buffer Does

Buffers are the bridge between data dependencies and coroutine suspension:

  • ReadBuffer<T> is an awaitable gate for reading the value of an Async<T> at a particular epoch.
  • WriteBuffer<T> is an awaitable gate for exclusive read/write access at a particular epoch.

When you write:

int v = co_await reader;

the coroutine suspends until the epoch becomes readable, then resumes and returns the value.

Similarly:

co_await writer = v;

suspends until the epoch becomes writable, then writes through the proxy returned by co_await writer.

The "Static + Captureless" Rule (Important)

Coroutine lambdas that return AsyncTask must be:

  • captureless
  • static (C++23 static lambda call operator)

Correct pattern:

schedule([](ReadBuffer<int> in, WriteBuffer<int> out) static -> AsyncTask {
int v = co_await in;
co_await out = v;
co_return;
}(a.read(), b.write()));

Why this exists:

  • Captured values live in the lambda closure object, which often lives on the caller stack.
  • The coroutine frame can outlive that closure object.
  • If a coroutine suspends, any captured references (and sometimes captured values) can become dangling.

Passing everything as parameters ensures the data lives in the coroutine frame and has the right lifetime.

Where to Look in the Code

If you are debugging or trying to understand exact behavior, these files are the usual starting points:

Tests under tests/async/ encode current behavior. If they disagree with the canonical async contract, treat that as a defect or unresolved semantic conflict rather than assuming the test is automatically authoritative.