C++20 协程深入:co_await 机制、自定义 Awaitable 与生成器

深入 C++20 无栈协程的编译器视角:协程帧布局、co_await/co_yield 的展开机制、promise_type 与自定义 Awaitable、C++23 生成器与异步流,以及协程在服务端 IO 与性能陷阱上的实战经验。

一、协程的编译器视角:无栈的代价与回报

1. 无栈协程 vs 有栈协程

C++20 引入的是无栈协程(stackless)。与 Go goroutine、Java 虚拟线程这类有栈协程不同,C++ 协程不拥有独立的执行栈,而是把一个函数"切"成可挂起/恢复的多个片段。

这带来三个关键后果:

  • 极低的切换成本:恢复协程只是函数返回 + 跳转,没有内核调度、没有栈切换,通常数十纳秒量级;
  • 不适用任意嵌套:无栈协程挂起时只能让出控制权给直接调用者,不能在嵌套调用链深处挂起(被调用的普通函数内部无法 co_await);
  • 状态显式化:局部变量必须存放到堆分配的协程帧中,编译器负责这部分的搬运。

正是这三点,让 C++ 协程特别适合写生成器与异步 I/O 状态机,而非通用调度器。

2. 协程帧(Coroutine Frame)的布局

当函数体出现 co_await / co_yield / co_return 之一时,该函数成为协程。编译器会为它构造一个协程帧:

协程帧(一般堆分配)
├── promise 对象(用户定义的 promise_type 实例)
├── 挂起点的程序计数器(恢复时的跳转地址)
├── 参数拷贝(按值存入帧,保证生命周期)
└── 跨挂起点存活的局部变量

帧的分配默认使用 operator new,但可以通过重载 promise_type 的 operator new 或使用分配器定制——这是性能优化的关键入口(见第五节)。

3. 编译器把协程翻译成什么

理解协程的最佳方式是记住编译器的"契约":调用协程函数时,先构造协程帧,在帧内构造 promise,然后调用 promise.get_return_object() 返回给调用者;调用者拿到的是一个句柄,协程主体尚未执行。

执行按 promise_type 定义的初始挂起点 initial_suspend() 决定:

  • 返回 suspend_never{}:协程体立即执行,直到第一个挂起点或结束;
  • 返回 suspend_always{}:协程体延迟执行,需要手动 resume()。
#include <coroutine>
#include <iostream>

struct Task {
    struct promise_type {
        Task get_return_object() { return Task{}; }
        std::suspend_never initial_suspend() { return {}; } // 立即执行
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
};

Task hello() {
    std::cout << "coroutine body\n";
    co_return;
}

int main() {
    hello();               // 立即打印 "coroutine body"
    std::cout << "main\n"; // 随后打印
}

二、co_await 的展开机制

1. 从语法糖到状态机

co_await expr 是协程挂起/恢复的核心。编译器把它展开成一个状态机转换。以 co_await some_awaitable 为例,逻辑等价于:

// 伪代码:编译器对 co_await expr 的展开
auto awaiter = expr.operator co_await();        // ① 获取 awaiter
if (!awaiter.await_ready()) {                   // ② 是否已就绪?
    // ③ 挂起:保存当前状态,调用 awaiter.await_suspend(handle)
    awaiter.await_suspend(coroutine_handle);
    return;                                     // 挂起,让出控制权
}
// 恢复后:
auto result = awaiter.await_resume();           // ④ 获取结果

四种情况决定行为:

  • await_ready() 返回 true:不挂起,直接 await_resume(),无任何状态保存开销;
  • await_suspend() 返回 void:无条件挂起,恢复时从 await_resume() 继续;
  • await_suspend() 返回 bool:返回 true 挂起,false 立即继续(此时 awaiter 已被销毁,不允许接触其成员);
  • await_suspend() 返回 coroutine_handle<>:对称转移,直接切换到另一个协程,见第五节。

2. 手写一个最小 Awaitable

#include <coroutine>
#include <thread>
#include <chrono>

// 一个在后台线程睡眠后恢复的 awaitable
struct SleepAwaitable {
    std::chrono::milliseconds ms;

    // 是否已就绪:总是 false,必须等待
    bool await_ready() const noexcept { return false; }

    // 挂起:启动后台任务,保存 handle 供恢复用
    void await_suspend(std::coroutine_handle<> h) {
        std::thread([h, ms = this->ms] {
            std::this_thread::sleep_for(ms);
            h.resume();        // 睡眠结束后恢复协程
        }).detach();
    }

