13、C++20协程入门:协程概念与优势、co_await/co_return/co_yield、promise_type与awaiter、协程与线程的对比
说实话,C++20 的协程刚出来那会儿,我第一反应是:又来新东西了?
但真正用上之后,我得承认——这东西确实香。它不像线程那样动不动就给你整个上下文切换的开销,也不像回调那样把代码写成金字塔。协程,说白了就是让你用写同步代码的方式,去处理异步逻辑。
这一章,咱们就把它掰开揉碎,看看协程到底是个什么玩意儿。
协程是什么?一个简单的定义
协程是一个可以暂停、可以恢复的函数。你想想看,普通函数一旦调用,就得从头跑到尾,中间不能停。但协程不一样——它可以在某个点停下来,把控制权交出去,等条件满足了再回来接着跑。
我习惯把协程比作一个「会喘气的函数」。它跑累了可以歇会儿,歇够了再继续。这个特性在 I/O 密集型场景下特别有用。
核心区别一句话:线程是抢占式调度,协程是协作式调度。协程自己决定什么时候让出 CPU,而不是被操作系统强行打断。
协程的三个关键字:co_await、co_return、co_yield
这三个关键字是协程的「三驾马车」。咱们一个一个说。
co_await:等待一个异步操作
co_await 是协程里最常用的关键字。它的意思是:我在这里等一个结果,但我不阻塞线程。等结果回来了,再继续往下走。
我在项目中遇到过这样一个场景:需要从网络读取数据,然后解析。如果用传统方式,要么阻塞线程,要么写回调。用 co_await 就简单多了:
// 伪代码,展示 co_await 的用法
Task<std::string> fetchData() {
auto request = makeHttpRequest("https://api.example.com/data");
// 发起请求,但不阻塞线程
auto response = co_await request;
// 等响应回来了,继续执行
co_return parseResponse(response);
}
你看,代码是顺序写的,但执行是异步的。这就是协程的魅力。
co_return:返回结果
co_return 就是协程版的 return。但它做的事情比普通 return 多——它会触发 promise_type 的 return_value 方法,把结果存起来,然后通知等待者。
小提示:协程里不能同时用 co_return 和 co_yield。要么返回一个值,要么生成一系列值,二选一。
co_yield:生成一个值
co_yield 用于生成器场景。它把当前值抛出去,然后暂停自己,等调用者要下一个值的时候再恢复。
// 一个简单的生成器
Generator<int> range(int start, int end) {
for (int i = start; i < end; ++i) {
co_yield i; // 每次生成一个值
}
}
// 使用
for (auto v : range(0, 5)) {
std::cout << v << " "; // 输出 0 1 2 3 4
}
promise_type 与 awaiter:协程的幕后英雄
这两个东西是协程的「基础设施」。你平时写代码可能不直接碰它们,但理解它们的工作原理,能帮你写出更靠谱的协程代码。
promise_type:协程的「管家」
每个协程都有一个关联的 promise_type。它负责管理协程的状态——协程什么时候开始、什么时候结束、返回值怎么存、异常怎么处理。
我刚开始学协程时,被 promise_type 搞得一头雾水。后来我把它理解成「协程的构造函数和析构函数」——它定义了协程的生命周期。
// 一个最简单的 promise_type 示例
struct TaskPromise {
// 协程开始时的行为
std::suspend_never initial_suspend() { return {}; }
// 协程结束时的行为
std::suspend_never final_suspend() noexcept { return {}; }
// 处理 co_return
void return_void() {}
// 处理异常
void unhandled_exception() { std::terminate(); }
// 返回协程对象
Task get_return_object() { return Task{this}; }
};
awaiter:控制等待行为
awaiter 决定了 co_await 具体怎么「等」。它有三个方法:
- await_ready:检查结果是否已经准备好了。如果准备好了,就不暂停,直接继续。
- await_suspend:如果没准备好,这个函数会被调用。它决定协程暂停后做什么——比如注册回调、启动异步操作。
- await_resume:协程恢复时,这个函数的返回值就是 co_await 表达式的结果。
注意:await_suspend 的返回值类型很重要。返回 void 表示无条件暂停;返回 bool 表示根据条件决定是否暂停;返回其他协程的句柄,可以实现协程间的跳转。
协程与线程的对比
很多人刚接触协程时,会问一个问题:协程和线程到底有什么区别?
嗯,这个问题我当年也问过。咱们直接上表格对比:
| 对比维度 | 线程 | 协程 |
|---|---|---|
| 调度方式 | 抢占式(操作系统决定) | 协作式(协程自己决定) |
| 切换开销 | 高(涉及内核态切换) | 极低(用户态切换) |
| 并发数量 | 受限于系统资源(通常几千) | 可以轻松达到数十万 |
| 数据共享 | 需要锁或原子操作 | 通常不需要锁(单线程内) |
| 适用场景 | CPU 密集型、并行计算 | I/O 密集型、高并发网络服务 |
说白了,线程适合「算」,协程适合「等」。如果你的程序大部分时间在等 I/O(网络、磁盘、数据库),协程是更好的选择。
协程的优势:为什么值得学?
我总结了几点,都是我在实际项目中体会到的:
- 代码简洁:用同步的方式写异步代码,不用回调嵌套,不用状态机。
- 性能好:协程切换只保存几个寄存器,比线程切换快两个数量级。
- 内存省:每个协程的栈可以很小(甚至无栈),百万协程不是梦。
- 组合性强:可以轻松实现超时、取消、错误处理等复杂逻辑。
避坑指南:我曾经在项目里用协程处理大量短连接,结果发现协程对象创建销毁的开销也不小。后来我用了协程池,把协程复用起来,性能才上去。记住:协程不是银弹,该优化的地方还是得优化。
知识体系结构图
下面这张图展示了协程的核心概念和它们之间的关系:
一个完整的协程示例
最后,咱们看一个完整的例子,把上面这些概念串起来:
#include <coroutine>
#include <iostream>
#include <thread>
// 一个简单的协程返回类型
struct Task {
struct promise_type {
Task get_return_object() { return Task{}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
};
// 一个可等待对象
struct SleepAwaiter {
int ms;
bool await_ready() { return false; }
void await_suspend(std::coroutine_handle<> handle) {
std::thread([handle, this]() {
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
handle.resume(); // 恢复协程
}).detach();
}
void await_resume() {}
};
// 协程函数
Task myCoroutine() {
std::cout << "协程开始" << std::endl;
co_await SleepAwaiter{1000}; // 等待1秒,但不阻塞线程
std::cout << "协程恢复" << std::endl;
}
int main() {
myCoroutine();
std::this_thread::sleep_for(std::chrono::seconds(2));
return 0;
}
这个例子虽然简单,但包含了协程的完整流程:创建协程、暂停、等待、恢复、结束。你在实际项目中遇到的协程,本质上都是这个模式的变体。
总结一下:协程不是用来替代线程的,而是用来补充线程的。线程负责「算」,协程负责「等」。两者配合使用,才能写出高性能的并发程序。