<< Home

Chapter 22: Coroutines

Coroutines in C++ suspend and resume functions at well defined points so that work can be interleaved without extra threads. The language keywords co_await, co_yield, and co_return pair with user types that model the protocol. This chapter explains the building blocks, then shows how to design libraries that feel natural and safe.

The coroutine model and promise types

When a function uses co_await, co_yield, or co_return, the compiler transforms it into a state machine. The return type defines an inner promise_type that controls creation, suspension, completion, and exception flow. A std::coroutine_handle<P> is the opaque control handle for a coroutine whose promise is P.

Lifecycle and involved types

The transformed function allocates a coroutine frame that stores locals and bookkeeping. The promise is constructed first; the initial suspend point runs next; then the body executes and may suspend many times. On final suspend the coroutine either destroys itself or stays alive until the handle is destroyed. Exceptions are routed to promise_type::unhandled_exception.

ElementRole
return_objectConcrete object returned by the coroutine function
initial_suspend()Choose to start suspended or run immediately
final_suspend()Choose to suspend at end so observers can react
get_return_object()Construct the object the caller receives
return_void or return_valueSupply the result at completion
unhandled_exception()Capture exceptions to deliver later

A minimal generator<T> shell

This sketch shows the promise hooks and how co_yield communicates a value. Real implementations add iteration support and memory management; the outline highlights the moving parts.

#include <coroutine>
#include <optional>

template <class T>
struct generator {
  struct promise_type {
    std::optional<T> current;

    generator get_return_object() { return generator{handle_type::from_promise(*this)}; }
    std::suspend_always initial_suspend() noexcept { return {}; }
    std::suspend_always final_suspend() noexcept { return {}; }
    std::suspend_always yield_value(T v) noexcept { current = std::move(v); return {}; }
    void return_void() noexcept {}
    void unhandled_exception() { ex = std::current_exception(); }

    std::exception_ptr ex{};
  };

  using handle_type = std::coroutine_handle<promise_type>;

  explicit generator(handle_type h) : h(h) {}
  generator(generator&& o) noexcept : h(std::exchange(o.h, {})) {}
  ~generator() { if (h) h.destroy(); }

  bool next() {
    if (!h || h.done()) return false;
    h.resume();
    if (h.promise().ex) std::rethrow_exception(h.promise().ex);
    return !h.done();
  }

  T& value() { return *h.promise().current; }

private:
  handle_type h{};
};
πŸ’‘ The return type owns the std::coroutine_handle by policy. Decide who calls destroy() and document that clearly.

Handles and frame ownership

A handle is a lightweight pointer to the frame; it does not own memory by itself. Destruction policy lives outside the handle. Choose one of two styles: the return object destroys in its destructor, or an external scheduler stores handles and controls lifetime. Both styles are valid when the contract is explicit.

Generators and async workflows

Generators produce a sequence lazily with co_yield. Async workflows represent pending results that complete later. Both are coroutines, yet they use different promise contracts and await behaviors.

Iterating a generator

A friendly generator supports range based for. The iterator adaptor drives resume() on increment and reads the yielded value on dereference. The example emits squares on demand.

#include <coroutine>
#include <utility>

generator<int> squares(int n) {
  for (int i = 1; i <= n; ++i) {
    co_yield i * i;
  }
}

A simple task<T> for async

An async task holds either a result or an exception and can be awaited by other coroutines. The await_suspend hook decides how to schedule the continuation; for now we resume inline to keep the example compact.

#include <coroutine>
#include <exception>
#include <optional>

template <class T>
struct task {
  struct promise_type {
    std::optional<T> value;
    std::exception_ptr ex;

    task get_return_object() { return task{handle::from_promise(*this)}; }
    std::suspend_always initial_suspend() noexcept { return {}; }
    std::suspend_always final_suspend() noexcept { return {}; }
    void return_value(T v) noexcept { value = std::move(v); }
    void unhandled_exception() noexcept { ex = std::current_exception(); }
  };

