C++20 introduced coroutines as a powerful language feature that allows developers to write asynchronous code in a natural, sequential style. While the underlying implementation involves complex state machines, the syntax and concepts are intentionally designed to be approachable. In this article, we’ll walk through the fundamentals of coroutines, examine how they integrate with existing C++ constructs, and showcase a real-world example of building an asynchronous iterator for file processing.
1. The What and Why
A coroutine is a function that can suspend its execution (co_await, co_yield, or co_return) and later resume from the same point. This is especially useful for:
- Asynchronous I/O – Avoid blocking the main thread while waiting for network or disk operations.
- Lazy Evaluation – Generate values on demand, useful for large data streams or infinite sequences.
- Structured Concurrency – Combine multiple asynchronous tasks with simple syntax.
The benefit is that you can write code that reads sequentially, even though the underlying execution is non‑blocking and potentially concurrent.
2. The Building Blocks
C++ coroutines are built on three key language constructs:
co_await– Suspends a coroutine until aawaitableobject signals completion.co_yield– Produces a value to the caller, suspending until the next value is requested.co_return– Terminates the coroutine, optionally returning a value.
Additionally, the compiler expects a promise type that the coroutine generates. The promise type defines how to handle suspension, resumption, and the final return value.
3. Writing a Simple Awaitable
An awaitable is any type that provides two member functions:
bool await_ready(); // Called immediately; return true if the coroutine can continue without suspension.
void await_suspend(std::coroutine_handle<> h); // Called if await_ready() returns false; responsible for scheduling the coroutine.
T await_resume(); // Called when the coroutine resumes; provides the value returned to the caller.
A minimal example that simulates a 1‑second delay:
#include <coroutine>
#include <chrono>
#include <thread>
struct Sleep {
std::chrono::milliseconds duration;
bool await_ready() noexcept { return duration.count() == 0; }
void await_suspend(std::coroutine_handle<> h) {
std::thread([h, dur = duration]() {
std::this_thread::sleep_for(dur);
h.resume();
}).detach();
}
void await_resume() noexcept {}
};
Using it:
await Sleep{std::chrono::seconds(1)};
4. A Coroutine Returning a Value
Consider a coroutine that fetches a string from a URL asynchronously. The promise type handles the result:
#include <coroutine>
#include <string>
#include <iostream>
struct AsyncString {
struct promise_type {
std::string value;
AsyncString get_return_object() { return AsyncString{std::coroutine_handle <promise_type>::from_promise(*this)}; }
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void unhandled_exception() { std::terminate(); }
void return_value(std::string v) { value = std::move(v); }
};
std::coroutine_handle <promise_type> coro;
std::string get() { return coro.promise().value; }
};
AsyncString fetch_url(const std::string& url) {
std::string result = "Simulated response from " + url;
co_return result;
}
int main() {
auto async_res = fetch_url("https://example.com");
std::cout << async_res.get() << '\n';
}
In real code you would replace the simulated response with an asynchronous HTTP client that yields a promise of the result.
5. Asynchronous Iterator – Lazy File Reader
One powerful use of coroutines is to implement lazy iterators. Below is a minimal coroutine that reads lines from a file one at a time without loading the whole file into memory:
#include <coroutine>
#include <string>
#include <fstream>
struct LineStream {
struct promise_type {
std::string current_line;
std::fstream file;
LineStream get_return_object() {
return LineStream{std::coroutine_handle <promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void unhandled_exception() { std::terminate(); }
void return_void() {}
std::suspend_always yield_value(std::string line) {
current_line = std::move(line);
return {};
}
};
std::coroutine_handle <promise_type> coro;
std::string value() const { return coro.promise().current_line; }
bool done() const { return !coro || coro.done(); }
};
LineStream read_lines(const std::string& path) {
LineStream::promise_type::file f{path, std::ios::in};
if (!f) throw std::runtime_error("Unable to open file");
std::string line;
while (std::getline(f, line)) {
co_yield std::move(line);
}
}
Usage:
int main() {
for (auto lines = read_lines("big_log.txt"); !lines.done(); lines.coro.resume()) {
std::cout << lines.value() << '\n';
}
}
Because the coroutine suspends on each co_yield, memory consumption remains constant regardless of file size.
6. Combining with std::experimental::generator (C++20)
C++20 standard library offers a lightweight generator template that encapsulates the boilerplate. Re‑writing the file reader:
#include <experimental/generator>
#include <fstream>
#include <string>
std::experimental::generator<std::string> read_lines(std::string const& path) {
std::ifstream file(path);
std::string line;
while (std::getline(file, line)) {
co_yield line;
}
}
This yields a generator that can be used in a range‑based for:
for (auto const& line : read_lines("big_log.txt")) {
std::cout << line << '\n';
}
7. Error Handling and Cancellation
Coroutines can throw exceptions just like regular functions. The promise’s unhandled_exception() can be customized to propagate errors to callers. For cancellation, a `std::atomic