· 2 min read ·

The co_await Protocol: What Happens When a C++ Coroutine Suspends

Source: isocpp

C++ coroutines get most of their introduction coverage from the promise_type side of the equation. That makes sense: promise_type is where the most boilerplate accumulates, and it’s the first wall you hit when writing a coroutine return type. But the other half of the machinery, the awaitable protocol, is where the interesting control-flow decisions actually live.

Quasar Chunawala’s deep dive on isocpp.org, originally published in December 2025, covers both sides in detail. The awaitable section is worth examining closely because it clarifies what co_await is actually doing at each step.

The Three-Method Contract

When you write co_await expr, the compiler transforms that expression into a call sequence on an awaitable object. The awaitable needs to implement:

struct MyAwaitable {
    bool await_ready();
    void await_suspend(std::coroutine_handle<> handle);
    ReturnType await_resume();
};

await_ready runs first. If it returns true, the coroutine never suspends at all; execution continues immediately. This is the fast path for already-completed operations, like a future that’s already resolved.

If await_ready returns false, the coroutine suspends and await_suspend is called with a handle to the current coroutine. This handle is the key: you can store it, pass it to a thread pool, register it with an I/O completion callback, or schedule it on a timer. Whoever resumes the handle is whoever wakes up the coroutine. The awaitable controls that entirely.

When the coroutine eventually resumes, await_resume is called, and its return value becomes the result of the co_await expression.

What This Looks Like in Practice

Consider writing a simple awaitable that defers to a thread pool:

struct ThreadPoolAwaitable {
    ThreadPool& pool;

    bool await_ready() { return false; }

    void await_suspend(std::coroutine_handle<> h) {
        pool.post([h]() mutable { h.resume(); });
    }

    void await_resume() {}
};

The coroutine suspends, hands its handle to the thread pool via a lambda, and that lambda resumes execution on whatever thread the pool assigns. The calling code sees none of this; it just sees a co_await that yields to the pool and picks up where it left off.

For a Discord bot handling gateway events, this pattern maps cleanly onto async I/O: suspend on a WebSocket read, resume when data arrives. The coroutine holds its local state in the heap-allocated frame, so nothing is lost across the suspension.

The Lifetime Implication

The part Chunawala’s article gets right is the emphasis on what happens to the coroutine handle after await_suspend. The handle represents live state on the heap. If nothing resumes it and nothing destroys it, that memory leaks. If something resumes a handle after the coroutine’s return object has been destroyed, you get undefined behavior.

This is why higher-level libraries wrap this machinery carefully. Writing raw awaitables is powerful, but the lifetime contracts require attention that promise_type boilerplate alone does not enforce.

The protocol is not complicated once you map each method to its role in the lifecycle. The setup cost is front-loaded, and the payoff is full control over when and how your coroutines move between execution contexts.

Was this interesting?