Introduction
C++20 coroutines let you write asynchronous code that reads like synchronous code. A coroutine is a function that can suspend execution at specific points (co_await, co_yield, co_return) and resume later without blocking a thread. This eliminates callback nesting and manual state machines for async I/O, generators, and lazy sequences. Unlike std::async or std::thread, coroutines do not create new threads — they provide cooperative multitasking on existing threads.
Basic Coroutine Structure
1#include <coroutine>
2#include <iostream>
3
4// A simple Task type that wraps a coroutine
5struct Task {
6 struct promise_type {
7 Task get_return_object() { return {}; }
8 std::suspend_never initial_suspend() { return {}; }
9 std::suspend_never final_suspend() noexcept { return {}; }
10 void return_void() {}
11 void unhandled_exception() { std::terminate(); }
12 };
13};
14
15Task sayHello() {
16 std::cout << "Hello, ";
17 co_await std::suspend_always{}; // Suspend here
18 std::cout << "World!\n"; // Resume here
19}
A function becomes a coroutine when it uses co_await, co_yield, or co_return. The compiler transforms it into a state machine that can be paused and resumed.
co_await: Asynchronous Operations
1#include <coroutine>
2#include <future>
3#include <iostream>
4
5// Simplified async task
6struct AsyncTask {
7 struct promise_type {
8 int result;
9 AsyncTask get_return_object() {
10 return {std::coroutine_handle<promise_type>::from_promise(*this)};
11 }
12 std::suspend_never initial_suspend() { return {}; }
13 std::suspend_always final_suspend() noexcept { return {}; }
14 void return_value(int value) { result = value; }
15 void unhandled_exception() { std::terminate(); }
16 };
17
18 std::coroutine_handle<promise_type> handle;
19
20 int getResult() { return handle.promise().result; }
21 ~AsyncTask() { if (handle) handle.destroy(); }
22};
23
24AsyncTask fetchData() {
25 // Simulate async work
26 co_return 42;
27}
28
29AsyncTask processData() {
30 auto task = fetchData();
31 int value = task.getResult();
32 co_return value * 2;
33}
co_yield: Generators
Generators produce values lazily — each value is computed on demand:
1#include <coroutine>
2#include <iostream>
3
4template<typename T>
5struct Generator {
6 struct promise_type {
7 T current_value;
8 Generator get_return_object() {
9 return {std::coroutine_handle<promise_type>::from_promise(*this)};
10 }
11 std::suspend_always initial_suspend() { return {}; }
12 std::suspend_always final_suspend() noexcept { return {}; }
13 std::suspend_always yield_value(T value) {
14 current_value = value;
15 return {};
16 }
17 void return_void() {}
18 void unhandled_exception() { std::terminate(); }
19 };
20
21 std::coroutine_handle<promise_type> handle;
22
23 bool next() {
24 handle.resume();
25 return !handle.done();
26 }
27 T value() { return handle.promise().current_value; }
28 ~Generator() { if (handle) handle.destroy(); }
29};
30
31Generator<int> fibonacci() {
32 int a = 0, b = 1;
33 while (true) {
34 co_yield a;
35 auto next = a + b;
36 a = b;
37 b = next;
38 }
39}
40
41int main() {
42 auto fib = fibonacci();
43 for (int i = 0; i < 10 && fib.next(); i++) {
44 std::cout << fib.value() << " ";
45 }
46 // Output: 0 1 1 2 3 5 8 13 21 34
47}
The generator produces Fibonacci numbers one at a time, computing each only when next() is called.
Async I/O Pattern
1// Conceptual async file reader using coroutines
2struct FileReadAwaitable {
3 std::string filename;
4 std::string result;
5
6 bool await_ready() { return false; } // Always suspend
7
8 void await_suspend(std::coroutine_handle<> handle) {
9 // Submit async I/O operation
10 // When I/O completes, resume the coroutine:
11 // io_service.async_read(filename, [handle](auto data) {
12 // result = data;
13 // handle.resume();
14 // });
15 }
16
17 std::string await_resume() { return result; }
18};
19
20AsyncTask processFile() {
21 auto content = co_await FileReadAwaitable{"data.txt"};
22 // Execution continues here after file is read
23 // No blocking, no callback nesting
24 co_return content.size();
25}
The coroutine suspends at co_await, the I/O operation runs asynchronously, and the coroutine resumes when data is ready — all without blocking a thread.
Coroutines vs Callbacks vs Threads
1// CALLBACK STYLE — nested, hard to follow
2void processData_callbacks() {
3 readFile("input.txt", [](auto data) {
4 parseData(data, [](auto parsed) {
5 writeFile("output.txt", parsed, [](auto result) {
6 std::cout << "Done: " << result << "\n";
7 });
8 });
9 });
10}
11
12// COROUTINE STYLE — reads top to bottom
13AsyncTask processData_coroutine() {
14 auto data = co_await readFileAsync("input.txt");
15 auto parsed = co_await parseDataAsync(data);
16 auto result = co_await writeFileAsync("output.txt", parsed);
17 std::cout << "Done: " << result << "\n";
18 co_return;
19}
Coroutines linearize async code. Error handling works with normal try/catch instead of error callbacks.
Lazy Evaluation with Coroutines
1Generator<int> range(int start, int end) {
2 for (int i = start; i < end; i++) {
3 co_yield i;
4 }
5}
6
7Generator<int> filter(Generator<int> gen, auto predicate) {
8 while (gen.next()) {
9 if (predicate(gen.value())) {
10 co_yield gen.value();
11 }
12 }
13}
14
15// Only computes values as needed
16auto evens = filter(range(0, 1000000), [](int x) { return x % 2 == 0; });
17for (int i = 0; i < 5 && evens.next(); i++) {
18 std::cout << evens.value() << " ";
19}
20// Output: 0 2 4 6 8
21// Only computed 9 values from range, not 1,000,000
Library Support
1// cppcoro library (most popular coroutine utility library)
2// Install: vcpkg install cppcoro
3
4#include <cppcoro/task.hpp>
5#include <cppcoro/sync_wait.hpp>
6#include <cppcoro/when_all.hpp>
7
8cppcoro::task<int> fetchA() { co_return 1; }
9cppcoro::task<int> fetchB() { co_return 2; }
10
11cppcoro::task<int> fetchBoth() {
12 auto [a, b] = co_await cppcoro::when_all(fetchA(), fetchB());
13 co_return a + b;
14}
15
16int main() {
17 int result = cppcoro::sync_wait(fetchBoth());
18 std::cout << result << "\n"; // 3
19}
cppcoro provides task<T>, generator<T>, when_all, when_any, and other utilities that make C++20 coroutines practical.
Common Pitfalls
No standard library Task type: C++20 provides the coroutine machinery (co_await, co_yield, co_return) but no standard Task or Generator type. You must write your own or use a library like cppcoro.
Dangling coroutine handles: If you destroy a coroutine_handle while it is suspended, any references to stack variables in the coroutine become invalid. Always ensure proper lifetime management.
Coroutines are not threads: co_await does not create a new thread. If no executor schedules the resume on another thread, the coroutine resumes on whatever thread calls handle.resume(). For true parallelism, integrate with a thread pool.
Heap allocation: Coroutine state is allocated on the heap by default. For performance-critical code, the compiler can sometimes elide this allocation (HALO optimization), but it is not guaranteed.
Compiler support varies: GCC, Clang, and MSVC all support C++20 coroutines but with varying levels of optimization and debugging support. Test on your target compiler.
Summary
C++20 coroutines use co_await, co_yield, and co_return to write suspendable functions
Generators (co_yield) produce values lazily without computing the entire sequence upfront
Async I/O (co_await) eliminates callback nesting by letting you write linear async code
Coroutines do not create threads — they provide cooperative multitasking on existing threads
Use a library like cppcoro for practical Task and Generator types
The promise_type pattern lets you customize suspension, return values, and exception handling