C++20 Coroutines Explained: Trace Suspension, Resumption and Frame Lifetime

Trace one framework-free C++20 coroutine from lazy creation through event suspension, inline resumption, final suspension, and owner-controlled frame destruction.

KnowledgeGate Team

Exam prep & CS education

Updated 4 Nov 20266 min read
C++20 Coroutines Explained: Trace Suspension, Resumption and Frame Lifetime

Framework tasks and generators can make co_await look like a pause button. Bugs appear when you cannot say who owns the paused call, what remains alive, or which line resumes it. A framework-free coroutine stores an event handle and moves from value=10 to value=15, then resumes at value=30 before final suspension and destruction. An await may suspend or continue immediately; control returns according to the awaiter, and the frame may be destroyed only by its owner.

1. C++20 coroutines are resumable functions, not threads

A coroutine preserves its state while suspending and later resuming. The co_await and co_return make job a coroutine, which the compiler rewrites as a state machine connected to a promise and return object.

Keep four boundaries clear:

  • A coroutine is not a thread. It runs on the calling or resuming thread unless an awaiter transfers execution.

  • co_await does not mean blocking, and it does not always mean suspension.

  • A coroutine promise is not automatically a JavaScript promise or a future.

  • A coroutine handle is a non-owning control token unless a wrapper gives it an ownership policy.

Functions, classes, RAII, move-only ownership, and basic templates are the prerequisites. The Coding & DSA route provides the broader context.

2. A coroutine call creates state before its body runs

The owning return type is:

cpp
#include <coroutine>
#include <exception>
#include <iostream>
#include <utility>

struct TraceTask {
    struct promise_type;
    using handle_type = std::coroutine_handle<promise_type>;

    struct promise_type {
        TraceTask get_return_object() {
            std::cout << "promise: get_return_object\n";
            return TraceTask{handle_type::from_promise(*this)};
        }

        std::suspend_always initial_suspend() noexcept {
            std::cout << "promise: initial_suspend\n";
            return {};
        }

        std::suspend_always final_suspend() noexcept {
            std::cout << "promise: final_suspend\n";
            return {};
        }

        void return_void() noexcept {}
        void unhandled_exception() noexcept { std::terminate(); }
    };

    handle_type h{};

    explicit TraceTask(handle_type handle) : h(handle) {}
    TraceTask(TraceTask&& other) noexcept
        : h(std::exchange(other.h, {})) {}
    TraceTask(const TraceTask&) = delete;

    ~TraceTask() {
        if (h) {
            std::cout << "task: destroy frame\n";
            h.destroy();
        }
    }

    void resume() {
        if (h && !h.done()) {
            h.resume();
        }
    }
};

A call creates coroutine state, preserves its parameters, constructs promise_type, calls get_return_object(), and awaits initial_suspend(). Because that returns suspend_always, the lazy coroutine returns its TraceTask before executing job. Here TraceTask::h owns the frame. With suspend_never, the body would begin during the call. An exception escaping the body reaches unhandled_exception(), which terminates here. If classes or RAII are unfamiliar, first review C++ Tutorial: The Complete Learning Path from First Program to STL.

3. co_await asks three questions in a fixed order

First, await_ready() decides whether to skip suspension. If it returns false, the coroutine records its resume point and calls await_suspend(handle). On resumption, await_resume() supplies the co_await expression's result, here void.

cpp
struct ManualEvent {
    bool ready = false;
    std::coroutine_handle<> waiter{};

    bool await_ready() const noexcept {
        std::cout << "event: await_ready="
                  << std::boolalpha << ready << '\n';
        return ready;
    }

    void await_suspend(std::coroutine_handle<> handle) noexcept {
        waiter = handle;
        std::cout << "event: await_suspend stored handle\n";
    }

    void await_resume() const noexcept {
        std::cout << "event: await_resume\n";
    }

    void set() {
        std::cout << "event: set\n";
        ready = true;
        if (waiter) {
            auto handle = std::exchange(
                waiter, std::coroutine_handle<>{});
            handle.resume();
        }
    }
};

waiter does not own the frame. set() clears it before resuming, so the stored handle cannot be resumed twice. A void return from await_suspend leaves the coroutine suspended. A bool keeps it suspended on true and resumes it on false; a returned std::coroutine_handle<> transfers execution to that coroutine. Here, set() synchronously resumes the stored handle.

4. C++20 coroutine worked trace: 10 becomes 15, pauses, then becomes 30

The external ManualEvent is declared before TraceTask, so it outlives the task in this program. That lifetime is required.

