Complete Guide to C++20 Coroutines | A New Era of Asynchronous Programming

Complete Guide to C++20 Coroutines | A New Era of Asynchronous Programming

이 글의 핵심

C++20 coroutines: promise types, awaiters, co_await/co_yield—building Task and Generator types with clear lifetime rules.

What Are C++20 Coroutines? Why Do We Need Them?

Problem Scenario: Callback Hell

The Problem: Handling asynchronous tasks with callbacks often leads to deeply nested code, making it less readable.

// Callback hell
async_read_file("config.json", [](std::string content) {
    auto config = parse_json(content);
    async_fetch_url(config.url, [](std::string response) {
        auto data = parse_response(response);
        async_save_db(data, [](bool success) {
            if (success) {
                std::cout << "Done\n";
            }
        });
    });
});

The Solution: Coroutines allow you to write asynchronous tasks in a synchronous style.

// Clean with coroutines
Task<void> process() {
    auto content = co_await async_read_file("config.json");
    auto config = parse_json(content);
    auto response = co_await async_fetch_url(config.url);
    auto data = parse_response(response);
    bool success = co_await async_save_db(data);
    if (success) {
        std::cout << "Done\n";
    }
}
flowchart TD
    subgraph callback["Callback Style"]
        c1["async_read_file(callback1)"]
        c2["  callback1: parse_json"]
        c3["    async_fetch_url(callback2)"]
        c4["      callback2: parse_response"]
        c5["        async_save_db(callback3)"]
    end
    subgraph coroutine["Coroutine Style"]
        co1["co_await async_read_file"]
        co2["parse_json"]
        co3["co_await async_fetch_url"]
        co4["parse_response"]
        co5["co_await async_save_db"]
    end
    c1 --> c2 --> c3 --> c4 --> c5
    co1 --> co2 --> co3 --> co4 --> co5

Table of Contents

  1. Basic Keywords: co_await, co_yield, co_return
  2. Promise Type
  3. Implementing a Generator
  4. Implementing a Task (Asynchronous)
  5. Awaitable Objects
  6. Common Errors and Solutions
  7. Production Patterns
  8. Complete Example: Asynchronous HTTP Client
  9. Performance Considerations

1. Basic Keywords

co_yield: Suspend After Returning a Value

Generator<int> counter(int max) {
    for (int i = 0; i < max; ++i) {
        co_yield i;  // Return i and suspend
    }
}

int main() {
    auto gen = counter(5);
    while (gen.next()) {
        std::cout << gen.value() << '\n';
    }
    // Output: 0 1 2 3 4
}

co_return: Return Final Value and Exit

Task<int> compute() {
    int result = 42;
    co_return result;  // Exit
}

co_await: Wait for an Asynchronous Task

Task<std::string> fetch_data() {
    auto response = co_await async_http_get("https://api.example.com/data");
    co_return response;
}

2. Promise Type

What Is a Promise Type?

The promise type defines the behavior of a coroutine. The return type of a coroutine function must have a nested promise_type.

struct MyCoroutine {
    struct promise_type {
        // 1. Create coroutine object
        MyCoroutine get_return_object() {
            return MyCoroutine{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        
        // 2. Initial suspension
        std::suspend_always initial_suspend() { return {}; }  // Suspend
        // std::suspend_never initial_suspend() { return {}; }  // Execute immediately
        
        // 3. Final suspension
        std::suspend_always final_suspend() noexcept { return {}; }
        
        // 4. Handle return
        void return_void() {}
        // void return_value(T value) { this->value = value; }
        
        // 5. Handle exceptions
        void unhandled_exception() {
            exception = std::current_exception();
        }
        
        // 6. Handle yield (for Generator)
        std::suspend_always yield_value(T value) {
            this->value = value;
            return {};
        }
        
        T value;
        std::exception_ptr exception;
    };
    
    std::coroutine_handle<promise_type> handle;
    
    ~MyCoroutine() {
        if (handle) handle.destroy();
    }
};

How Promise Type Works

When you call a coroutine function, the compiler performs these steps:

flowchart TD
    A["Call coroutine function"] --> B["Allocate coroutine frame"]
    B --> C["Construct promise_type"]
    C --> D["Call get_return_object()"]
    D --> E["Call initial_suspend()"]
    E --> F{"Suspend?"}
    F -->|Yes| G["Return to caller"]
    F -->|No| H["Execute coroutine body"]
    H --> I["co_yield/co_return/co_await"]
    I --> J["Call final_suspend()"]
    J --> K["Destroy promise"]
    K --> L["Deallocate frame"]

Step-by-step explanation:

