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

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_awaitdoes 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:
#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.
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.
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:
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 frameCreation 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.

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.

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 | Inspect |
| A ready event would skip | Start with |
Resumption automatically changes thread |
| 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 |
Final suspension destroys the frame |
| Give one owner the destruction duty |
A stored waiter handle owns 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:
With the program unchanged, after
task.resume()the last printed line ismain: first resume returned, and the preserved value is15.If
event.ready=truebefore that resume,await_ready=trueis printed andawait_suspendis omitted.await_resume,B value=15, andC value=30occur beforetask.resume()returns.If
final_suspend()becomessuspend_neverwithout redesigningTraceTask, 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:
What owns the frame?
Does
initial_suspendstart eagerly or lazily?What did
await_readyreturn?Who receives or stores the handle?
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.
Keep learning
Templates in C++: Reusable Parameterized Code with Worked Examples
Learn how one C++ template produces type-specific functions and classes. Follow exact calls, calculations, deduction failures, and practical corrections.
Friend Functions in C++: Controlled Class Access with a Worked Example
See how a non-member friend function can validate and update two Wallet objects while private balances stay hidden from ordinary callers. Includes runnable C++ and an exact trace.
Data Types in C++: Fundamental and Derived Types with Worked Examples
Learn how C++ fundamental types differ from arrays, pointers, references, and function types. Trace one complete program, then test the conversion traps.
C++ Constructors and Object Initialization: Techniques, Order and a Worked Trace
Learn how C++ chooses default, parameterised, delegating and copy constructors, then trace member states and initialization order through complete code.





