C++协程详解

文章字数:5315

协程(Coroutine)是一种可以在执行中途主动挂起、稍后恢复的函数。和线程不同,协程的挂起不涉及操作系统内核,完全在用户态完成——编译器把函数体切成多段状态机,挂起时保存现场到"协程帧"里,恢复时再跳回来继续执行。

C++20 引入了协程的语言级支持。它没有像 Go 或 Lua 那样提供开箱即用的协程,而是给了三个关键字(co_awaitco_yieldco_return)和一套可定制的基础设施,让库作者在之上构建自己的协程模型。

为什么需要协程

线程有两个根本问题:

  1. 创建和切换开销大——每次线程切换走内核态,保存/恢复整套寄存器,几百纳秒到几微秒
  2. 内存占用大——每个线程独立栈空间,默认 1~8MB。10,000 个线程 ≈ 10GB 内存

协程解决这两个问题的方式很直接:

1
2
3
4
5
线程:  [task A 执行] → [OS 抢占] → [task B 执行] → [OS 抢占] → ...
       ↑ 被动切换,每次进出内核态                  ↑ 独立栈 ~MB

协程:  [task A 执行] → [co_await 挂起] → [task B 执行] → [co_await 挂起] → ...
       ↑ 主动挂起,纯用户态                        ↑ 协程帧 ~KB

对于游戏服务端来说,一个玩家连接就是一个持续的"请求-响应"会话。如果每个连接占一个线程,几百个玩家线程池就崩了。用协程,一万个协程只占几十 MB,CPU 也只在真正有活干的协程之间切换。

三个关键字

C++20 协程的核心建立在三个关键字上。编译器一旦在函数体里看到其中之一,这个函数就不再是普通函数,而是一个协程:

关键字作用
co_await挂起协程,等某个操作完成后再恢复
co_yield挂起协程并产出一个值,后续可以继续执行
co_return结束协程并返回最终值
cpp
1
2
3
4
5
6
7
8
9
// 普通函数
int normal_fn() { return 42; }

// 协程 —— 只要用了 co_* 关键字就是协程
Task<int> coro_fn() {
    int x = co_await some_async_op();  // 挂起点 1
    int y = x * 2;
    co_return y;                        // 挂起点 2(结束)
}

协程的底层组件

C++20 的协程框架把职责拆给了几个角色,理解它们是理解协程的关键。

协程帧(Coroutine Frame)

编译器把协程拆成状态机后,需要一块内存来存所有跨挂起点的局部变量。这块内存就是协程帧,分配在堆上(可被编译器优化为栈分配)。

cpp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
Task<int> compute(std::string name) {
    int a = 100;                   // 在帧上
    int b = co_await fetch(a);     // 挂起!a 和 name 必须保存在帧上
    int c = a + b;                 // a 从上个挂起点恢复
    co_return c + name.size();
}

// 编译后大致等价于:
struct __compute_frame {
    std::string name;       // 参数
    int a, b, c;            // 跨挂起点的局部变量
    promise_type __promise;
    int __state = 0;        // 状态编号:0=起点, 1=co_await后, 2=完成
};
// resume() 时用 switch(__state) 跳到对应位置继续执行

关键:只有跨挂起点还活着的变量才放进帧。如果变量在挂起点之前就析构了,它仍然在普通栈上。编译器的活跃性分析决定了哪些进帧。

promise_type

每个协程关联一个 promise_type(跟 std::promise 无关,只是名字巧合)。它是编译器与协程之间的中间人——控制协程的生命周期、获取返回值、处理异常:

cpp
1
2
3
4
5
6
7
struct Task<T>::promise_type {
    Task<T> get_return_object();    // 协程被调用时,返回给调用者的对象
    suspend_always initial_suspend(); // 协程一开始是否立即挂起
    suspend_always final_suspend();   // 协程结束时是否挂起(不挂起则自动析构帧)
    void return_value(T value);       // co_return value → 走这里
    void unhandled_exception();       // 协程内抛了未捕获异常 → 走这里
};