    void await_resume() noexcept {}
};

struct Task2 {
    struct promise_type {
        Task2 get_return_object() { return {}; }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() {}
    };
};

Task2 demo() {
    std::cout << "sleep start\n";
    co_await SleepAwaitable{std::chrono::milliseconds(500)};
    std::cout << "sleep end\n";
}

int main() {
    demo();
    std::this_thread::sleep_for(std::chrono::seconds(1));
}

注意 h.resume() 必须线程安全——如果事件循环线程与恢复线程不同,需要保证 resume() 与 run() 的同步关系(通常通过任务队列投递)。

三、promise_type:协程的"人格"

1. promise_type 的方法与生命周期

每个协程帧内都含一个 promise_type 对象。它决定了协程的对外行为,其方法调用时机如下:

方法调用时机作用
get_return_object()构造帧后立即生成返回给调用者的对象(句柄)
initial_suspend()帧构造完成后决定协程体是否立即执行
return_void() / return_value(v)co_return 时传递返回值
yield_value(v)co_yield 时产出值并可选挂起
final_suspend()协程体结束决定帧如何清理
unhandled_exception()异常逃逸异常处理钩子

帧的销毁时机由 final_suspend() 决定:若返回 suspend_always,协程在结束后仍保持"已完成"状态,由调用方通过句柄显式 destroy();若返回 suspend_never,帧在结束瞬间自动销毁——但此时调用方无法安全地获取结果。生成器惯例是 final_suspend 返回 suspend_always,让消费者遍历完再统一清理。

2. 一个能返回值的 Task

#include <coroutine>
#include <optional>
#include <exception>

template<typename T>
struct ValueTask {
    struct promise_type {
        ValueTask get_return_object() { return {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_.emplace(std::move(v)); }
        void unhandled_exception() { exc_ = std::current_exception(); }

        std::optional<T> value_;
        std::exception_ptr exc_;
    };

    std::coroutine_handle<promise_type> h_;

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

    ValueTask(const ValueTask&) = delete;
    ValueTask& operator=(const ValueTask&) = delete;

    T get() {
        h_.resume();
        if (h_.promise().exc_) std::rethrow_exception(h_.promise().exc_);
        return std::move(*h_.promise().value_);
    }
};

ValueTask<int> compute() {
    co_return 42;
}

int main() {
    ValueTask<int> t = compute();
    int result = t.get();   // 42
}

3. 常见实现错误

  • 忘记 initial_suspend 为 suspend_always 导致协程在 get_return_object() 返回前就跑完,值已丢失;
  • 移动/拷贝语义被隐式保留:coroutine_handle 是普通指针包装,必须 delete 拷贝,否则双重 destroy();
  • final_suspend 返回 suspend_never 时在 get() 中 resume() 已销毁的帧 → 未定义行为。

四、生成器与异步流

1. C++23 std::generator

C++23 标准库引入了 std::generator,终结了手写生成器 promise 的痛苦。它基于 co_yield 产出值,支持懒求值:

#include <generator>
#include <iostream>

std::generator<int> fibonacci(int n) {
    int a = 0, b = 1;
    for (int i = 0; i < n; ++i) {
        co_yield a;      // 产出当前值并挂起
        int t = a + b; a = b; b = t;
    }
}

int main() {
    for (int v : fibonacci(8)) {
        std::cout << v << ' ';   // 0 1 1 2 3 5 8 13
    }
}

co_yield a 实际上是 co_await promise.yield_value(a) 的语法糖。std::generator 内部已处理好 final_suspend、异常传播与资源清理,是生产代码的首选。

2. 手写 generator 理解原理

为了看清本质,实现一个最小生成器(在 C++23 之前可用):

#include <coroutine>

template<typename T>
struct Gen {
    struct promise_type {
        T current;