cpp
TraceTask job(ManualEvent& event) {
    int value = 10;
    std::cout << "job: A value=" << value << '\n';
    value += 5;
    co_await event;
    std::cout << "job: B value=" << value << '\n';
    value *= 2;
    std::cout << "job: C value=" << value << '\n';
    co_return;
}

int main() {
    ManualEvent event;
    std::cout << "main: call job\n";
    auto task = job(event);
    std::cout << "main: task created\n";
    task.resume();
    std::cout << "main: first resume returned\n";
    event.set();
    std::cout << "main: event set returned\n";
}

Running the program prints:

Code
main: call job
promise: get_return_object
promise: initial_suspend
main: task created
job: A value=10
event: await_ready=false
event: await_suspend stored handle
main: first resume returned
event: set
event: await_resume
job: B value=15
job: C value=30
promise: final_suspend
main: event set returned
task: destroy frame

Creation stops at initial_suspend, so job: A cannot appear before task.resume(). That first resume starts with 10, computes 10 + 5 = 15, finds the event unready, stores the handle, and returns to main. Then event.set() resumes inline. await_resume, B value=15, 15 * 2 = 30, and final_suspend all happen before main: event set returned.

Two-lane coroutine timeline showing main and job execution, values changing from 10 to 15 to 30, with suspension, resumption, and frame destruction.

5. The coroutine frame preserves live state, but the handle does not own it

Conceptually, the frame holds the promise, parameters, resume point, and locals alive across suspension; its layout is compiler-specific. Here event remains external while local value=15 survives the event suspension.

By policy, task.h owns the frame; while job is suspended, event.waiter is a non-owning alias. At co_return, return_void() runs, automatic locals end, and final_suspend() preserves the completed frame for its owner. Although h.done() is true, TraceTask must still call h.destroy().

destroy() is a lifetime operation for a suspended coroutine: it destroys remaining state and releases storage. A compiler may elide a separate dynamic allocation, so the frame is not necessarily heap-allocated.

Four ownership snapshots show task.h retaining the coroutine frame, event.waiter becoming a non-owning reference, value reaching 15, then output reaching 30 before destruction.

6. Suspension traps to understand before a framework hides them

Mistaken assumption

What the trace proves

Safe rule

Calling a coroutine always runs its body

Lazy initial_suspend delays job: A

Inspect initial_suspend

co_await always suspends

A ready event would skip await_suspend

Start with await_ready

Resumption automatically changes thread

event.set() resumes inline

The resumer's thread continues unless transfer is arranged

A non-null handle is always resumable

A done coroutine still has a non-null handle

Never call resume() on a done coroutine

Final suspension destroys the frame

suspend_always leaves destruction to task

Give one owner the destruction duty

A stored waiter handle owns the frame

event.waiter only points to the frame

Clear waiters before their frame can die

Destroying task while event.waiter points to its frame leaves a dangling handle. Cancellation must clear external waiters, and two owners must never destroy one frame. Avoid holding a mutex or borrowing a short-lived object across an unknown suspension because other code may run before continuation.

By contrast, virtual dispatch selects an implementation for a call; a coroutine preserves and resumes one invocation. Object Oriented Technology Explained: Four Pillars, Worked Java Examples, GATE and Interview Patterns covers virtual dispatch.

7. How interviews and exam-style traces test coroutine suspension

Concept checks ask which promise hooks precede the body, what follows job(event), whether await_suspend runs, which value survives, and who destroys a frame at final suspension.

Answer these variations first:

  1. With the program unchanged, after task.resume() the last printed line is main: first resume returned, and the preserved value is 15.

  2. If event.ready=true before that resume, await_ready=true is printed and await_suspend is omitted. await_resume, B value=15, and C value=30 occur before task.resume() returns.

  3. If final_suspend() becomes suspend_never without redesigning TraceTask, completion destroys the frame. The stored owning handle then dangles, making the existing destructor unsafe.

Does this program create another thread? No. Its continuations run inside task.resume() and event.set(). A scheduler or I/O awaiter may choose another context, but the language feature creates no thread.

8. C++20 coroutine suspension: the short version and next step

Before trusting an abstraction, ask five questions:

  1. What owns the frame?

  2. Does initial_suspend start eagerly or lazily?

  3. What did await_ready return?

  4. Who receives or stores the handle?

  5. Who resumes or destroys it?

If any answer is missing, the abstraction is not yet safe to use. Reconstruct the trace once: A value=10, add 5 and pause with value=15, resume at B value=15, multiply to C value=30, stop at final_suspend, then destroy the frame only when task leaves scope.

Use C++ Programming for the language foundations and Coding For Placements for broader practice. Frameworks become easier once suspension and ownership are visible.