Project 9: Coroutine Generator Library
Build a Generator
type that enables Python-like generator functions in C++ using co_yield, producing values lazily on demand.
Quick Reference
| Attribute | Value |
|---|---|
| Language | C++ (C++20 required) |
| Difficulty | Expert |
| Time | 2-3 weeks |
| Prerequisites | Solid C++ fundamentals, templates, RAII |
| Coolness | Level 4: Hardcore Tech Flex |
| Portfolio Value | Interview Gold |
Learning Objectives
By completing this project, you will:
- Understand stackless coroutine mechanics: Explain how C++20 coroutines differ from traditional function calls and why they’re “stackless”
- Implement promise_type correctly: Write the required methods that tell the compiler how to manage coroutine state
- Master coroutine_handle operations: Use handles to resume, query state, and destroy coroutines safely
- Design lazy evaluation patterns: Build generators that only compute values when requested
- Implement iterator interfaces: Create STL-compatible iterators for range-based for loops
- Handle coroutine lifetimes: Prevent memory leaks and dangling references through proper RAII
- Connect theory to practice: Understand how generators serve as the foundation for async/await
- Debug coroutine code: Navigate the complexities of suspended execution and state machines
- Apply move-only semantics: Implement correct ownership transfer for coroutine resources
- Read production coroutine code: Understand cppcoro, Folly, and other real-world implementations
The Core Question You’re Answering
“How can a function pause in the middle of its execution, remember its state, and resume later to produce the next value?”
This question gets at the heart of what coroutines enable. In traditional programming, a function runs from start to finish, then returns. But generators need to “yield” values one at a time, suspending between yields and resuming when the caller asks for more. Understanding the machinery that makes this possible—promise types, coroutine handles, and suspension points—unlocks not just generators but the entire world of async/await programming.
Concepts You Must Understand First
Before writing any code, ensure you can answer these questions:
| Concept | Why It Matters | Self-Assessment Question |
|---|---|---|
| Move semantics | Coroutine handles are move-only resources | Can you implement a move constructor and prevent copies? |
| RAII | Coroutine state must be cleaned up | Why does destructor timing matter for resource management? |
| Templates | Generator |
Can you write a template class with dependent types? |
| Iterators | Range-for requires iterator protocol | What methods does an input iterator need? |
| Function pointers | Coroutine handles are like typed function pointers | How does a pointer to code differ from a pointer to data? |
| Stack vs Heap | Coroutine state lives on the heap | Why can’t coroutine locals live on the stack? |
Deep Dive: Key Concepts
1. Stackless vs Stackful Coroutines
C++20 coroutines are stackless: they don’t preserve their own call stack. When a coroutine suspends, only its local variables (the “coroutine frame”) are saved—not nested function calls.
Stackful Coroutine (Boost.Context, fibers):
┌──────────────────────────────────────────┐
│ Coroutine has its own stack │
│ │
│ ┌─────────────────────────┐ │
│ │ helper() │ │
│ ├─────────────────────────┤ │
│ │ process() │ ← Can yield │
│ ├─────────────────────────┤ from here │
│ │ generator_func() │ │
│ └─────────────────────────┘ │
└──────────────────────────────────────────┘
Stackless Coroutine (C++20):
┌──────────────────────────────────────────┐
│ Coroutine frame (heap-allocated) │
│ │
│ ┌─────────────────────────┐ │
│ │ Local variables only │ │
│ │ No nested call stack │ ← Can ONLY │
│ │ Suspension point info │ yield at │
│ └─────────────────────────┘ top level │
└──────────────────────────────────────────┘
Why stackless? Memory efficiency. Stackful coroutines need a full stack (typically 4KB-1MB). Stackless coroutines only need enough heap for their locals (often <100 bytes).
2. The Three Keywords
C++20 introduces three keywords that transform a function into a coroutine:
- co_yield expr: Suspend and produce a value
- co_return [expr]: Complete the coroutine (optionally with a value)
- co_await expr: Suspend until an awaitable is ready
When the compiler sees any of these keywords in a function body, it transforms the function into a coroutine.
3. The Promise Type Contract
The promise type is the control center. The compiler generates code that calls promise methods at specific points:
User writes: Compiler generates:
──────────────────────────── ─────────────────────────────────────────
Generator<int> count(int n) { // Allocate coroutine frame
// Construct promise
promise.get_return_object(); // Get Generator
co_await promise.initial_suspend();
for (int i = 0; i < n; i++)
co_yield i; promise.yield_value(i);
co_await std::suspend_always{};
} // implicit co_return
promise.return_void();
co_await promise.final_suspend();
// Coroutine cleanup
Theoretical Foundation
Why Coroutines Exist
Traditional iteration patterns have limitations:
Problem 1: Memory
// Generates ALL values upfront
std::vector<int> fibonacci(int n) {
std::vector<int> result;
int a = 0, b = 1;
for (int i = 0; i < n; i++) {
result.push_back(a);
int temp = a + b;
a = b;
b = temp;
}
return result; // O(n) memory!
}
Problem 2: Callback Hell
// Inverted control flow
void fibonacci(int n, std::function<void(int)> callback) {
int a = 0, b = 1;
for (int i = 0; i < n; i++) {
callback(a); // Who owns the flow?
int temp = a + b;
a = b;
b = temp;
}
}
Solution: Generators
// Best of both worlds: lazy + clean control flow
Generator<int> fibonacci() {
int a = 0, b = 1;
while (true) {
co_yield a; // Suspend, return a, resume later
int temp = a + b;
a = b;
b = temp;
}
}
// Caller controls iteration
auto fib = fibonacci();
for (int x : fib | std::views::take(10)) {
std::cout << x << '\n';
}
The Coroutine State Machine
When you write a generator, the compiler transforms it into a state machine:
Generator<int> example() { Becomes conceptually:
int x = 1; ─────────────────────────────────────
co_yield x; ──────────▶ struct example_frame {
x++; int x;
co_yield x; int suspension_point;
x++; };
co_yield x;
} void example_resume(example_frame* f) {
switch (f->suspension_point) {
case 0: goto start;
case 1: goto after_yield1;
case 2: goto after_yield2;
case 3: goto after_yield3;
}
start:
f->x = 1;
f->suspension_point = 1;
return; // yield f->x
after_yield1:
f->x++;
f->suspension_point = 2;
return; // yield f->x
after_yield2:
f->x++;
f->suspension_point = 3;
return; // yield f->x
after_yield3:
f->suspension_point = -1;
return; // done
}
Memory Layout
The coroutine frame is heap-allocated (usually) and contains:
┌─────────────────────────────────────────────────────────────────────┐
│ COROUTINE FRAME │
├─────────────────────────────────────────────────────────────────────┤
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Promise Object (promise_type) │ │
│ │ - current_value: T ← Holds yielded value │ │
│ │ - exception: exception_ptr ← Holds thrown exception │ │
│ └───────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────┤
│ Suspension Point Index: int ← Which co_yield are we at? │
├─────────────────────────────────────────────────────────────────────┤
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Local Variables │ │
│ │ - All locals that live across suspension points │ │
│ │ - Parameters copied into frame │ │
│ └───────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────┤
│ Resume Function Pointer ← Points to generated state machine │
│ Destroy Function Pointer ← Points to cleanup code │
└─────────────────────────────────────────────────────────────────────┘
Historical Context
C++20 coroutines evolved from a long history:
- 1958: Melvin Conway coins “coroutine” for assembly language cooperation
- 1972: Donald Knuth explains coroutines in “The Art of Computer Programming”
- 1990s: Stackful coroutines in various languages (Modula-2, Lua)
- 2001: Python generators (PEP 255)
- 2012: C# async/await popularizes stackless coroutines
- 2017: C++ TS for Coroutines
- 2020: Coroutines standardized in C++20
C++20 provides only the primitive machinery. The standard library doesn’t include a Generator type (until C++23’s std::generator). This project implements what the standard left out.
Common Misconceptions
Misconception 1: “Coroutines are like threads” Reality: Coroutines are single-threaded cooperative multitasking. They never run in parallel—they take turns.
Misconception 2: “co_yield returns from the function” Reality: co_yield suspends the coroutine but doesn’t destroy it. The function is paused, not finished.
Misconception 3: “The promise_type is what I return”
Reality: The promise_type is internal machinery. get_return_object() creates what the caller sees (Generator
Misconception 4: “Coroutines are always heap-allocated” Reality: The standard allows compilers to elide the allocation if they can prove the coroutine lifetime doesn’t exceed its caller. This is called HALO (Heap Allocation eLision Optimization).
Project Specification
What You Will Build
A complete Generator<T> library that enables writing Python-style generator functions in C++:
// User code - what you're enabling
Generator<int> fibonacci(int n) {
int a = 0, b = 1;
for (int i = 0; i < n; i++) {
co_yield a;
int temp = a + b;
a = b;
b = temp;
}
}
// Usage
for (int x : fibonacci(10)) {
std::cout << x << ' '; // 0 1 1 2 3 5 8 13 21 34
}
Functional Requirements
- Core Generator Operations:
Generator<T>class template for any copyable or moveable type T- Support for
co_yieldto produce values - Support for
co_return(void) to terminate next()method to advance and check for more valuesvalue()method to retrieve the current yielded value
- Iterator Support:
- Range-based for loop compatibility
begin()returns iterator pointing to first valueend()returns sentinel for completion checkoperator++advances to next valueoperator*dereferences to current value
- Lifetime Management:
- Move-only semantics (no copying coroutines)
- Proper destruction via RAII
- No memory leaks on early termination
- Exception handling for coroutine failures
- Promise Type Requirements:
get_return_object()creates the Generatorinitial_suspend()returnssuspend_alwaysfor lazy startfinal_suspend()returnssuspend_alwaysfor controlled cleanupyield_value(T)stores value and suspendsreturn_void()handles completionunhandled_exception()handles exceptions
Non-Functional Requirements
- C++20 Compliance: Uses only standard C++20 features
- Header-Only: Single-header library for easy integration
- Zero Overhead: No dynamic allocation beyond the coroutine frame itself
- Thread Safety: Single-threaded use (no concurrent access to one generator)
Real World Outcome
When complete, you’ll have a library enabling these patterns:
// Example 1: Infinite sequence
Generator<int> natural_numbers() {
int n = 0;
while (true) {
co_yield n++;
}
}
// Example 2: File line reader
Generator<std::string> read_lines(const std::filesystem::path& path) {
std::ifstream file(path);
std::string line;
while (std::getline(file, line)) {
co_yield std::move(line);
}
}
// Example 3: Tree traversal
template<typename T>
Generator<T> inorder(TreeNode<T>* node) {
if (!node) co_return;
for (T val : inorder(node->left)) co_yield val;
co_yield node->value;
for (T val : inorder(node->right)) co_yield val;
}
// Example 4: Range transformation
Generator<int> squares(Generator<int> source) {
for (int x : source) {
co_yield x * x;
}
}
Example Build & Run:
$ mkdir generator-lib && cd generator-lib
$ cat > generator.hpp << 'EOF'
// Your implementation goes here
EOF
$ cat > main.cpp << 'EOF'
#include "generator.hpp"
#include <iostream>
Generator<int> fibonacci(int n) {
int a = 0, b = 1;
for (int i = 0; i < n; ++i) {
co_yield a;
int temp = a + b;
a = b;
b = temp;
}
}
int main() {
std::cout << "Fibonacci sequence:\n";
for (int x : fibonacci(15)) {
std::cout << x << ' ';
}
std::cout << '\n';
// Manual iteration
auto gen = fibonacci(5);
while (gen.next()) {
std::cout << "Got: " << gen.value() << '\n';
}
return 0;
}
EOF
$ g++ -std=c++20 -fcoroutines main.cpp -o generator_demo
$ ./generator_demo
Fibonacci sequence:
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377
Got: 0
Got: 1
Got: 1
Got: 2
Got: 3
Solution Architecture
High-Level Design
┌─────────────────────────────────────────────────────────────────────────────┐
│ GENERATOR LIBRARY │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ User Code Library Internals │
│ ───────── ────────────────── │
│ │
│ Generator<int> count() { ┌─────────────────────────────────────────┐ │
│ co_yield 1; │ Generator<T> │ │
│ co_yield 2; │ - coroutine_handle<promise_type> │ │
│ co_yield 3; │ - begin(), end() │ │
│ } │ - next(), value() │ │
│ │ │ - move-only (deleted copy) │ │
│ │ └────────────────────────┬────────────────┘ │
│ │ creates │ │
│ ▼ ┌────────────────────────▼────────────────┐ │
│ ┌─────────────────────┐ │ promise_type │ │
│ │ Generator<int> │◀─────│ - current_value: T │ │
│ │ (returned to user) │ │ - get_return_object() │ │
│ └─────────────────────┘ │ - initial/final_suspend() │ │
│ │ │ - yield_value(T) │ │
│ │ iterates │ - unhandled_exception() │ │
│ ▼ └────────────────────────┬────────────────┘ │
│ ┌─────────────────────┐ │ │
│ │ for (int x : gen) │ ┌────────────────────────▼────────────────┐ │
│ │ use(x); │──────│ iterator │ │
│ └─────────────────────┘ │ - coroutine_handle<promise_type> │ │
│ │ - operator++, operator*, operator!= │ │
│ └─────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Key Components
| Component | Responsibility | Key Methods |
|---|---|---|
Generator<T> |
User-facing wrapper, owns the coroutine handle | next(), value(), begin(), end() |
promise_type |
Coroutine machinery, stores yielded values | get_return_object(), yield_value(), initial_suspend(), final_suspend() |
iterator |
STL-compatible iterator for range-for | operator++, operator*, operator!= |
sentinel |
End marker for iterator comparison | std::default_sentinel_t (or custom) |
Data Structures
template<typename T>
class Generator {
public:
// The promise_type MUST be nested and named exactly "promise_type"
struct promise_type {
T current_value; // Stores yielded value
std::exception_ptr exception; // Stores thrown exception (optional)
Generator get_return_object(); // Creates the Generator
std::suspend_always initial_suspend(); // Lazy: don't run until asked
std::suspend_always final_suspend() noexcept; // Keep alive for value access
void return_void(); // Handle co_return;
std::suspend_always yield_value(T value); // Handle co_yield value;
void unhandled_exception(); // Handle exceptions
};
private:
std::coroutine_handle<promise_type> handle_;
public:
explicit Generator(std::coroutine_handle<promise_type> h);
~Generator();
// Move-only
Generator(Generator&& other) noexcept;
Generator& operator=(Generator&& other) noexcept;
Generator(const Generator&) = delete;
Generator& operator=(const Generator&) = delete;
// Core operations
bool next();
T& value();
// Iterator support
class iterator;
iterator begin();
std::default_sentinel_t end();
};
Coroutine Lifecycle
┌─────────────────────────────────────────────────────────────────────────────┐
│ COROUTINE LIFECYCLE │
└─────────────────────────────────────────────────────────────────────────────┘
1. CREATION
─────────
User calls: auto gen = my_generator();
┌─────────────────────────────────────────┐
│ Compiler-generated: │
│ 1. Allocate coroutine frame (heap) │
│ 2. Construct promise_type │
│ 3. Call promise.get_return_object() │
│ 4. Call co_await initial_suspend() │ ← SUSPENDS (we use suspend_always)
│ 5. Return Generator to caller │
└─────────────────────────────────────────┘
2. RESUMPTION (each call to next())
─────────────────────────────────
User calls: gen.next()
┌─────────────────────────────────────────┐
│ Your code: │
│ handle_.resume(); │ ← Continues from suspension point
│ │
│ Coroutine runs until: │
│ - co_yield value (suspends) │
│ - co_return (completes) │
│ - exception (completes + stores) │
│ │
│ Return: !handle_.done() │
└─────────────────────────────────────────┘
3. VALUE ACCESS
─────────────
User calls: gen.value()
┌─────────────────────────────────────────┐
│ Your code: │
│ return handle_.promise().current_value;│
└─────────────────────────────────────────┘
4. DESTRUCTION
────────────
Generator goes out of scope
┌─────────────────────────────────────────┐
│ ~Generator(): │
│ if (handle_) { │
│ handle_.destroy(); │ ← Deallocates frame
│ } │
└─────────────────────────────────────────┘
Timeline:
─────────
Creation Resume Yield Resume Yield Resume Done Destroy
│ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│Allocate│───▶│ Run to │──▶│Suspend │──▶│Continue│──▶│Suspend │──▶│Continue│─▶│Complete│──▶│ Free │
│ Frame │ │ yield 1│ │@yield 1│ │to yield2│ │@yield 2│ │to end │ │ frame │ │ Frame │
└────────┘ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘
Implementation Guide
Phase 1: Minimal Compiling Generator (Days 1-3)
Goal: Get a coroutine to compile and produce a single value.
Step 1: Basic Structure
#include <coroutine>
#include <utility>
template<typename T>
class Generator {
public:
struct promise_type {
T current_value;
Generator get_return_object() {
return Generator{
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(T value) {
current_value = std::move(value);
return {};
}
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
private:
std::coroutine_handle<promise_type> handle_;
public:
explicit Generator(std::coroutine_handle<promise_type> h)
: handle_(h) {}
~Generator() {
if (handle_) handle_.destroy();
}
// Move only
Generator(Generator&& other) noexcept
: handle_(std::exchange(other.handle_, {})) {}
Generator& operator=(Generator&& other) noexcept {
if (this != &other) {
if (handle_) handle_.destroy();
handle_ = std::exchange(other.handle_, {});
}
return *this;
}
Generator(const Generator&) = delete;
Generator& operator=(const Generator&) = delete;
};
Step 2: Add next() and value()
public:
bool next() {
handle_.resume();
return !handle_.done();
}
T& value() {
return handle_.promise().current_value;
}
const T& value() const {
return handle_.promise().current_value;
}
Step 3: Test Basic Functionality
Generator<int> simple() {
co_yield 1;
co_yield 2;
co_yield 3;
}
int main() {
auto gen = simple();
while (gen.next()) {
std::cout << gen.value() << '\n';
}
}
Checkpoint: This should print 1, 2, 3 on separate lines.
Phase 2: Iterator Support (Days 4-7)
Goal: Enable range-based for loops.
Step 1: Define the Iterator Class
public:
class iterator {
public:
using iterator_category = std::input_iterator_tag;
using value_type = T;
using difference_type = std::ptrdiff_t;
using pointer = T*;
using reference = T&;
private:
std::coroutine_handle<promise_type> handle_;
public:
iterator() : handle_(nullptr) {}
explicit iterator(std::coroutine_handle<promise_type> h)
: handle_(h) {}
iterator& operator++() {
handle_.resume();
return *this;
}
// Post-increment (required for input_iterator but returns void here)
void operator++(int) {
++*this;
}
T& operator*() {
return handle_.promise().current_value;
}
T* operator->() {
return &handle_.promise().current_value;
}
bool operator==(std::default_sentinel_t) const {
return handle_.done();
}
bool operator!=(std::default_sentinel_t sentinel) const {
return !(*this == sentinel);
}
};
Step 2: Add begin() and end()
public:
iterator begin() {
handle_.resume(); // Advance to first yield
return iterator{handle_};
}
std::default_sentinel_t end() {
return {};
}
Step 3: Test Range-For
Generator<int> countdown(int n) {
while (n > 0) {
co_yield n--;
}
}
int main() {
for (int x : countdown(5)) {
std::cout << x << ' '; // 5 4 3 2 1
}
}
Checkpoint: Range-for works correctly, stopping after all values.
Phase 3: Exception Handling (Days 8-10)
Goal: Properly propagate exceptions from the coroutine.
Step 1: Store Exceptions in Promise
struct promise_type {
T current_value;
std::exception_ptr exception_;
// ... existing methods ...
void unhandled_exception() {
exception_ = std::current_exception();
}
};
Step 2: Check and Rethrow
public:
bool next() {
handle_.resume();
if (handle_.promise().exception_) {
std::rethrow_exception(handle_.promise().exception_);
}
return !handle_.done();
}
Step 3: Test Exception Propagation
Generator<int> might_throw() {
co_yield 1;
co_yield 2;
throw std::runtime_error("Generator failed!");
co_yield 3; // Never reached
}
int main() {
try {
for (int x : might_throw()) {
std::cout << x << '\n';
}
} catch (const std::exception& e) {
std::cout << "Caught: " << e.what() << '\n';
}
}
Checkpoint: Exception propagates to caller after yielding 1 and 2.
Phase 4: Reference Types and Efficiency (Days 11-12)
Goal: Support yielding references and avoid unnecessary copies.
Step 1: Support Lvalue and Rvalue Yields
struct promise_type {
// Use std::optional or pointer to handle reference types
T current_value;
std::suspend_always yield_value(T value) {
current_value = std::move(value);
return {};
}
// For lvalue references
std::suspend_always yield_value(T& value) {
current_value = value; // Copy
return {};
}
};
Step 2: Consider a Generator<T&> Specialization
For reference generators (yielding references to existing data):
template<typename T>
class Generator<T&> {
struct promise_type {
T* current_ptr = nullptr;
std::suspend_always yield_value(T& value) {
current_ptr = &value;
return {};
}
// ...
};
T& value() {
return *handle_.promise().current_ptr;
}
};
Phase 5: Recursive Generators (Days 13-14)
Goal: Enable generators that yield from other generators.
This is tricky because you can’t directly co_yield from in C++ (unlike Python’s yield from). Instead, you iterate:
template<typename T>
Generator<T> flatten(Generator<Generator<T>> nested) {
for (auto inner : nested) {
for (T value : inner) {
co_yield value;
}
}
}
// Usage: tree traversal
template<typename T>
Generator<T> inorder(TreeNode<T>* node) {
if (!node) co_return;
// Must iterate, not co_yield directly
for (T val : inorder(node->left)) {
co_yield val;
}
co_yield node->value;
for (T val : inorder(node->right)) {
co_yield val;
}
}
Why no co_yield from? C++20 doesn’t have it. Each generator is a separate coroutine with its own frame. Symmetric transfer (C++20) helps with stack overflow, but you still iterate.
Testing Strategy
Unit Tests
1. Basic Functionality
TEST(Generator, YieldsValuesInOrder) {
auto gen = []() -> Generator<int> {
co_yield 1;
co_yield 2;
co_yield 3;
}();
std::vector<int> values;
while (gen.next()) {
values.push_back(gen.value());
}
EXPECT_EQ(values, (std::vector<int>{1, 2, 3}));
}
2. Empty Generator
TEST(Generator, EmptyGeneratorIsDoneImmediately) {
auto gen = []() -> Generator<int> {
co_return;
}();
EXPECT_FALSE(gen.next());
}
3. Range-For Loop
TEST(Generator, WorksWithRangeFor) {
auto gen = []() -> Generator<int> {
for (int i = 0; i < 5; i++) {
co_yield i;
}
}();
std::vector<int> values;
for (int x : gen) {
values.push_back(x);
}
EXPECT_EQ(values, (std::vector<int>{0, 1, 2, 3, 4}));
}
4. Move Semantics
TEST(Generator, MoveTransfersOwnership) {
auto gen1 = []() -> Generator<int> {
co_yield 42;
}();
Generator<int> gen2 = std::move(gen1);
EXPECT_TRUE(gen2.next());
EXPECT_EQ(gen2.value(), 42);
}
5. Exception Propagation
TEST(Generator, PropagatesExceptions) {
auto gen = []() -> Generator<int> {
co_yield 1;
throw std::runtime_error("test");
}();
EXPECT_TRUE(gen.next());
EXPECT_EQ(gen.value(), 1);
EXPECT_THROW(gen.next(), std::runtime_error);
}
6. Infinite Generator with Early Termination
TEST(Generator, InfiniteGeneratorCleanlyDestructs) {
bool destroyed = false;
{
auto gen = [&destroyed]() -> Generator<int> {
int n = 0;
// Custom destructor notification
struct Guard {
bool& flag;
~Guard() { flag = true; }
} guard{destroyed};
while (true) {
co_yield n++;
}
}();
gen.next(); // Get one value
// gen destructs here
}
EXPECT_TRUE(destroyed);
}
Stress Tests
1. Large Sequence
TEST(Generator, HandlesLargeSequence) {
auto gen = []() -> Generator<int> {
for (int i = 0; i < 1000000; i++) {
co_yield i;
}
}();
int count = 0;
for (int x : gen) {
count++;
}
EXPECT_EQ(count, 1000000);
}
2. Nested Generators
TEST(Generator, NestedGeneratorsWork) {
auto inner = []() -> Generator<int> {
co_yield 1;
co_yield 2;
};
auto outer = [&inner]() -> Generator<int> {
for (int x : inner()) co_yield x;
for (int x : inner()) co_yield x * 10;
}();
std::vector<int> values;
for (int x : outer) {
values.push_back(x);
}
EXPECT_EQ(values, (std::vector<int>{1, 2, 10, 20}));
}
Memory Leak Detection
Use AddressSanitizer:
g++ -std=c++20 -fcoroutines -fsanitize=address -g test.cpp -o test
./test
Common leaks:
- Forgetting
handle_.destroy()in destructor - Double-destroy after move
- Not clearing handle in move constructor
Common Pitfalls and Debugging
Pitfall 1: Forgetting to Destroy the Handle
Problem: Memory leak because coroutine frame is never freed.
~Generator() {
// WRONG: Missing destroy!
}
Solution: Always destroy in destructor:
~Generator() {
if (handle_) {
handle_.destroy();
}
}
Pitfall 2: Double Destroy After Move
Problem: Moving a generator then destroying both leads to double-free.
Generator(Generator&& other) noexcept
: handle_(other.handle_) { // WRONG: other still has handle!
}
Solution: Use std::exchange to clear the source:
Generator(Generator&& other) noexcept
: handle_(std::exchange(other.handle_, {})) {}
Pitfall 3: Accessing Value When Done
Problem: Undefined behavior accessing value after generator completes.
auto gen = simple();
while (gen.next()) { }
auto x = gen.value(); // UB: no value!
Solution: Always check next() before value():
if (gen.next()) {
use(gen.value()); // Safe
}
Pitfall 4: Not Calling initial_suspend Correctly
Problem: Generator runs immediately without being asked.
std::suspend_never initial_suspend() { return {}; } // WRONG for lazy gen
Solution: Use suspend_always for lazy generators:
std::suspend_always initial_suspend() { return {}; }
Pitfall 5: Resuming a Done Coroutine
Problem: Calling resume() on a finished coroutine is undefined behavior.
handle_.resume(); // What if already done?
Solution: Always check done() before resuming:
bool next() {
if (!handle_ || handle_.done()) {
return false;
}
handle_.resume();
return !handle_.done();
}
Pitfall 6: final_suspend Returns suspend_never
Problem: Coroutine frame destroyed before you can read the last value.
std::suspend_never final_suspend() noexcept { return {}; } // WRONG!
Solution: Use suspend_always to keep frame alive:
std::suspend_always final_suspend() noexcept { return {}; }
Pitfall 7: Iterator Invalidation
Problem: Creating multiple iterators from the same generator.
auto gen = numbers();
auto it1 = gen.begin(); // Resumes coroutine
auto it2 = gen.begin(); // Resumes AGAIN - UB!
Solution: Document single-pass iteration:
// Generator is single-pass: only one iteration allowed
// Multiple calls to begin() have undefined behavior
Debugging Techniques
1. Print Suspension Points
std::suspend_always yield_value(T value) {
std::cout << "[DEBUG] Yielding: " << value << '\n';
current_value = std::move(value);
return {};
}
2. Track Coroutine Lifecycle
promise_type() { std::cout << "[DEBUG] Promise constructed\n"; }
~promise_type() { std::cout << "[DEBUG] Promise destroyed\n"; }
3. Use AddressSanitizer
g++ -std=c++20 -fcoroutines -fsanitize=address,undefined -g test.cpp
4. Verify Handle State
void debug_state() {
std::cout << "Handle: " << (handle_ ? "valid" : "null") << '\n';
if (handle_) {
std::cout << "Done: " << (handle_.done() ? "yes" : "no") << '\n';
}
}
Extensions and Challenges
Beginner Extensions
- Add
operator bool(): Allowwhile (gen)instead ofwhile (gen.next()) - Add
take(n): Method to consume at most n values - Add
skip(n): Method to skip the first n values
Intermediate Extensions
- Implement
Generator<T&>: Yield references without copying - Add
filter(predicate): Compose generators with filtering - Add
transform(func): Apply transformation to yielded values - Support
co_return value: Return a final value after iteration
Advanced Extensions
- Symmetric Transfer: Use
std::coroutine_handle<>return fromawait_suspendto avoid stack overflow in deeply nested generators - Allocator Support: Allow custom allocation for the coroutine frame
- Thread-Safe Generator: Allow passing generators between threads safely
- Async Generator: Combine with
co_awaitfor async iteration
Challenge: Implement Ranges Support
Make your generator work with C++20 ranges:
auto values = my_generator()
| std::views::take(10)
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; });
for (int x : values) {
std::cout << x << '\n';
}
This requires implementing the range concept:
template<typename T>
class Generator {
// ... existing code ...
// For ranges support
using iterator = /* ... */;
using sentinel = std::default_sentinel_t;
// Ensure Generator satisfies std::ranges::input_range
};
static_assert(std::ranges::input_range<Generator<int>>);
Resources
Essential Reading
| Resource | Description |
|---|---|
| Stanford Coroutines Tutorial | Best introduction to C++20 coroutines |
| cppreference: Coroutines | Authoritative language reference |
| ModernesCpp: C++20 Coroutines | Clear explanations with examples |
| Lewis Baker’s Blog | Deep dives from cppcoro author |
Books That Will Help
| Topic | Book | Chapter |
|---|---|---|
| C++20 Coroutines | “C++ Concurrency in Action, 2nd Ed” by Anthony Williams | Part 2 (for context) |
| Modern C++ Features | “C++20 - The Complete Guide” by Nicolai Josuttis | Coroutines chapter |
| Template Metaprogramming | “C++ Templates: The Complete Guide” by Vandevoorde | Entire book |
| Move Semantics | “Effective Modern C++” by Scott Meyers | Items 23-25 |
Reference Implementations
| Library | Notes |
|---|---|
| cppcoro | The reference implementation by Lewis Baker |
| std::generator (C++23) | Standard library version (study the spec) |
| Folly Coroutines | Facebook’s production implementation |
| libcoro | Lightweight coroutine library |
Videos
- CppCon 2016: Gor Nishanov “C++ Coroutines: Under the covers”
- CppCon 2019: Lewis Baker “Structured Concurrency: Writing Safer Concurrent Code with Coroutines and Senders”
- Meeting C++ 2019: Rainer Grimm “C++20: The Coroutines”
Self-Assessment Checklist
Understanding
- I can explain the difference between stackful and stackless coroutines
- I can list all methods required in
promise_typeand explain what each does - I can explain why
final_suspendmust returnsuspend_alwaysfor generators - I can trace through what happens when
co_yieldis executed - I understand why generators are move-only
- I can explain the relationship between the handle and the promise
Implementation
- Basic
co_yieldproduces values correctly - Range-based for loop works
- Move semantics work without memory leaks
- Early destruction cleans up properly
- Exceptions propagate to the caller
- Code compiles with
-Wall -Wextra -Werror
Testing
- All unit tests pass
- AddressSanitizer finds no issues
- UndefinedBehaviorSanitizer finds no issues
- Memory leak detector finds no leaks
- Stress tests with large sequences pass
Growth
- I can now read cppcoro source code and understand it
- I can debug coroutine-related compile errors
- I understand how this foundation enables async/await
- I can explain generators in a job interview
The Interview Questions They’ll Ask
- “How do C++20 coroutines differ from threads?”
- Coroutines are single-threaded, cooperative; threads are preemptive and parallel
- Coroutines share a call stack; threads each have their own
- Coroutine suspension is explicit; thread suspension is OS-controlled
- “What is the promise_type and why is it named that way?”
- It’s the control center for coroutine behavior
- Named by convention: compiler looks for
promise_typenested type - Not related to
std::promise—different concept entirely
- “Explain what happens when co_yield is executed.”
- Promise’s
yield_value(expr)is called with the expression - Return value (suspend_always) tells coroutine to suspend
- Control returns to whoever last called
resume()
- Promise’s
- “Why is initial_suspend() returning suspend_always?”
- Makes the generator lazy—doesn’t run until asked
- Gives caller control over when execution starts
- Allows value retrieval setup before first resume
- “What happens if you forget to call destroy() on the handle?”
- Memory leak: coroutine frame never freed
- Destructors of locals in the coroutine never run
- Easy to detect with AddressSanitizer
- “How would you implement yield-from behavior in C++?”
- No direct support in C++20 (unlike Python)
- Must iterate the inner generator and yield each value
- Symmetric transfer helps with deep recursion
Submission / Completion Criteria
Minimum Viable Completion
Generator<T>compiles and basicco_yieldworksnext()andvalue()API functions correctly~Generator()properly destroys the coroutine- Simple test case works (yields 1, 2, 3)
Full Completion
- Range-based for loop works (
begin(),end()) - Move semantics implemented correctly
- Exception propagation works
- Multiple test cases pass
- No memory leaks (verified with sanitizer)
- Infinite generators can be safely destroyed
Excellence (Going Above and Beyond)
- Composable generators (
filter,transform,take) - Ranges library compatibility
- Reference type specialization (
Generator<T&>) - Custom allocator support
- Performance benchmarks vs hand-written iterators
- Blog post or documentation explaining implementation
Conclusion
Building a Generator
The generator is the simplest coroutine pattern, but mastering it opens the door to:
- Async tasks: Replace
yield_valuewith awaiting external events - Channels: Combine generators with thread-safe queues
- Pipelines: Chain coroutines for data processing
- Schedulers: Build systems that manage many coroutines
Next Step: Try Project 10: Async Task Framework to see how these concepts extend to async/await with scheduling and task composition.
This guide was expanded from LEARN_CPP_CONCURRENCY_AND_PARALLELISM.md. For the complete learning path, see the project index.