Exploring the Magic of C++20 Coroutines: A Practical Guide

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:

  1. co_await – Suspends a coroutine until a awaitable object signals completion.
  2. co_yield – Produces a value to the caller, suspending until the next value is requested.
  3. 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

cancelled` flag can be checked before each `co_yield` or `co_await`. ### 8. Performance Considerations * **State machine size** – Every suspension point introduces a frame; avoid too many small suspensions. * **Stack usage** – Coroutines use the heap for their promise objects, but local variables are stored on the stack unless captured by the promise. * **Detaching threads** – In the `Sleep` example we detached a thread; in production use thread pools or async I/O libraries to avoid thread proliferation. ### 9. Summary Coroutines bring a new paradigm to C++: write asynchronous code in a linear style, free from callbacks and promise chains. The key steps are: 1. **Define the promise type** that describes the coroutine’s lifecycle. 2. **Use `co_await`/`co_yield`** to suspend and resume execution. 3. **Leverage standard library helpers** (`generator`, `task` in libraries like cppcoro) to reduce boilerplate. Once mastered, coroutines enable elegant solutions for high‑performance I/O, lazy computations, and structured concurrency—transforming how modern C++ applications are architected.

发表评论