协程被调用时的流程:

1
2
3
4
5
6
7
8
1. operator new 分配协程帧
2. 把参数拷贝/移动到帧上
3. 在帧上构造 promise_type
4. promise.get_return_object() → 返回给调用者
5. co_await promise.initial_suspend() → 决定是否一开始就挂起
6. 执行协程体直到 co_return 或 co_await 或异常
7. co_await promise.final_suspend() → 决定协程帧是否立即释放
8. 析构 promise + 局部变量 + operator delete 帧

Awaitable 协议

co_await expr 右边的 expr 必须是一个 awaitable,即满足以下三个方法之一的对象:

cpp
1
2
3
4
5
struct Awaitable {
    bool await_ready();                      // 能立刻完成?return true → 不挂起
    void await_suspend(coroutine_handle<>);  // 挂起时调用,获得协程句柄
    T    await_resume();                     // 恢复时调用,返回 co_await 的结果值
};

执行流程:

1
2
3
4
5
6
7
8
9
co_await expr:
  1. expr.await_ready()?
       true  → 直接走 4,不挂起(高效路径)
       false → 进入 2
  2. 挂起协程(保存状态到帧,切换出去)
  3. expr.await_suspend(handle)
       - 把 handle 保存到某个地方(比如 IO 完成队列)
       - 稍后外部事件触发 handle.resume()
  4. expr.await_resume() 的返回值 = co_await 的结果

拿一个简单的"等一秒"来演示:

cpp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
struct SleepAwaiter {
    chrono::milliseconds delay_;

    bool await_ready() { return false; }  // 永远挂起

    void await_suspend(coroutine_handle<> h) {
        // 把协程句柄注册到定时器,delay_ 毫秒后 h.resume()
        timer_system::schedule(delay_, [h] { h.resume(); });
    }

    void await_resume() {}  // 无返回值
};

Task<> wait_and_print() {
    cout << "start" << endl;
    co_await SleepAwaiter{500ms};  // 挂起 500ms
    cout << "500ms later" << endl;
}

这就实现了协程在挂起点 sleep 不占线程——协程句柄注册到定时器后,当前线程立即返回去处理其他协程,500ms 后定时器触发 resume(),协程从断点继续。

coroutine_handle

std::coroutine_handle<> 是协程帧的裸指针包装。你可以通过它:

cpp
1
2
3
4
5
handle.resume();        // 恢复协程执行
handle.done();          // 协程是否已结束
handle.destroy();       // 手动销毁协程帧
handle.address();       // 获取帧地址(可用于调试或哈希)
handle.promise();       // 访问帧内的 promise_type

一个完整的 Generator 示例

Generator 是协程最简单的应用——一个可以 co_yield 多次产出值、外部逐次拉取的惰性序列:

cpp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <coroutine>
#include <iostream>
#include <optional>
using namespace std;

template<typename T>
struct Generator {
    struct promise_type {
        optional<T> current;

        Generator get_return_object() {
            return Generator{coroutine_handle<promise_type>::from_promise(*this)};
        }
        suspend_always initial_suspend() { return {}; }  // 一开始挂起
        suspend_always final_suspend() noexcept { return {}; }
        suspend_always yield_value(T value) {
            current = value;    // 保存产出值
            return {};          // 挂起
        }
        void return_void() {}
        void unhandled_exception() { terminate(); }
    };

    coroutine_handle<promise_type> handle_;

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

    // 不可拷贝
    Generator(const Generator&) = delete;
    Generator& operator=(const Generator&) = delete;
    Generator(Generator&& other) noexcept : handle_(exchange(other.handle_, nullptr)) {}

    // 迭代器:每次 ++ 就 resume 协程取下一个 co_yield 值
    optional<T> next() {
        if (!handle_ || handle_.done()) return nullopt;
        handle_.resume();
        if (handle_.done()) return nullopt;
        return handle_.promise().current;
    }
};

