一、协程到底是什么?
协程这个概念,其实我最早接触是在 Lua 和 Python 里。那时候做游戏服务器,用 Lua 的协程写状态机,简直爽到飞起。后来 C++20 终于原生支持了,说实话,我等这一天等了很久。
协程说白了,就是可以暂停和恢复的函数。普通函数一旦调用,就得一口气执行完。协程不一样,它可以在中间停下来,把控制权交出去,等条件满足了再回来接着跑。
你想想看,这像什么?像不像一个可以手动切换的线程?但协程比线程轻量得多。线程切换要内核态,协程切换就是用户态的一次函数调用。我在项目中测过,百万级协程的创建和调度,内存开销远小于同等数量的线程。
核心区别一句话:线程是抢占式调度,协程是协作式调度。协程自己决定什么时候让出 CPU。
二、C++20 协程语法:三个关键字
C++20 的协程语法,说实话,刚看的时候我也觉得有点绕。它不像 Go 的 go 关键字那么直观。C++ 选择了零开销抽象的路线,把底层机制暴露给了你。
三个核心关键字:
co_await—— 挂起当前协程,等待某个操作完成co_return—— 从协程返回一个值co_yield—— 产生一个值,然后挂起(类似生成器)
但光有这三个关键字还不够。你需要自己实现一个承诺对象(promise_type)和一个可等待对象(Awaitable)。嗯,这里要注意,C++20 的协程是框架无关的,标准库只提供了底层机制,没有提供高级封装。
一个最简单的协程例子
#include <coroutine>
#include <iostream>
struct Task {
struct promise_type {
Task get_return_object() { return {}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
};
Task myCoroutine() {
std::cout << "Hello from coroutine!\n";
co_return;
}
int main() {
myCoroutine();
return 0;
}
这段代码跑起来,你会看到输出。但说实话,这个协程跟普通函数没啥区别,因为它没有挂起。真正的威力在 co_await 上。
co_await 的工作原理
当你写 co_await expr 时,编译器会做三件事:
- 检查
expr是否有await_ready()方法。如果返回 true,说明操作已经完成,直接继续执行。 - 如果没准备好,调用
await_suspend(),把当前协程的句柄存起来,然后挂起。 - 当操作完成时,调用
await_resume(),恢复协程执行。
我在项目中遇到过一个问题:await_suspend 返回 void 和返回 bool 的行为不一样。返回 void 表示无条件挂起,返回 false 表示不挂起直接继续。这个细节坑了不少人。
注意:协程的 promise_type 对象是存储在堆上的。如果你在高频创建协程的场景下,内存分配会成为瓶颈。我一般会配合自定义分配器来优化。
三、协程调度器:谁来管理这些协程?
协程本身不会自己跑,它需要一个调度器来管理。调度器负责:
- 维护一个就绪队列
- 决定下一个执行哪个协程
- 处理协程的挂起和恢复
最简单的调度器就是一个 std::queue 加上一个循环。但实际项目中,我一般会设计成多级队列,把 I/O 密集型协程和 CPU 密集型协程分开。
一个迷你调度器示例
#include <coroutine>
#include <queue>
#include <functional>
class Scheduler {
std::queue<std::coroutine_handle<>> tasks;
public:
void emplace(std::coroutine_handle<> h) {
tasks.push(h);
}
void run() {
while (!tasks.empty()) {
auto h = tasks.front();
tasks.pop();
h.resume();
if (!h.done()) {
tasks.push(h); // 没执行完,重新入队
} else {
h.destroy();
}
}
}
};
这个调度器是协作式的。每个协程执行到 co_await 时主动让出,调度器再选下一个。你想想看,这不就是事件循环吗?对,协程调度器本质上就是一个事件循环。
我的经验:调度器的核心是公平性。我曾经写过一个调度器,某个协程一直不挂起,导致其他协程饿死。后来加了一个时间片限制,每个协程最多执行 N 次 co_await 后强制让出。
四、协程在网络编程中的应用
网络编程是协程最典型的应用场景。传统的 Reactor 模型,你要写回调,代码像意大利面条一样。用协程,你可以用同步的方式写异步代码。
传统回调 vs 协程
| 场景 | 传统回调 | 协程 |
|---|---|---|
| 读数据 | 注册回调,数据到了再调用 | co_await socket.read() |
| 写数据 | 注册写完成回调 | co_await socket.write() |
| 超时处理 | 启动定时器,回调里取消 | co_await timeout(5s) |
| 错误处理 | 回调里传 error_code | 异常或返回值,跟同步代码一样 |
说白了,协程把异步回调的控制流反转给正了回来。你写代码的时候,逻辑是线性的,读起来跟同步代码一样。但底层其实是异步的,不会阻塞线程。
一个简单的 TCP Echo 服务器
Task handle_connection(Socket sock) {
char buf[1024];
while (true) {
auto n = co_await sock.async_read(buf);
if (n == 0) break; // 连接关闭
co_await sock.async_write(buf, n);
}
}
Task server_loop() {
Acceptor acceptor("0.0.0.0", 8080);
while (true) {
auto sock = co_await acceptor.async_accept();
// 每个连接启动一个协程处理
handle_connection(std::move(sock));
}
}
你看,这段代码没有一行回调。每个连接的处理逻辑是顺序的,但底层是异步的。我在生产环境中用过类似的模式,单机轻松支撑 10 万连接。
关键点:协程本身不提高吞吐量,它提高的是开发效率和代码可维护性。吞吐量取决于你的 I/O 模型和调度器设计。
五、协程的代价与陷阱
协程不是银弹。我踩过几个坑,分享给你:
- 内存开销:每个协程的栈帧在堆上分配。如果协程数量极大(百万级),内存压力不小。我一般会控制协程的栈大小,或者用对象池复用。
- 调试困难:协程的调用栈在挂起和恢复时会断裂。GDB 对协程的支持在 C++20 初期并不好,现在好多了,但还是不如普通函数直观。
- 线程安全:协程本身不是线程安全的。如果你在多线程环境下恢复协程,需要加锁。我习惯用一个线程跑一个调度器,避免竞争。
- 生命周期管理:协程句柄
std::coroutine_handle是一个裸指针。忘记destroy()会导致内存泄漏。我建议用 RAII 包装一下。
我曾经犯过的错:在协程里捕获了一个局部变量的引用,协程挂起后局部变量析构了,恢复时访问了悬空引用。嗯,这个问题排查了整整一天。记住:协程里尽量传值,或者用 shared_ptr。
六、知识体系总览
下面这张图,是我对协程知识体系的总结。你可以把它当作学习路线图。
这张图把协程分成了三大块:语法基础、调度器设计、网络编程应用。底部是常见陷阱。我个人建议的学习路径是:先搞懂语法,再写一个迷你调度器,最后应用到网络编程中。每一步都动手写代码,光看是学不会的。
公众号:蓝海资料掘金营,微信deep3321