跳至内容

C++20 协程

理解协程作为可挂起函数的概念。学习 co_yield、co_return、co_await、生成器模式以及异步 I/O 和状态机等实际用例。

什么是协程?

协程是一种可以挂起执行并在之后恢复的函数。与从头运行到尾的普通函数不同,协程可以在执行中途暂停、产出中间结果,并从中断的地方继续。这与线程有根本区别:协程是协作式的(它们选择何时挂起)、轻量的(无需操作系统线程)且确定性的(没有并发访问导致的数据竞争)。

C++20 通过三个新关键字在语言层面引入了协程支持:co_yield(挂起并产出一个值)、co_return(以最终值完成协程),以及 co_await(挂起直到异步操作完成)。任何包含这些关键字之一的函数都自动成为协程。

生成器模式

生成器是一种惰性产出值序列的协程。每次调用 co_yield 都会挂起协程并向调用者传递一个值。以下是一个完整的、可编译的生成器实现:

generator.cpp
#include <iostream>
#include <coroutine>
#include <optional>

// A simple Generator type
template<typename T>
struct Generator {
    struct promise_type {
        T current_value;

        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 {}; }
        std::suspend_always yield_value(T value) {
            current_value = std::move(value);
            return {};
        }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };

    std::coroutine_handle<promise_type> handle;

    explicit Generator(std::coroutine_handle<promise_type> h) : handle(h) {}
    ~Generator() { if (handle) handle.destroy(); }

    // Move only
    Generator(Generator&& other) noexcept : handle(other.handle) { other.handle = nullptr; }
    Generator& operator=(Generator&&) = delete;
    Generator(const Generator&) = delete;

    bool next() {
        handle.resume();
        return !handle.done();
    }

    T value() const { return handle.promise().current_value; }
};

// A coroutine that generates Fibonacci numbers
Generator<long long> fibonacci() {
    long long a = 0, b = 1;
    while (true) {
        co_yield a;
        auto next = a + b;
        a = b;
        b = next;
    }
}

// A coroutine that generates a range
Generator<int> range(int start, int end) {
    for (int i = start; i < end; ++i) {
        co_yield i;
    }
}

int main() {
    auto fib = fibonacci();
    std::cout << "First 10 Fibonacci numbers: ";
    for (int i = 0; i < 10 && fib.next(); ++i) {
        std::cout << fib.value() << " ";
    }
    std::cout << "\n";  // 0 1 1 2 3 5 8 13 21 34

    auto r = range(1, 6);
    while (r.next()) {
        std::cout << r.value() << " ";  // 1 2 3 4 5
    }
    std::cout << "\n";

    return 0;
}

协程机制:Promise 与 Handle

每个协程都有两个关键组件:

promise_type ── 控制协程的行为。编译器在协程的返回类型中查找嵌套的 promise_type。关键方法:
- get_return_object() ── 创建返回给调用者的对象
- initial_suspend() ── 返回 suspend_always(惰性启动)或 suspend_never(急切启动)
- final_suspend() ── 协程结束时执行的操作(必须是 noexcept)
- yield_value(T) ── 在 co_yield 时被调用
- return_value(T) 或 return_void() ── 在 co_return 时被调用
- unhandled_exception() ── 在异常逃逸时被调用

std::coroutine_handle

── 指向协程帧的类型擦除指针。提供 resume()、done() 和 destroy() 方法。协程帧存活在堆上,包含所有局部变量和挂起点。

流程为:调用者创建协程 -> initial_suspend -> 调用者调用 resume() -> 协程运行直到 co_yield/co_return/co_await -> 控制返回调用者 -> 重复。

co_await 与可等待对象

co_await 挂起协程直到异步操作完成。任何实现了 Awaitable 接口的类型都可以与 co_await 一起使用。这是异步 I/O 框架的基础:

co_await_example.cpp
#include <iostream>
#include <coroutine>
#include <chrono>
#include <thread>

// A simple awaitable that simulates async work
struct AsyncTimer {
    std::chrono::milliseconds duration;

    bool await_ready() const noexcept {
        return duration.count() <= 0;  // skip suspend if no wait needed
    }