// 使用 Generator 生成斐波那契数列
Generator<int> fibonacci() {
    int a = 0, b = 1;
    while (true) {
        co_yield a;
        auto next = a + b;
        a = b;
        b = next;
    }
}

int main() {
    auto gen = fibonacci();
    for (int i = 0; i < 10; ++i) {
        cout << *gen.next() << " ";  // 0 1 1 2 3 5 8 13 21 34
    }
}

Generator 怎么一步一步跑起来的

把协程想成一盘可以随时暂停继续的录像带——外面每拉一次,里面播一段,碰到暂停点就定住。

fibonacci() 这个函数虽然写了 while (true),但它不是一口气算完所有数。每次 gen.next() 只让它跑一圈 while,碰到 co_yield 就停:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
第 1 次 gen.next():
  a=0, b=1 → co_yield a → 输出 0 → 暂停!(next 还没算呢)
  停在哪里:co_yield 的下一行

第 2 次 gen.next():
  从上次停的地方继续 → next = 0+1 = 1 → a=1, b=1 → 转回 while 头
  → co_yield a → 输出 1 → 暂停!

第 3 次 gen.next():
  继续 → next = 1+1 = 2 → a=1, b=2 → co_yield a → 输出 1 → 暂停!

第 4 次 gen.next():
  继续 → next = 1+2 = 3 → a=2, b=3 → co_yield a → 输出 2 → 暂停!

第 5 次 gen.next():
  继续 → next = 2+3 = 5 → a=3, b=5 → co_yield a → 输出 3 → 暂停!

就像卷尺——你拉一下,出来一段(一个数);不拉就不动。拉 10 下 = 算出 10 个数。while (true) 没把 CPU 卡死,因为每次 co_yield 都主动把执行权还给了外面的 main()

回到代码机制:co_yield ayield_value(a)a 存到 promise_type.current 里,然后挂起协程;gen.next()handle_.resume() 恢复协程从 co_yield 下一行继续,跑完一圈 while 又碰到 co_yield,再挂起。整个过程没有线程切换。

协程 vs 线程 vs 回调

线程协程回调
代码结构顺序书写顺序书写(像同步代码)嵌套/链式,容易回调地狱
挂起机制OS 抢占主动 co_await函数返回 + 注册回调
切换代价~µs 内核态~ns 用户态无(函数调用开销)
~1MB 独立~KB 协程帧由闭包捕获决定
1 万个的代价~10GB + 调度风暴~几十 MB取决于闭包大小

协作式调度:你的责任

线程用的是抢占式调度——CPU 定时器中断一到,OS 不管你跑到哪里,强行切走。你什么都不用写,OS 保证公平。

协程用的是协作式调度——它只在 co_await / co_yield / co_return 处主动交出执行权。如果协程里没有这些挂起点,它会像普通函数一样一口气跑到结束,整个线程上的其他协程全被饿死

cpp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 危险:死循环里没有挂起点,整个线程卡死
Task<> bad_loop() {
    while (true) {
        heavy_compute();  // 纯计算,永远不交出执行权
    }
}

// 安全:每次循环都主动挂起,让出 CPU 给其他协程
Task<> good_loop() {
    while (true) {
        heavy_compute();
        co_await yield();  // 显式让出,调度器去跑别人
    }
}

这正是文章开头 SleepAwaiter 示例的深层含义:协程在 co_await 时把句柄交给定时器,当前线程立即返回去处理其他协程——不是 OS 帮你切,是协程自己主动让位

Generator 例子里的 while (true) { co_yield a; ... } 也是同理——死循环不阻塞,因为 co_yield 本身就是挂起点。外层的 gen.next() 每调用一次,协程就跑一圈 while 然后挂起等下一次 resume()

