71、STL与协程:协程概念、协程实现、协程与线程对比
协程这个话题,说实话在C++社区里火了好几年了。C++20正式把协程纳入标准后,我身边不少老同事都开始重新审视自己的异步代码。今天我们就来聊聊协程到底是什么,怎么用,以及它和线程到底有什么区别。
一、协程是什么?一个直观的理解
协程,说白了就是可以暂停和恢复的函数。普通函数一旦调用,就得从头跑到尾,中间不能停。但协程不一样——它可以在执行到某个点时主动让出控制权,等条件满足了再回来接着跑。
我刚开始接触这个概念时,总觉得它和回调函数差不多。后来在项目中写了一个复杂的异步网络库,回调嵌套了五六层,代码完全没法维护。那时候我才真正体会到协程的价值——它让异步代码写起来像同步代码一样直观。
核心要点:协程是一种更轻量级的并发原语,它允许函数在执行过程中暂停,并在之后恢复执行。
二、C++20协程的基本实现
C++20的协程实现,说实话有点复杂。它不像Python那样用yield关键字就完事了,而是提供了一套底层机制,让你可以自己定制协程的行为。这里我给大家拆解一下核心组件。
2.1 协程的三个关键对象
每个协程背后都有三个关键对象在协同工作:
- promise_type:管理协程的状态,决定协程返回什么值
- awaiter:控制协程何时暂停、何时恢复
- coroutine_handle:协程的句柄,用来手动控制协程的暂停和恢复
来看一个最简单的协程示例:
#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 myCoroutine() {
std::cout << "第一步:开始执行\n";
co_await std::suspend_always{};
std::cout << "第二步:恢复执行\n";
}
int main() {
auto task = myCoroutine();
std::cout << "主线程继续工作\n";
task.handle.resume();
return 0;
}
这段代码里,co_await std::suspend_always{} 让协程在第一次执行后暂停。主线程打印完消息后,通过 handle.resume() 恢复协程。输出结果是:
第一步:开始执行
主线程继续工作
第二步:恢复执行
我的经验:刚开始写协程时,我建议先别急着搞复杂的promise_type。先用std::suspend_always和std::suspend_never把暂停/恢复的流程跑通,再逐步定制。
2.2 协程的生命周期
协程的执行流程其实很清晰:
- 调用协程函数,创建promise对象
- 执行
initial_suspend,决定是否立即暂停 - 执行协程体,遇到
co_await时暂停 - 外部通过
coroutine_handle恢复执行 - 协程执行完毕,执行
final_suspend
嗯,这里要注意:final_suspend如果返回suspend_never,协程结束后会自动销毁。如果返回suspend_always,你需要手动调用handle.destroy(),否则会内存泄漏。我曾经在这个坑里栽过一次,排查了半天才发现是协程句柄没释放。
三、协程与线程的对比
很多初学者会把协程和线程搞混。其实它们的定位完全不同。我整理了一个对比表,方便大家理解:
| 对比维度 | 协程 | 线程 |
|---|---|---|
| 调度方式 | 用户态主动让出(协作式) | 内核态抢占式调度 |
| 切换开销 | 极低(仅保存寄存器) | 较高(涉及内核上下文切换) |
| 内存占用 | 栈可极小(KB级别) | 默认栈大小(MB级别) |
| 并发能力 | 单线程内可创建上万个 | 受限于CPU核心数和内存 |
| 数据竞争 | 无需锁(单线程内切换) | 需要锁或原子操作 |
| 适用场景 | IO密集型、异步任务 | CPU密集型、并行计算 |
你想想看,线程切换一次,光内核态到用户态的切换就要几百个CPU周期。而协程切换,说白了就是保存几个寄存器的值,几十个周期就搞定了。这就是为什么协程能轻松创建上万个,而线程到几千个就开始吃力了。
避坑指南:我曾经在一个项目中用线程池处理大量网络连接,结果线程数一上去,CPU全花在上下文切换上了。后来改用协程,同样的硬件配置,吞吐量提升了3倍多。但要注意,协程不适合CPU密集型的计算任务——它只是让等待不阻塞,并不能让计算变快。
四、协程的实际应用场景
协程最典型的应用场景就是异步IO。比如网络请求、文件读写、数据库查询这些需要等待的操作。用协程写出来的代码,比回调函数清晰得多。
来看一个模拟的异步读取示例:
struct AsyncReader {
struct promise_type {
// ... 省略promise实现
};
bool await_ready() { return false; }
void await_suspend(std::coroutine_handle<> h) {
// 模拟异步IO,启动后立即返回
std::thread([h]() {
std::this_thread::sleep_for(std::chrono::seconds(1));
h.resume(); // IO完成后恢复协程
}).detach();
}
std::string await_resume() { return "读取完成的数据"; }
};
AsyncReader asyncRead() {
std::string data = co_await AsyncReader{};
std::cout << data << std::endl;
}
这段代码里,co_await 让协程暂停,等后台线程读取完数据后再恢复。主线程在此期间可以去做其他事情。这就是协程的魅力——用同步的写法,实现异步的效果。
五、协程与STL的配合
C++20的协程和STL配合得还不错。比如你可以用std::generator(C++23引入)来生成惰性序列:
#include <generator>
#include <ranges>
std::generator<int> fibonacci() {
int a = 0, b = 1;
while (true) {
co_yield a;
auto next = a + b;
a = b;
b = next;
}
}
int main() {
for (int n : fibonacci() | std::views::take(10)) {
std::cout << n << " ";
}
// 输出:0 1 1 2 3 5 8 13 21 34
}
我个人习惯用co_yield来生成数据流,配合std::views做管道操作,代码非常简洁。不过要注意,std::generator目前只在C++23中可用,如果你的编译器还不支持,可以用第三方库如cppcoro替代。
六、协程的注意事项
最后说几个我踩过的坑:
- 协程对象必须保持存活:协程暂停后,如果协程对象被销毁,再恢复就是未定义行为。我建议用智能指针管理协程句柄。
- 不要在协程中持有锁:协程暂停时不会释放锁,容易造成死锁。如果必须加锁,考虑用异步锁。
- 注意栈溢出:协程默认使用小栈,递归调用要小心。我遇到过递归深度超过1000层导致栈溢出的情况。
- 调试比较困难:协程的调用栈在暂停和恢复时会断裂,调试器可能看不到完整的调用链。建议多打日志。
总结一下:协程不是线程的替代品,而是互补品。线程负责并行计算,协程负责异步等待。把两者结合起来,才能写出高性能的C++程序。
这张图展示了协程从创建到结束的完整流程。你可以看到,co_await 是协程的核心——它让协程暂停,等外部条件满足后再恢复。整个过程都在用户态完成,没有内核参与,所以效率极高。
好了,关于协程的内容就聊到这里。记住一句话:协程让异步代码回归同步写法,线程让并行计算变得简单。两者各司其职,才是C++并发编程的正确打开方式。
公众号:蓝海资料掘金营,微信deep3321