  1. Allocate coroutine frame: The compiler allocates memory for the coroutine state (local variables, parameters, promise).
  2. Construct promise_type: The promise object is constructed in the frame.
  3. get_return_object(): Creates the coroutine object that will be returned to the caller.
  4. initial_suspend(): Determines if the coroutine starts immediately or suspends.
    • suspend_always: Coroutine suspends, caller must call resume().
    • suspend_never: Coroutine executes immediately until first suspension point.
  5. Execute body: The coroutine body runs until co_yield, co_await, or co_return.
  6. final_suspend(): Called when coroutine finishes. Usually returns suspend_always to allow cleanup.
  7. Cleanup: Promise is destroyed, frame is deallocated.

Memory management: The coroutine frame is allocated on the heap by default. The coroutine_handle manages the lifetime of this frame.

// Coroutine frame layout (conceptual)
struct CoroutineFrame {
    promise_type promise;        // Promise object
    void* resume_point;          // Where to resume
    int local_var1;              // Local variables
    int local_var2;
    // ... parameters, temporaries ...
};

Promise Type Methods

MethodPurposeReturn Type
get_return_object()Create coroutine objectCoroutine return type
initial_suspend()Start suspended or notsuspend_always / suspend_never
final_suspend()End suspended or notsuspend_always / suspend_never
return_void()Handle co_return;void
return_value(T)Handle co_return value;void
unhandled_exception()Handle exceptionsvoid
yield_value(T)Handle co_yield value;Awaitable

Example: Custom promise with logging:

struct LoggingTask {
    struct promise_type {
        int value = 0;
        
        LoggingTask get_return_object() {
            std::cout << "[Promise] get_return_object()\n";
            return LoggingTask{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        
        std::suspend_never initial_suspend() {
            std::cout << "[Promise] initial_suspend()\n";
            return {};
        }
        
        std::suspend_always final_suspend() noexcept {
            std::cout << "[Promise] final_suspend()\n";
            return {};
        }
        
        void return_value(int v) {
            std::cout << "[Promise] return_value(" << v << ")\n";
            value = v;
        }
        
        void unhandled_exception() {
            std::cout << "[Promise] unhandled_exception()\n";
        }
    };
    
    std::coroutine_handle<promise_type> handle;
    
    ~LoggingTask() {
        std::cout << "[Task] Destructor\n";
        if (handle) handle.destroy();
    }
};

LoggingTask example() {
    std::cout << "[Coroutine] Body executing\n";
    co_return 42;
}

int main() {
    std::cout << "[Main] Calling coroutine\n";
    auto task = example();
    std::cout << "[Main] Coroutine returned\n";
    // Output shows the execution order
}

3. Implementing a Generator

Complete Generator

#include <coroutine>
#include <iostream>
#include <stdexcept>

template<typename T>
struct Generator {
    struct promise_type {
        T value;
        std::exception_ptr exception;
        
        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 {}; }
        
        void return_void() {}
        
        void unhandled_exception() {
            exception = std::current_exception();
        }
        
        std::suspend_always yield_value(T v) {
            value = v;
            return {};
        }
    };
    
    std::coroutine_handle<promise_type> handle;
    
    explicit Generator(std::coroutine_handle<promise_type> h) : handle(h) {}
    
    ~Generator() {
        if (handle) handle.destroy();
    }
    
    // Disable copy
    Generator(const Generator&) = delete;
    Generator& operator=(const Generator&) = delete;
    
    // Enable move
    Generator(Generator&& other) noexcept : handle(other.handle) {
        other.handle = nullptr;
    }
    
    Generator& operator=(Generator&& other) noexcept {
        if (this != &other) {
            if (handle) handle.destroy();
            handle = other.handle;
            other.handle = nullptr;
        }
        return *this;
    }
    
