协程基础:C++20协程的引入、co_await与co_return、协程与线程的对比
说到并发编程,很多人的第一反应就是线程、锁、条件变量。嗯,这些确实是经典方案。但C++20带来的协程,说白了是一种更轻量、更优雅的并发模型。我刚开始接触协程时也觉得它有点玄乎,直到在项目中用它重构了一个异步I/O密集型的服务——那感觉,就像从手动挡换成了自动挡。
一、为什么需要协程?
先问一个问题:你写过一个异步回调嵌套的回调地狱吗?我曾经维护过一个网络库,里面全是回调函数套回调函数,代码读起来像在解迷宫。协程的出现,就是为了让异步代码能像同步代码一样写。
线程的切换成本很高——每次上下文切换都要保存寄存器、刷新缓存。而协程的切换,说白了就是保存和恢复几个寄存器变量,开销几乎可以忽略不计。你想想看,如果有一万个并发任务,用线程的话系统早就扛不住了,但用协程却可以轻松应对。
二、C++20协程的基本概念
C++20的协程不是库,而是一套语言特性。它提供了三个核心关键字:co_await、co_return 和 co_yield。嗯,这里要注意,co_yield 其实可以看作 co_await 的一种特例。
一个函数如果包含了这三个关键字中的任何一个,它就成了一个协程。编译器会自动将其状态保存到堆上分配的对象中。我个人习惯把协程看作是一个可以暂停和恢复的函数。
2.1 协程的组成
一个协程背后有这几个关键对象:
- promise_type:控制协程的行为,比如返回值、异常处理
- coroutine_handle:协程的句柄,用来恢复或销毁协程
- awaiter:定义了
await_ready、await_suspend、await_resume三个方法
听起来有点复杂?别急,我们看个最简单的例子就明白了。
#include <coroutine>
#include <iostream>
struct SimpleTask {
struct promise_type {
SimpleTask get_return_object() {
return SimpleTask{
std::coroutine_handle<promise_type>::from_promise(*this)
};
}
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
std::coroutine_handle<promise_type> handle;
};
SimpleTask hello() {
std::cout << "Hello from coroutine!" << std::endl;
co_return;
}
int main() {
auto task = hello();
return 0;
}
这段代码虽然简单,但包含了协程的所有核心要素。你看,hello() 函数里用了 co_return,它就变成了一个协程。编译器自动帮我们生成了状态机。
三、深入理解 co_await
co_await 是协程的灵魂。它让协程可以暂停执行,等待某个操作完成后再恢复。我在项目中遇到过这样一个场景:需要从多个远程服务获取数据,然后合并结果。用回调的话代码会非常分散,但用 co_await 就可以写成顺序的。
struct Awaiter {
bool await_ready() { return false; } // 不立即执行
void await_suspend(std::coroutine_handle<> h) {
// 这里可以启动异步操作
// 保存句柄,等操作完成时调用 h.resume()
std::thread([h]() {
std::this_thread::sleep_for(std::chrono::seconds(1));
h.resume(); // 恢复协程
}).detach();
}
int await_resume() { return 42; } // 返回结果
};
Task example() {
int result = co_await Awaiter{};
std::cout << "Got: " << result << std::endl;
}
为什么会这样设计?await_ready 先检查操作是否已经完成,如果完成了就直接返回结果,不需要暂停。否则 await_suspend 会被调用,这里你可以启动异步操作,并把协程句柄保存起来。等异步操作完成时,调用 h.resume() 恢复协程。最后 await_resume 返回结果给调用者。
await_ready 返回 true,可以避免不必要的协程暂停和恢复,提升性能。
四、co_return 与返回值
co_return 用来结束协程并返回结果。它和普通的 return 不同,co_return 会触发 promise_type 中的 return_value 或 return_void 方法。
struct Task {
struct promise_type {
int value;
Task get_return_object() { return Task{this}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_value(int v) { value = v; }
void unhandled_exception() { std::terminate(); }
};
promise_type* p;
};
Task compute() {
co_return 100;
}
int main() {
auto task = compute();
std::cout << task.p->value << std::endl; // 输出 100
return 0;
}
注意 final_suspend 返回的是 suspend_always,这意味着协程结束后不会自动销毁,我们可以通过句柄来获取返回值。如果返回 suspend_never,协程结束后会立即销毁,你就拿不到返回值了。
final_suspend 中返回了 suspend_never,结果协程结束后 promise 对象被销毁,再去访问返回值就变成了悬空指针。排查了好久才发现是这个原因。
五、协程与线程的对比
很多人会问:协程能替代线程吗?我的答案是:不能,但它们是互补的。
| 特性 | 线程 | 协程 |
|---|---|---|
| 调度方式 | 抢占式(操作系统调度) | 协作式(用户代码主动让出) |
| 切换开销 | 高(微秒级,涉及内核态切换) | 低(纳秒级,用户态切换) |
| 并发数量 | 通常几千个就撑不住了 | 几十万个都没问题 |
| 栈大小 | 默认 1-8 MB | 通常几 KB |
| 适用场景 | CPU 密集型、需要多核并行 | I/O 密集型、大量等待操作 |
| 数据共享 | 需要加锁保护 | 通常不需要锁(单线程内) |
你想想看,如果有一个网络服务器,需要同时处理一万个连接。每个连接大部分时间都在等待网络数据。用线程的话,光是栈空间就要吃掉几十 GB 内存。而用协程,每个协程只需要几 KB 的状态,内存占用小了两个数量级。
但协程也有它的短板。它不能利用多核 CPU 的并行能力。一个协程在运行时,其他协程必须等待。所以实际项目中,我们通常把协程和线程池结合起来:用线程池来利用多核,用协程来处理 I/O 等待。
六、协程的状态机本质
为了让你更直观地理解协程的工作原理,我画了一张图。协程本质上是一个编译器生成的状态机。每次 co_await 都是一个暂停点,每次恢复都从上一个暂停点继续执行。
从这张图可以看出,协程的执行路径不是线性的。它在 co_await 处暂停,等异步操作完成后再恢复。这种机制让协程非常适合处理 I/O 密集型任务——大部分时间都在等待,但等待时不占用 CPU。
七、实际项目中的经验
我在一个高并发网络服务中用过协程。当时需要处理数千个 WebSocket 连接,每个连接都要收发消息。如果用线程,每个连接一个线程,系统负载会非常高。用协程后,我们只用了 8 个工作线程,每个线程上跑着数百个协程。
这里有几个经验分享:
- 不要滥用协程:如果任务主要是 CPU 计算,用线程池更合适。协程的优势在于等待,而不是计算。
- 注意协程的生命周期:协程对象可能比调用者活得更久,要确保在协程完成后才释放相关资源。
- 小心栈溢出:协程的栈是有限的,递归调用要谨慎。我见过有人用协程做深度递归,结果栈溢出了。
- 调试比较困难:协程的调用栈和普通函数不同,调试器可能无法正确显示。建议多打日志。
八、总结
C++20 的协程提供了一种全新的并发编程范式。它让异步代码可以像同步代码一样写,避免了回调地狱。协程和线程不是替代关系,而是互补关系——协程负责等待,线程负责计算。
说实话,C++20 的协程学习曲线有点陡。它的设计偏底层,需要你理解 promise_type、coroutine_handle、awaiter 这些概念。但一旦掌握了,你会发现它在处理 I/O 密集型任务时威力巨大。
我个人建议,先从简单的例子入手,理解 co_await 和 co_return 的基本用法。然后在实际项目中找一个合适的场景(比如网络请求、文件读写)来实践。纸上得来终觉浅,绝知此事要躬行。