        Gen get_return_object() {
            return Gen{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() noexcept { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        std::suspend_always yield_value(T v) {
            current = std::move(v);   // 存值
            return {};                // 挂起
        }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };

    std::coroutine_handle<promise_type> h_;

    explicit Gen(std::coroutine_handle<promise_type> h) : h_(h) {}
    ~Gen() { if (h_) h_.destroy(); }
    Gen(Gen&& o) noexcept : h_(o.h_) { o.h_ = nullptr; }
    Gen& operator=(Gen&& o) noexcept { std::swap(h_, o.h_); return *this; }
    Gen(const Gen&) = delete;
    Gen& operator=(const Gen&) = delete;

    bool next() {
        if (!h_) return false;
        h_.resume();                    // 推进到下一个 co_yield
        return !h_.done();
    }
    T value() const { return h_.promise().current; }
};

Gen<int> range(int n) {
    for (int i = 0; i < n; ++i) co_yield i;
}

int main() {
    Gen<int> g = range(5);
    while (g.next()) {
        // 使用 g.value()
    }
}

注意 co_yield 让 resume() 在每次产出后挂起,所以 next() 每次只推进一个值——这就是懒生成器的全部秘密。

3. 异步流:事件驱动的数据管道

把生成器与异步 I/O 结合,就得到异步流:数据到达时产出,数据未到时挂起协程,不占线程。概念上等价于 cppcoro::async_generator:

// 伪代码:从 socket 读取"行"的异步流
AsyncStream<std::string> read_lines(tcp::socket& sock) {
    std::string line;
    char c;
    while (true) {
        // co_await 一次异步读一个字节(仅示意)
        auto [ec, n] = co_await sock.async_read_some(net::buffer(&c, 1),
            net::as_tuple(net::use_awaitable));
        if (ec) co_return;
        if (c == '\n') { co_yield std::move(line); line.clear(); }
        else line += c;
    }
}

异步流的价值在于:管道中每一级都不阻塞线程,数据就像流水线一样从 socket 流过各处理协程。这正是协程在服务端 I/O 中的杀手级应用。

五、协程在服务端 I/O 的应用

1. 与事件循环结合

协程本身不产生并发,它需要一个驱动器——通常是 io_context/事件循环。Asio 提供 net::awaitable<T> 与 net::co_spawn 把两者粘合:

#include <boost/asio/co_spawn.hpp>
#include <boost/asio/use_awaitable.hpp>

net::awaitable<void> echo(tcp::socket sock) {
    char buf[4096];
    for (;;) {
        auto [ec, n] = co_await sock.async_read_some(
            net::buffer(buf), net::as_tuple(net::use_awaitable));
        if (ec) co_return;
        co_await net::async_write(sock, net::buffer(buf, n), net::use_awaitable);
    }
}

void run_server(net::io_context& io, tcp::acceptor& acceptor) {
    for (;;) {
        tcp::socket sock = co_await acceptor.async_accept(net::use_awaitable);
        net::co_spawn(io, echo(std::move(sock)), net::detached);
    }
}

co_await ... use_awaitable 让 Asio 的异步操作适配协程的 await 协议,回调链被拍平成线性代码。单线程事件循环配合协程,可以支撑数十万连接——每个连接只是一个协程帧(堆上约几百字节),而非一个线程栈。

2. 连接数对比

模型每连接资源万级连接成本代码形态
每连接一线程约 8MB 栈 + TCB数十 GB,不可行同步线性
异步回调少量堆内存可行回调嵌套
协程 + 事件循环一个协程帧(几百字节)轻松同步线性

3. 与其他语言协程的对照

C++ 协程是库导向的:语言只提供机制(promise_type/awaitable 协议),标准库不强制调度策略。相比之下,Python asyncio、Rust tokio 都有配套运行时。C++ 阵营中 Asio、cppcoro、Folly 各成生态,这也是 C++ 协程学习曲线陡峭的原因之一。

六、性能与陷阱

1. 堆分配与帧定制

协程帧默认走 operator new,高频率创建/销毁协程会带来分配开销。优化手段:

  • 在 promise_type 中定义 operator new,改用 arena 或线程本地缓存(参考 https://plumephp.com/cpp-memory-pool-allocators/);
  • 编译器优化:某些情况下帧可省略分配(全内联、无跨挂点存活变量时);
  • 避免在热路径上创建"一次性小协程",可用普通函数替代。

2. 悬垂引用与生命周期

协程帧的存活期与调用者的作用域无关。捕获的引用(如 &sock、this)必须在协程帧销毁前保持有效。典型错误:

net::awaitable<void> bad(net::io_context& io) {
    int local = 0;                 // 存入协程帧,OK
    co_await SleepAwaitable{...};
    // 若在此之后使用外部引用,必须保证引用对象仍存活
}

3. 对称转移(Symmetric Transfer)

当 await_suspend 返回另一个 coroutine_handle 时,编译器直接跳转到那个协程,而不是先返回再 resume。这避免了"恢复-挂起"的交替调用栈深度累积:

// 深链式调度:每个 await_suspend 返回下一个 handle,栈不增长
std::coroutine_handle<> await_suspend(std::coroutine_handle<> h) {
    return next_coroutine;   // 对称转移,而非 h.resume() 后返回
}

若在对称转移场景误用 resume() 递归,可能导致栈溢出(如深度优先遍历一棵大树的异步版本)。这是协程库作者需要警惕,应用开发者较少直接触及的细节。

4. 性能小结

  • 挂起/恢复:纳秒级,优于任何有栈协程切换;
  • 帧分配:若未定制,与 malloc 同量级,热路径必须优化;
  • 代码膨胀:协程状态机展开会使二进制体积增加,嵌入式场景需权衡(见 https://plumephp.com/cpp-embedded-game-engine-integration/)。

七、总结

C++20 协程把"挂起点"从语言内置原语(setjmp/longjmp 或汇编栈切换)抽象为可组合的 awaitable 协议,让异步代码回到线性的阅读形态。掌握它需要三层认知:

  1. 机制层:协程帧 + promise_type + awaiter 三者的生命周期契约;
  2. 工具层:std::generator(C++23)、Asio awaitable、cppcoro 等现成轮子,避免重复造 Promise;
  3. 陷阱层:帧生命周期、对称转移、堆分配开销。

正如前面 https://plumephp.com/cpp-modern-17-20-23/ 所提示的,协程 API 偏底层,生产落地建议直接选用成熟的第三方库而非手写 Promise。当异步 I/O 规模上升时,协程 + 事件循环的组合将是 C++ 服务端超越"每连接一线程"限制的正统路径。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cpp」更多文章

  1. C++ 嵌入式与游戏引擎集成:宿主嵌入、绑定生成与性能内存约束
  2. C++ 跨平台构建矩阵:CMake Presets、包管理器与 CI 矩阵、ABI 兼容
  3. C++ 编译期反射与序列化:模板元编程驱动的结构与高性能二进制协议