换句话说:协程把调度责任从 OS 内核接了过来。 节省的开销换来的代价是——你必须自己操心"什么时候让出执行权"。

协程的核心价值是:写起来像同步代码,运行起来像异步代码。 不用把一个连续业务逻辑撕成回调碎片,也不需要一个线程占着不动等 IO。

游戏服务端中的应用

协程在游戏服务端最典型的应用是把有状态的异步流程变成顺序可读的代码

1. 登录流程

cpp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Task<> handle_login(connection_id cid) {
    auto packet = co_await recv_async(cid);          // 1. 等客户端发握手包
    auto player = co_await db_query(packet.uid);      // 2. 查数据库(异步)
    if (!player) {
        co_await send_async(cid, "login failed");
        co_return;
    }
    auto scene = world.get_scene(player->last_pos);   // 3. 获取场景
    co_await scene->enter_async(player);               // 4. 进入场景(可能异步加载)
    co_await send_async(cid, "login ok");
    player->online = true;
}

如果不用协程,这 6 步会变成 6 层嵌套回调,出错处理和 if(!player) 的逻辑分散在各处。协程把整条时序链路写成了直排,一眼看到全貌。

2. 条件等待

cpp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 等一个条件成熟,同时设超时
Task<bool> wait_for_players(int32 instance_id, int count, chrono::seconds timeout) {
    auto deadline = chrono::steady_clock::now() + timeout;
    while (world.get_instance(instance_id).player_count() < count) {
        if (chrono::steady_clock::now() > deadline) {
            co_return false;  // 超时
        }
        co_await SleepAwaiter{10ms};  // 挂起 10ms,让出执行权
    }
    co_return true;
}

500 个副本实例各等各的玩家,如果用线程 = 500 个线程 sleep。用协程 = 500 个协程帧 + 1 个线程定时器驱动。

3. 量化对比:为什么不直接多开线程?

这里最重要的结论不是"协程比线程快",而是"协程比线程便宜"。

便宜到你可以给每个玩家开一个协程,而线程你开不起。

场景:1000 个玩家同时登录,每人 4 步异步 IO(收包 → 查 DB → 加载场景 → 发响应),单玩家合计约 85~130ms。

协程版(1 线程 + 事件循环)。核心:co_await 把句柄注册到事件循环后立即返回,线程不阻塞:

cpp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
struct AsyncOp {
    int delay_ms_;
    EventLoop& loop_;

    bool await_ready() noexcept { return false; }
    void await_suspend(coroutine_handle<> h) noexcept {
        loop_.schedule(h, delay_ms_);  // 注册唤醒时间,线程立即返回
    }
    void await_resume() noexcept {}
};

Task player_login(int id, EventLoop& loop) {
    co_await AsyncOp{20 + id % 30, loop}; // 收包
    co_await AsyncOp{30 + id % 50, loop}; // 查 DB
    co_await AsyncOp{25 + id % 30, loop}; // 加载场景
    co_await AsyncOp{10 + id % 15, loop}; // 发响应
}

线程池版(现实中你能做的)。每个 sleep 把线程占死,线程数是瓶颈:

cpp
1
2
3
4
5
6
void player_login(int id) {
    sleep_for(milliseconds(20 + id % 30)); // 线程被占着,什么都干不了
    sleep_for(milliseconds(30 + id % 50));
    sleep_for(milliseconds(25 + id % 30));
    sleep_for(milliseconds(10 + id % 15));
}

实际跑出来的结果——协程 1 线程,线程池试了不同大小:

协程线程池 8线程池 16线程池 32
并发单元1000 协程8 线程16 线程32 线程
总耗时~180 ms~13 秒~6.5 秒~3.3 秒
内存~0.4 MB~8 MB~16 MB~32 MB

趋势很清楚:线程池越大越快,但要追上协程的 180ms,需要 ~1000 个线程。


