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
- Basic Keywords: co_await, co_yield, co_return
- Promise Type
- Implementing a Generator
- Implementing a Task (Asynchronous)
- Awaitable Objects
- Common Errors and Solutions
- Production Patterns
- Complete Example: Asynchronous HTTP Client
- 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:
- Allocate coroutine frame: The compiler allocates memory for the coroutine state (local variables, parameters, promise).
- Construct promise_type: The promise object is constructed in the frame.
- get_return_object(): Creates the coroutine object that will be returned to the caller.
- initial_suspend(): Determines if the coroutine starts immediately or suspends.
suspend_always: Coroutine suspends, caller must callresume().suspend_never: Coroutine executes immediately until first suspension point.
- Execute body: The coroutine body runs until
co_yield,co_await, orco_return. - final_suspend(): Called when coroutine finishes. Usually returns
suspend_alwaysto allow cleanup. - 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
| Method | Purpose | Return Type |
|---|---|---|
get_return_object() | Create coroutine object | Coroutine return type |
initial_suspend() | Start suspended or not | suspend_always / suspend_never |
final_suspend() | End suspended or not | suspend_always / suspend_never |
return_void() | Handle co_return; | void |
return_value(T) | Handle co_return value; | void |
unhandled_exception() | Handle exceptions | void |
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:
- await_ready(): Called first. If
true, skip suspension and callawait_resume()immediately. - await_suspend(): Called if
await_ready()returnsfalse. Receives the coroutine handle for later resumption. - 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:
- Saving local state
- Returning control to caller
- Later: restoring state and resuming
Performance tips:
- Minimize suspensions: Don’t
co_awaitin tight loops. - Batch operations: Await once for multiple items.
- Use symmetric transfer: Return
coroutine_handlefromawait_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
| Item | Description |
|---|---|
| Keywords | co_await, co_yield, co_return |
| Promise Type | Defines coroutine behavior (suspension, return, exceptions) |
| Generator | Lazy sequence with co_yield |
| Task | Asynchronous computation with co_await |
| Awaitable | Object with await_ready, await_suspend, await_resume |
| Memory | Heap-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 등으로 검색하시면 이 글이 도움이 됩니다.