    bool next() {
        if (!handle || handle.done()) return false;
        handle.resume();
        if (handle.promise().exception) {
            std::rethrow_exception(handle.promise().exception);
        }
        return !handle.done();
    }
    
    T value() const {
        return handle.promise().value;
    }
};

// Example: Fibonacci
Generator<int> fibonacci(int n) {
    int a = 0, b = 1;
    for (int i = 0; i < n; ++i) {
        co_yield a;
        int next = a + b;
        a = b;
        b = next;
    }
}

int main() {
    auto fib = fibonacci(10);
    while (fib.next()) {
        std::cout << fib.value() << ' ';
    }
    std::cout << '\n';
    // Output: 0 1 1 2 3 5 8 13 21 34
}

4. Implementing a Task (Asynchronous)

Complete Task

#include <coroutine>
#include <exception>
#include <iostream>

template<typename T>
struct Task {
    struct promise_type {
        T value;
        std::exception_ptr exception;
        
        Task get_return_object() {
            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        
        std::suspend_never initial_suspend() { return {}; }  // Execute immediately
        std::suspend_always final_suspend() noexcept { return {}; }
        
        void return_value(T v) {
            value = v;
        }
        
        void unhandled_exception() {
            exception = std::current_exception();
        }
    };
    
    std::coroutine_handle<promise_type> handle;
    
    explicit Task(std::coroutine_handle<promise_type> h) : handle(h) {}
    
    ~Task() {
        if (handle) handle.destroy();
    }
    
    Task(const Task&) = delete;
    Task& operator=(const Task&) = delete;
    
    Task(Task&& other) noexcept : handle(other.handle) {
        other.handle = nullptr;
    }
    
    T get() {
        if (!handle.done()) {
            handle.resume();
        }
        if (handle.promise().exception) {
            std::rethrow_exception(handle.promise().exception);
        }
        return handle.promise().value;
    }
    
    bool done() const {
        return handle.done();
    }
};

// Example Usage
Task<int> async_compute(int x) {
    // Simulate asynchronous work
    co_return x * x;
}

int main() {
    auto task = async_compute(10);
    std::cout << "Result: " << task.get() << '\n';  // Output: 100
}

5. Awaitable Objects

What Makes an Object Awaitable?

An object is awaitable if it has (or can be converted to an object that has) these three methods:

struct MyAwaitable {
    // 1. Can we skip suspension?
    bool await_ready() const noexcept {
        return false;  // false = suspend, true = skip suspension
    }
    
    // 2. What to do when suspending?
    void await_suspend(std::coroutine_handle<> handle) {
        // Schedule resume, store handle, etc.
    }
    
    // 3. What value to return when resuming?
    int await_resume() const noexcept {
        return 42;
    }
};

Task<int> use_awaitable() {
    int result = co_await MyAwaitable{};  // result = 42
    co_return result;
}

How it works:

  1. await_ready(): Called first. If true, skip suspension and call await_resume() immediately.
  2. await_suspend(): Called if await_ready() returns false. Receives the coroutine handle for later resumption.
  3. await_resume(): Called when the coroutine resumes. Its return value becomes the result of co_await.

Practical example - Async timer:

#include <chrono>
#include <thread>

struct AsyncTimer {
    std::chrono::milliseconds duration;
    
    bool await_ready() const noexcept { return false; }
    
    void await_suspend(std::coroutine_handle<> handle) {
        // Schedule resume after duration
        std::thread([handle, d = duration]() {
            std::this_thread::sleep_for(d);
            handle.resume();
        }).detach();
    }
    
    void await_resume() const noexcept {}
};

Task<void> delayed_task() {
    std::cout << "Starting...\n";
    co_await AsyncTimer{std::chrono::seconds(2)};
    std::cout << "2 seconds later\n";
}

6. Common Errors and Solutions

Error 1: Missing promise_type

// ❌ Error: no member named 'promise_type'
struct BadTask {};

BadTask my_coroutine() {
    co_return;  // Error!
}

// ✅ Solution: Add promise_type
struct GoodTask {
    struct promise_type {
        GoodTask get_return_object() { return {}; }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() {}
    };
};

Error 2: Memory Leak (Forgot to destroy)

// ❌ Memory leak
Generator<int> gen = fibonacci(10);
// ... use gen ...
// Forgot to destroy handle!

// ✅ RAII: Destructor destroys handle
~Generator() {
    if (handle) handle.destroy();
}

Error 3: Use After Destroy

// ❌ Use after destroy
auto gen = fibonacci(10);
gen.next();
gen.handle.destroy();  // Manual destroy
gen.next();  // UB! Handle is destroyed

// ✅ Let RAII handle it
{
    auto gen = fibonacci(10);
    gen.next();
}  // Automatic destruction

7. Production Patterns

Pattern 1: Async I/O

struct AsyncFile {
    std::string path;
    
