协程(Coroutines)基础:C++20协程的概念、co_await、co_yield与co_return、协程与线程的对比
聊到协程,我得先坦白一件事。几年前我第一次看到 C++20 协程提案时,心里想的是:「这玩意儿不就是个语法糖吗?」直到我在一个高并发网络服务里,被成千上万的线程切换开销折磨得睡不着觉,才真正意识到——协程这东西,是来救命的。
好,咱们今天就把协程这层窗户纸捅破。你不需要一开始就理解所有细节,跟着我的节奏走就行。
什么是协程?一个直觉上的理解
协程,说白了就是「可以暂停和恢复的函数」。普通函数一旦调用,就得一口气执行完。协程不一样——它可以在中间停下来,把控制权交出去,等条件满足了再回来接着跑。
你想想看,这像什么?像不像一个会「让路」的函数?
我在项目中遇到过这样的场景:一个网络请求过来,需要读数据库、调远程服务、做计算。如果用线程,每个请求占一个线程,1000 个并发就是 1000 个线程。线程切换、栈空间、锁竞争……系统很快就扛不住了。换成协程,一个线程可以同时处理成千上万个「半路暂停」的任务。这就是协程的魅力。
C++20 协程的核心概念
C++20 的协程不是库,而是一套语言机制。它提供了三个关键字:co_await、co_yield、co_return。别被它们吓到,咱们一个一个拆。
co_await:等待一个异步操作
co_await 是协程的灵魂。它的作用是:暂停当前协程,等待某个操作完成,然后恢复执行。
举个例子。假设你要从网络读取数据:
// 伪代码,展示 co_await 的语义
Task<std::string> fetchData() {
std::string data = co_await asyncRead("https://example.com");
// 这里 data 已经准备好了
co_return data;
}
当执行到 co_await asyncRead(...) 时,协程会暂停。它不会阻塞线程,而是把线程让出来给别的协程用。等数据读完了,协程再恢复执行。整个过程,线程一直在干活,没有空转。
co_yield:生成一个值,然后暂停
co_yield 用于「生成器」场景。它返回一个值给调用者,然后暂停自己,等待下一次被唤醒。
我记得有一次写一个数据流处理模块,需要从一个大文件里逐行读取并处理。如果用普通函数,得一次性读完整个文件,内存扛不住。用 co_yield 就优雅多了:
Generator<std::string> readLines(const std::string& filename) {
std::ifstream file(filename);
std::string line;
while (std::getline(file, line)) {
co_yield line; // 返回一行,暂停
}
}
// 使用
for (auto& line : readLines("bigfile.txt")) {
process(line); // 每次只处理一行
}
这里 co_yield line 做了两件事:把 line 返回给调用者,然后暂停协程。下次循环进来时,从暂停的地方继续,读取下一行。
co_return:结束协程并返回值
co_return 就是协程的 return。它标志着协程执行完毕,同时把最终结果返回给调用者。
注意,协程里不能同时用 return 和 co_return。一旦函数体内出现了 co_await、co_yield 或 co_return 中的任何一个,这个函数就自动变成了协程。
Task<int> compute() {
int a = co_await fetchValue();
int b = co_await fetchValue();
co_return a + b;
}
协程与线程的对比
好,咱们来做个对比。我直接画了张图,帮你理清思路。
这张图我画得比较直白。你看,线程和协程不是替代关系,而是互补关系。我个人的习惯是:I/O 操作用协程,计算操作用线程。比如一个网络服务器,接收请求、读写数据库这些 I/O 密集的事交给协程;而图像处理、加密解密这些 CPU 密集的事,还是老老实实扔到线程池里。
一个完整的协程示例
光说不练假把式。咱们写一个能跑起来的例子,感受一下协程的完整流程。
#include <coroutine>
#include <iostream>
#include <thread>
#include <chrono>
// 一个极简的 Task 类型
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 unhandled_exception() { std::terminate(); }
std::suspend_always yield_value(int v) {
value = v;
return {};
}
void return_value(int v) { value = v; }
};
std::coroutine_handle<promise_type> handle;
explicit Task(promise_type& p) : handle(std::coroutine_handle<promise_type>::from_promise(p)) {}
~Task() { if (handle) handle.destroy(); }
int get() { return handle.promise().value; }
};
// 一个简单的协程
Task simpleCoroutine() {
std::cout << "协程开始\n";
co_yield 42;
std::cout << "协程恢复\n";
co_yield 100;
std::cout << "协程即将结束\n";
co_return 200;
}
int main() {
auto coro = simpleCoroutine();
std::cout << "第一次 yield 的值: " << coro.get() << "\n";
coro.handle(); // 恢复协程
std::cout << "第二次 yield 的值: " << coro.get() << "\n";
coro.handle(); // 再次恢复
std::cout << "最终返回值: " << coro.get() << "\n";
return 0;
}
这个例子虽然简单,但把 co_yield 和 co_return 的配合展示得很清楚。你运行一下就会发现,协程真的可以在中间停下来,等外面处理完了再回来继续跑。
什么时候用协程?什么时候用线程?
这个问题我经常被问到。我的回答是:
- 用协程的场景: 网络请求、文件读写、数据库查询、消息队列消费——总之,任何需要「等」的地方,都适合用协程。
- 用线程的场景: 图像渲染、视频编码、科学计算、游戏物理引擎——这些需要「算」的地方,线程更合适。
- 两者结合: 一个典型的架构是:协程处理 I/O 和业务逻辑,线程池处理计算密集型任务。协程把计算结果通过
co_await交给线程池,线程池算完再唤醒协程。
嗯,这里要注意一点:协程不是银弹。如果你的任务全是 CPU 密集型的,用协程反而会增加复杂度,没有任何收益。我见过有人把加密算法写成协程,结果性能还不如普通函数——因为协程的暂停恢复也是有开销的,虽然很小,但架不住你频繁调用。
小结
协程是 C++20 给并发编程带来的一股清流。它让异步代码写起来像同步代码一样直观,同时又避免了线程切换的高昂代价。我个人觉得,未来五年,协程会成为 C++ 后端开发者的标配技能,就像今天大家都会用智能指针一样。
别急,协程的生态还在完善中。标准库里的 std::generator 在 C++23 才正式落地,第三方库像 cppcoro、libunifex 也各有千秋。但核心概念——co_await、co_yield、co_return——是不会变的。把这几个关键字玩熟了,后面学什么库都顺手。