Skip to content
C Codeloom
C++

C++ Coroutines: An Introduction

Understand C++20 coroutines — co_await, co_yield, co_return — and how they enable async and generator-style code without blocking threads.

·4 min read · By Codeloom
Advanced 10 min read

What you'll learn

  • Understand what a coroutine is and how it differs from a function
  • Use co_await, co_yield, and co_return
  • Recognize the promise type and coroutine handle
  • See how generators and async tasks are built
  • Avoid common lifetime pitfalls

Prerequisites

  • Templates basics — see /blog/cpp-templates-and-generics

C++20 added coroutines: functions that can suspend and resume. They unlock cleaner async code and lazy generators without callbacks or thread blocking. The mechanism is powerful but unusual — the language gives you the scaffolding and asks you to build the abstractions.

What and Why

A coroutine is a function that can pause mid-execution, save its state on the heap, and resume later. The compiler rewrites the function into a state machine. Three keywords trigger this: co_await (suspend until something is ready), co_yield (produce a value and pause), and co_return (finish).

The “why” is that async code without coroutines either blocks threads (wasting them) or splinters into chained callbacks (hard to read). Coroutines let you write sequential-looking code that secretly suspends and resumes.

Mental Model

When a coroutine is called, the compiler allocates a frame on the heap containing local variables and a state index. It returns a handle to the caller almost immediately. Each co_await or co_yield parks the frame; later, someone calls resume() to pick up where it left off.

The standard library does not ship task or generator types — only the machinery. You provide a “promise type” that defines what return objects look like, when to suspend, and how values flow. In practice, you use libraries like cppcoro or wait for std::generator and friends to land.

Hands-on Example

Here is a minimal generator that yields integers.

#include <coroutine>
#include <iostream>

struct IntGen {
    struct promise_type {
        int value;
        IntGen get_return_object() {
            return IntGen{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        std::suspend_always yield_value(int v) { value = v; return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
    std::coroutine_handle<promise_type> h;
    ~IntGen() { if (h) h.destroy(); }
    bool next() { h.resume(); return !h.done(); }
    int value() const { return h.promise().value; }
};

IntGen counter() {
    for (int i = 0; i < 3; ++i) co_yield i;
}
Caller            Heap frame
│                 ┌────────────┐
│  call ────────> │ state = 0  │
│  <── handle ── │ locals     │
│                 └────────────┘
│  resume() ────>  state advances
│  <── value ──   suspend at co_yield
│  resume() ────>  ...until done()
Coroutine frame lives on heap; caller resumes it via handle.

You can drive the generator by calling next() repeatedly and reading value() between calls. Each co_yield suspends; each resume() runs until the next suspend.

Common Pitfalls

The biggest trap is lifetime. The coroutine frame lives on the heap and is owned by whoever holds the handle. If your promise type forgets to destroy the handle, you leak memory. If you destroy it while the coroutine is suspended waiting on something, you may corrupt state.

Another pitfall is capturing references to local variables in lambdas that survive past a suspension point. The lambda may execute on a different thread, and the original stack frame may be gone.

A subtle issue: co_await requires the awaited expression to be an “awaitable” with await_ready, await_suspend, and await_resume. Trying to co_await an arbitrary type fails with a long template error.

Practical Tips

Do not write your own promise types unless you have to. Use libraries like cppcoro, asio coroutine support, or folly::coro — they provide tested task and generator types with proper cancellation, exception handling, and scheduling.

Coroutines are not free. The frame allocation costs roughly one new, and most implementations cannot elide it. For ultra-tight loops, plain iteration is faster.

Mark coroutines clearly in your code review — they look like normal functions but behave very differently. Lifetime bugs are easy to introduce.

Prefer value semantics inside coroutines. Avoid storing raw pointers to objects that might outlive a suspension.

Wrap-up

Coroutines bring async and lazy programming into the language at zero conceptual cost — once you understand the mental model. The standard library gives you only the machinery; libraries give you usable task and generator types. Start by using them rather than writing them, and pay close attention to who owns the coroutine handle and when it gets destroyed.