    bool await_ready() const { return false; }
    
    void await_suspend(std::coroutine_handle<> handle) {
        // Schedule async read
        async_read_file(path, [handle](std::string content) {
            // Store content in promise
            handle.resume();
        });
    }
    
    std::string await_resume() const {
        return content;  // Return file content
    }
    
    std::string content;
};

Task<void> process_file() {
    auto content = co_await AsyncFile{"config.json"};
    std::cout << "File content: " << content << '\n';
}

Pattern 2: Async HTTP

struct AsyncHttpGet {
    std::string url;
    
    bool await_ready() const { return false; }
    
    void await_suspend(std::coroutine_handle<> handle) {
        http_client.get(url, [handle, this](std::string response) {
            this->response = response;
            handle.resume();
        });
    }
    
    std::string await_resume() const {
        return response;
    }
    
    std::string response;
};

Task<void> fetch_data() {
    auto data = co_await AsyncHttpGet{"https://api.example.com/data"};
    std::cout << "Data: " << data << '\n';
}

Pattern 3: Generator with Range

// C++20 ranges-compatible generator
template<typename T>
struct RangeGenerator {
    // ... promise_type ...
    
    struct iterator {
        std::coroutine_handle<promise_type> handle;
        
        iterator& operator++() {
            handle.resume();
            return *this;
        }
        
        T operator*() const {
            return handle.promise().value;
        }
        
        bool operator!=(std::default_sentinel_t) const {
            return !handle.done();
        }
    };
    
    iterator begin() {
        handle.resume();
        return {handle};
    }
    
    std::default_sentinel_t end() { return {}; }
};

// Usage with range-for
RangeGenerator<int> numbers(int n) {
    for (int i = 0; i < n; ++i) {
        co_yield i;
    }
}

int main() {
    for (int n : numbers(5)) {
        std::cout << n << ' ';  // 0 1 2 3 4
    }
}

8. Complete Example: Asynchronous HTTP Client

#include <coroutine>
#include <string>
#include <iostream>
#include <thread>
#include <chrono>

// Simulated async HTTP client
struct HttpClient {
    template<typename Callback>
    void get(const std::string& url, Callback cb) {
        std::thread([url, cb]() {
            std::this_thread::sleep_for(std::chrono::seconds(1));
            cb("Response from " + url);
        }).detach();
    }
};

HttpClient http_client;

// Awaitable HTTP GET
struct AsyncHttpGet {
    std::string url;
    std::string response;
    
    bool await_ready() const { return false; }
    
    void await_suspend(std::coroutine_handle<> handle) {
        http_client.get(url, [handle, this](std::string resp) mutable {
            this->response = resp;
            handle.resume();
        });
    }
    
    std::string await_resume() const {
        return response;
    }
};

// Task implementation
template<typename T>
struct Task {
    struct promise_type {
        T value;
        std::exception_ptr exception;
        std::coroutine_handle<> continuation;
        
        Task get_return_object() {
            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        
        void return_value(T v) { value = v; }
        void unhandled_exception() { exception = std::current_exception(); }
    };
    
    std::coroutine_handle<promise_type> handle;
    
    explicit Task(std::coroutine_handle<promise_type> h) : handle(h) {}
    ~Task() { if (handle) handle.destroy(); }
    
    Task(const Task&) = delete;
    Task(Task&& other) noexcept : handle(other.handle) {
        other.handle = nullptr;
    }
    