  using handle = std::coroutine_handle<promise_type>;
  explicit task(handle h) : h(h) {}
  task(task&& o) noexcept : h(std::exchange(o.h, {})) {}
  ~task() { if (h) h.destroy(); }

  bool await_ready() const noexcept { return false; }
  void await_suspend(std::coroutine_handle<> cont) noexcept { cont.resume(); }
  T await_resume() {
    if (h.promise().ex) std::rethrow_exception(h.promise().ex);
    return std::move(*h.promise().value);
  }

private:
  handle h{};
};

task<int> compute() {
  co_return 42;
}
⚠️ Resuming continuations inline is only for teaching. Real systems hand off to a scheduler so that blocking work does not wedge the caller.

Composing async coroutines

Awaiting a task<T> yields a value and propagates exceptions. This makes request pipelines read like straight line code. Add timeouts, cancellation, and retries around awaits to complete the story.

task<int> doubled() {
  int v = co_await compute();
  co_return v * 2;
}

Awaitables and schedulers

An awaitable is any type that provides the await trio: await_ready, await_suspend, and await_resume. A scheduler decides where and when a suspended coroutine continues. Together they route work to threads, event loops, or I/O completion ports.

The awaitable contract

The contract is small and powerful. If await_ready returns true, the coroutine does not suspend. Otherwise await_suspend receives the awaiting handle and can arrange resumption later. await_resume returns the result or throws to signal failure.

struct immediate_int {
  int v;
  bool await_ready() const noexcept { return true; }
  void await_suspend(std::coroutine_handle<>) noexcept {}
  int await_resume() const noexcept { return v; }
};

Posting continuations to a thread pool

Schedulers usually keep a queue of continuations. When an event fires, the scheduler calls resume() on the saved handle from a worker thread. The small adapter below posts the continuation through a user supplied function.

#include <functional>

struct post_awaitable {
  std::function<void(std::coroutine_handle<>)> post;

  bool await_ready() const noexcept { return false; }
  void await_suspend(std::coroutine_handle<> h) const { post(h); }
  void await_resume() const noexcept {}
};
πŸ’‘ A scheduler can be passed by reference, pointer, handle, or captured in the awaitable. Pick one convention and keep it consistent across your codebase.

Bridging callbacks to co_await

Legacy APIs signal completion via callbacks. Wrap them by capturing a std::coroutine_handle<> and resuming it inside the callback. Deliver results through shared state that await_resume reads. Add an atomic flag to guard against double resume and to handle cancellation correctly.

Designing coroutine friendly libraries

Libraries should expose ergonomic types that fit the coroutine protocol and integrate with scheduling. Focus on clear ownership, predictable cancellation, structured completion, and explicit context switching. Prefer value oriented APIs with small awaitables over giant framework objects.

Ownership and cancellation

Decide who owns the coroutine frame and who can cancel. Provide a token or stop source that propagates through nested awaits. When cancelled, complete the awaitable promptly and report a distinct error so callers can separate cancellation from failure.

Error handling and results

Coroutines can propagate exceptions through await_resume, or they can return tagged results such as expected<T,E>. Choose one style for a library and apply it everywhere; mixing strategies increases cognitive load.

Scheduler aware APIs

Accept an execution context parameter or capture one at construction. Document where continuations run. Many bugs arise from code that resumes on surprising threads. Provide schedule() or via(...) helpers to move execution between contexts when needed.

Interoperability contracts

Ensure awaitables are await-transform friendly. Provide operator co_await so callers can customize behavior. Keep awaiters trivially movable and noexcept where possible so that compilers generate lean state machines.

Testing and diagnostics

Add deterministic tests by using fake schedulers that run continuations in a controlled order. Log suspend and resume with handle addresses to trace flows. Provide counters for posted tasks, in flight operations, and cancellations to spot leaks early.

Coroutines offer direct expression of asynchronous logic with fewer layers. With clear ownership, simple awaitables, and scheduler aware design, coroutine based code stays readable and efficient.