关键逻辑: 如果你可以用线程池 1000,且你的机器内存管够、也不是真正的 IO 负载—— 那线程池的表现和协程是一样的。 因为只要并发的任务数 <= 线程数,大家都能"各自阻塞、各自等待"。

**但是:**线程是稀缺资源。操作系统对线程数有限制、每条线程都要分配独立的内核栈、 上下文切换占用 CPU 时间。在真正的游戏服务器上,你几乎不可能用一条线程服务一个 玩家连接。

协程的本质优势不是快,而是"便宜"。 便宜到你可以对每个并发任务都创建一个协程, 而线程你做不到。于是,你在协程上能拿到的是**“任务数量的并发度”, 而在线程池上你只能拿到“线程池大小的并发度”**。

所以对比不是"协程 vs 线程池 20",而是:

1
2
协程:    1000 任务 × 1000 协程 → 并发度 1000 → 耗时 ≈ 最长链
线程池:  1000 任务 × N 线程     → 并发度 N    → 耗时 ≈ 最长链 × ceil(1000/N)

线程不够 → 排队 → 延时爆炸。协程够便宜 → 不排队 → 延时只取决于任务本身。

协程的代价

协程不是银弹:

  • 堆分配:每次创建协程通常要 operator new 分配协程帧(可被编译器优化掉的场景有限)
  • 模板膨胀promise_type 和 awaitable 的类型组合会导致大量模板实例化
  • 调试困难:栈帧被拆散成状态机,堆栈回溯看到的是一堆 __coro_resume 中间帧,逻辑调用链丢失
  • 生态不成熟:C++20 只给了底层基础设施,没有标准化的 Task<T> 或调度器。生产环境一般用 cppcoro 或自己封装
  • 拷贝参数陷阱:协程参数默认被拷贝到帧上,传引用很容易出悬空引用(帧的生命周期可能比引用来源更长)
cpp
1
2
3
4
5
6
// 危险!
Task<> foo(const std::string& name) {
    co_await something();  // name 引用存到了帧上
    cout << name;          // 此时 name 引用的原始对象可能已经析构
}
// 安全:传值或 shared_ptr 到帧上

Q&A

请解释 C++20 协程的底层机制。co_awaitpromise_typecoroutine_handle、协程帧各自扮演什么角色?

协程帧——编译器把协程体切成状态机后,所有跨挂起点的局部变量和参数都保存在这块堆内存上。它用 switch(state) 实现"从上次断点继续"。

promise_type——编译器与协程之间的中间人。控制协程何时启动(initial_suspend)、何时销毁(final_suspend)、co_return 的值怎么传出、未捕获异常怎么处理、co_yield 的值怎么暂存。

coroutine_handle——协程帧的裸指针包装。通过它可以 resume()(恢复执行)、done()(判断是否结束)、destroy()(手动释放帧)。await_suspend 收到的就是这个句柄,把它注册到 IO/定时器等事件源,事件发生时回调 resume()

co_await——协程的挂起点。co_await expr 的执行流程:先调 await_ready(),如果 true 直接跳过不挂起;false 则保存状态、调 await_suspend(handle) 把句柄交给外部,协程让出执行权;后续外部调 handle.resume()await_resume() 返回值作为 co_await 的结果,协程从下一行继续。

协程 vs 线程的区别? 线程被 OS 抢占,切换进内核态(~µs),独立栈 ~1MB;协程主动挂起,纯用户态切换(~ns),协程帧 ~KB。1 万个线程 ≈ 10GB + 调度风暴,1 万个协程 ≈ 几十 MB + 零内核开销。协程解决的核心问题是"让异步代码看起来像同步代码,同时不浪费线程空等 IO"。

本文包含一些 AIGC 辅助生成内容,由作者人工校对整理后发布
本文采用 CC BY-NC-SA 4.0协议,如果对您有帮助或存在意见建议,欢迎在下方评论交流。
本页面浏览次数 加载中...
本页面访客数 加载中...

加载中...