    T get() {
        if (!handle.done()) handle.resume();
        if (handle.promise().exception) {
            std::rethrow_exception(handle.promise().exception);
        }
        return handle.promise().value;
    }
};

// Usage
Task<std::string> fetch_user_data(int user_id) {
    auto response = co_await AsyncHttpGet{"https://api.example.com/users/" + std::to_string(user_id)};
    co_return response;
}

int main() {
    auto task = fetch_user_data(123);
    std::cout << "User data: " << task.get() << '\n';
    return 0;
}

9. Performance Considerations

Memory Allocation

Coroutine frames are heap-allocated by default, but the compiler can optimize this with HALO (Heap Allocation eLision Optimization).

// Heap allocation (default)
Generator<int> gen = fibonacci(10);  // Frame allocated on heap

// HALO optimization (compiler-dependent)
// If the coroutine lifetime is fully contained within caller,
// the compiler may allocate the frame on the stack.

Custom allocator:

struct promise_type {
    void* operator new(std::size_t size) {
        std::cout << "Allocating " << size << " bytes\n";
        return ::operator new(size);
    }
    
    void operator delete(void* ptr) {
        std::cout << "Deallocating\n";
        ::operator delete(ptr);
    }
    
    // ... other methods ...
};

Suspension Overhead

Each co_await or co_yield involves:

  1. Saving local state
  2. Returning control to caller
  3. Later: restoring state and resuming

Performance tips:

  • Minimize suspensions: Don’t co_await in tight loops.
  • Batch operations: Await once for multiple items.
  • Use symmetric transfer: Return coroutine_handle from await_suspend() for zero-overhead chaining.
// ❌ Suspend in loop (slow)
Task<void> bad() {
    for (int i = 0; i < 1000; ++i) {
        co_await async_operation();  // 1000 suspensions
    }
}

// ✅ Batch operations
Task<void> good() {
    std::vector<Task<void>> tasks;
    for (int i = 0; i < 1000; ++i) {
        tasks.push_back(async_operation());
    }
    co_await wait_all(tasks);  // 1 suspension
}

Symmetric Transfer

Symmetric transfer allows one coroutine to directly resume another without returning to the caller, eliminating stack overhead.

struct Awaitable {
    std::coroutine_handle<> next_handle;
    
    bool await_ready() const { return false; }
    
    // Return handle to resume directly (symmetric transfer)
    std::coroutine_handle<> await_suspend(std::coroutine_handle<> handle) {
        return next_handle;  // Resume next_handle directly
    }
    
    void await_resume() const {}
};

Summary

ItemDescription
Keywordsco_await, co_yield, co_return
Promise TypeDefines coroutine behavior (suspension, return, exceptions)
GeneratorLazy sequence with co_yield
TaskAsynchronous computation with co_await
AwaitableObject with await_ready, await_suspend, await_resume
MemoryHeap-allocated by default, HALO optimization possible

FAQ

Q: When should I use coroutines?

A: Use coroutines for asynchronous I/O, generators, or when you want to write async code in a synchronous style. Avoid for CPU-bound tasks where threads or thread pools are more appropriate.

Q: What’s the difference between Generator and Task?

A: Generator uses co_yield for lazy sequences (pull model). Task uses co_await for asynchronous operations (push model).

Q: How do I handle exceptions in coroutines?

A: Implement unhandled_exception() in the promise type to catch exceptions. Store the exception pointer and rethrow it when the caller accesses the result.

Q: Are coroutines zero-cost?

A: No, there’s overhead for heap allocation and suspension. However, HALO optimization can eliminate heap allocation in some cases, and symmetric transfer can eliminate stack overhead.

Q: Can I use coroutines with existing async libraries?

A: Yes, implement custom awaitable objects that integrate with your async library’s callbacks or event loops.

Related posts: C++20 Concepts, C++20 Modules, Future and Promise.

One-line summary: C++20 coroutines enable writing asynchronous code in a synchronous style using co_await, co_yield, and co_return with custom promise types.

## 같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

- [C++20 Concepts 완벽 가이드 | 템플릿 제약의 새 시대](/blog/cpp-concept/)
- [C++20 Modules 완벽 가이드 | 헤더 파일을 넘어서](/blog/cpp-module/)

---

---

## 이 글에서 다루는 키워드 (관련 검색어)

C++, coroutine, cpp20, async, generator, co_await 등으로 검색하시면 이 글이 도움이 됩니다.