    void await_suspend(std::coroutine_handle<> handle) const {
        // In production, this would register with an event loop
        // Here we simulate with a thread
        std::thread([handle, d = duration]() {
            std::this_thread::sleep_for(d);
            handle.resume();  // resume the coroutine
        }).detach();
    }

    void await_resume() const noexcept {
        // Called when the coroutine resumes — return value becomes
        // the result of the co_await expression
    }
};

// The three Awaitable methods:
// 1. await_ready()  — can we skip suspending? (optimization)
// 2. await_suspend() — what to do when suspending
// 3. await_resume()  — what value to produce when resuming

// A Task type for async coroutines
struct Task {
    struct promise_type {
        Task get_return_object() { return {}; }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
};

Task async_work() {
    std::cout << "Starting async work...\n";
    co_await AsyncTimer{std::chrono::milliseconds(100)};
    std::cout << "Phase 1 complete\n";
    co_await AsyncTimer{std::chrono::milliseconds(100)};
    std::cout << "Phase 2 complete\n";
}

int main() {
    async_work();
    std::this_thread::sleep_for(std::chrono::milliseconds(500));
    return 0;
}

实际用例

协程在多个实际场景中表现出色:

生成器 / 惰性序列 ── 按需产出值,无需预先计算整个序列。非常适合无限序列、文件解析和数据管道。

异步 I/O ── 网络服务器、数据库查询和文件操作可以 co_await 完成而不阻塞线程。cppcoro、libunifex 和 Boost.Asio 等库提供了支持协程的 I/O 原语。

状态机 ── 每个 co_yield 或 co_await 代表一个状态转换。协程的局部变量自然地在转换之间保持状态,消除了手动状态结构体。

事件驱动编程 ── UI 框架和游戏循环可以使用协程来表达跨越多个帧或事件的顺序逻辑。

解析 ── 词法分析器和解析器可以在处理输入时 co_yield 标记或 AST 节点,隐式地维护解析状态。

最佳实践

协程和线程解决不同的问题:

使用协程的场景:需要协作式多任务、惰性求值或无操作系统线程开销的异步 I/O。协程没有上下文切换开销,不需要同步原语,并且可以扩展到数百万个并发任务。

使用线程的场景:需要跨 CPU 核心的真正并行来处理 CPU 密集型工作,或者需要抢占式调度(一个永不挂起的协程会阻塞其线程)。

最佳实践:对 I/O 密集型并发使用协程,对 CPU 密集型并行使用线程。许多高性能服务器将两者结合:线程池运行协程,每个协程使用 co_await 处理一个连接的 I/O。

常见陷阱

悬空引用:协程参数被拷贝到帧中,但对临时对象的引用可能悬空。始终按值传递参数或确保被引用对象的生命周期超过协程。

堆分配:每个协程都在堆上分配一个帧。对于非常热的、短生命周期的协程,这可能是性能问题(尽管编译器可以通过堆分配省略优化 ── HALO 来优化)。

调试困难:协程栈在调试器中更难检查。挂起状态在堆上而非调用栈上。使用日志和结构化并发来保持可调试性。

无标准库类型:C++20 提供了语言原语但没有标准的 Generator 或 Task 类型。你必须自己编写或使用库。C++23 添加了 std::generator。

关键要点
  • 协程是可挂起函数 ── 它们通过 co_yield/co_await 暂停并在之后恢复
  • 生成器模式产出惰性序列:每个 co_yield 挂起并传递一个值
  • co_await 挂起直到异步操作完成 ── 可等待对象的三个方法控制行为
  • promise_type 和 coroutine_handle 构成连接协程与调用者的机制
  • 协程是协作式的(无抢占)且轻量的(无操作系统线程)── 非常适合 I/O 密集型并发
  • C++20 仅提供原语;使用 std::generator(C++23)或 cppcoro 等库进行生产代码开发

测验 — 检验你的知识

(20 经验值)

1. 哪个关键字使函数在 C++20 中成为协程?

2. `promise_type::yield_value(T)` 的目的是什么?

3. 协程与线程有何不同?