16、异步I/O与协程:C++20协程原理、co_await与co_yield、异步文件读写、协程与网络编程结合
协程这个话题,说实话在C++社区里等了很久。C++20终于把它带进来了。我记得第一次看到提案的时候,心里想的是——终于不用再靠回调地狱活着了。
但说实话,C++20的协程跟Python、Go那种协程不太一样。它更像是一套底层机制,给你搭了个架子,具体怎么用,得你自己来。嗯,这很C++。
协程的核心概念
先搞清楚一件事:协程不是线程。它是在一个线程内部,把执行权交出去,再拿回来。说白了,就是函数可以暂停,可以恢复。
C++20的协程有几个关键角色:
- promise_type:协程的“管家”,负责管理协程的状态和返回值
- awaiter:决定要不要挂起,挂起后怎么恢复
- coroutine_handle:协程的“遥控器”,你可以手动恢复它
我刚开始学的时候,被这些概念绕得够呛。后来我换了个角度理解:
协程就是一个可以暂停的函数。暂停的时候,它把局部变量都存到堆上。恢复的时候,从上次停下的地方继续跑。
关键点:C++20协程是无栈协程。它不依赖操作系统栈切换,所以开销非常小。我在项目中测过,一次挂起恢复大概几十纳秒,比线程切换快两个数量级。
co_await:挂起与恢复
co_await 是协程的灵魂。它告诉编译器:这里可能要等一会儿,你先挂起我,等条件满足了再叫我。
来看一个最简单的例子:
#include <coroutine>
#include <iostream>
struct SimpleAwaiter {
bool await_ready() noexcept { return false; }
void await_suspend(std::coroutine_handle<> h) noexcept {
std::cout << "挂起协程\n";
// 这里可以保存handle,稍后恢复
}
void await_resume() noexcept {
std::cout << "恢复协程\n";
}
};
struct SimpleTask {
struct promise_type {
SimpleTask get_return_object() { return {}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() {}
};
};
SimpleTask demo() {
std::cout << "协程开始\n";
co_await SimpleAwaiter{};
std::cout << "协程结束\n";
}
这里 await_ready 返回 false,表示“我要挂起”。await_suspend 里你可以把协程句柄存起来,等异步操作完成后再恢复。await_resume 是恢复后执行的逻辑。
我的习惯:await_ready 一般用来做快速路径优化。如果数据已经准备好了,直接返回 true,省去挂起开销。我在网络库中经常这么用——数据已经在缓冲区了,就没必要挂起了。
co_yield:生成器模式
co_yield 是用来产生值的。它跟 co_await 的区别在于:co_yield 是“我主动给你一个值,然后我暂停”,co_await 是“我等别人给我值,然后我继续”。
举个例子:
#include <coroutine>
#include <optional>
template<typename T>
struct Generator {
struct promise_type {
T current_value;
Generator get_return_object() {
return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(T value) {
current_value = value;
return {};
}
void return_void() {}
void unhandled_exception() {}
};
std::coroutine_handle<promise_type> handle;
bool next() {
handle.resume();
return !handle.done();
}
T value() { return handle.promise().current_value; }
};
Generator<int> range(int start, int end) {
for (int i = start; i < end; ++i) {
co_yield i;
}
}
// 使用
auto gen = range(0, 5);
while (gen.next()) {
std::cout << gen.value() << " "; // 输出 0 1 2 3 4
}
你想想看,以前要实现这种懒加载的生成器,得写多少状态机代码?现在一个 co_yield 就搞定了。
异步文件读写
协程在I/O上的优势,说白了就是“不阻塞”。传统文件读写,线程会卡在 read/write 上。用协程,你可以挂起当前协程,让线程去干别的活。
我封装过一个简单的异步文件读取器:
#include <coroutine>
#include <fstream>
#include <vector>
#include <thread>
struct FileReadAwaiter {
std::string filename;
std::vector<char> buffer;
std::coroutine_handle<> handle;
bool await_ready() noexcept { return false; }
void await_suspend(std::coroutine_handle<> h) noexcept {
handle = h;
// 在线程池里执行真正的文件读取
std::thread([this]() {
std::ifstream file(filename, std::ios::binary);
if (file) {
file.seekg(0, std::ios::end);
size_t size = file.tellg();
file.seekg(0, std::ios::beg);
buffer.resize(size);
file.read(buffer.data(), size);
}
// 读取完成后恢复协程
handle.resume();
}).detach();
}
std::vector<char> await_resume() noexcept {
return std::move(buffer);
}
};
struct ReadTask {
struct promise_type {
ReadTask get_return_object() { return {}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() {}
};
};
ReadTask asyncReadFile() {
auto data = co_await FileReadAwaiter{.filename = "test.bin"};
std::cout << "读取了 " << data.size() << " 字节\n";
}
注意:上面的例子为了演示简单,用了 detach 线程。实际项目中千万别这么干。线程池是必须的,否则频繁创建线程的开销比文件读取还大。我曾经在一个项目里踩过这个坑,后来老老实实用了线程池。
协程与网络编程
网络编程是协程最能发挥价值的地方。传统的 epoll + 回调模式,代码逻辑被拆得七零八落。用协程,你可以写出同步风格的异步代码。
来看一个简单的 TCP 回显服务器:
#include <coroutine>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <vector>
struct SocketAwaiter {
int fd;
std::vector<char> buffer;
std::coroutine_handle<> handle;
bool await_ready() noexcept { return false; }
void await_suspend(std::coroutine_handle<> h) noexcept {
handle = h;
// 注册到事件循环,当fd可读时恢复
event_loop_add_read(fd, [this]() {
buffer.resize(1024);
ssize_t n = read(fd, buffer.data(), buffer.size());
if (n > 0) buffer.resize(n);
handle.resume();
});
}
std::vector<char> await_resume() noexcept {
return std::move(buffer);
}
};
struct EchoTask {
struct promise_type {
EchoTask get_return_object() { return {}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() {}
};
};
EchoTask handleClient(int client_fd) {
while (true) {
auto data = co_await SocketAwaiter{client_fd};
if (data.empty()) break; // 连接关闭
write(client_fd, data.data(), data.size());
}
close(client_fd);
}
你看,这段代码读起来就像同步编程一样。但实际上,每次 co_await 的时候,协程都挂起了,线程可以去处理其他连接。
协程的生命周期管理
这里有个容易踩坑的地方:协程对象的生命周期。
协程挂起后,它的局部变量都保存在堆上。如果协程对象被销毁了,这些内存就泄漏了。反过来,如果协程已经结束,你还去恢复它,那就是未定义行为。
我的做法是:
- 用
unique_ptr或shared_ptr管理协程句柄 - 在
final_suspend里返回suspend_never,让协程自动销毁 - 或者返回
suspend_always,手动调用handle.destroy()
避坑指南:我曾经在协程里捕获了一个局部变量的引用,协程挂起后局部变量被销毁了,恢复时访问了悬空引用。排查了好久才发现。记住:协程挂起期间,所有被捕获的引用都必须保证有效。
协程的性能考量
协程不是银弹。它也有开销:
| 操作 | 开销(近似) | 说明 |
|---|---|---|
| 协程创建 | ~100ns | 堆分配 + 初始化 |
| 挂起/恢复 | ~30ns | 状态保存 + 跳转 |
| 线程切换 | ~1μs | 上下文切换 + 缓存失效 |
可以看到,协程的挂起恢复比线程切换快一个数量级。但如果你每次操作都创建销毁协程,那开销也不小。
我的建议是:协程适合 I/O 密集型场景,不适合 CPU 密集型。如果你要算圆周率,老老实实用线程池。
协程与事件循环的配合
协程本身不提供事件循环。你需要自己实现,或者用现成的库(如 libuv、asio)。
基本模式是这样的:
// 事件循环
void event_loop() {
while (running) {
auto events = epoll_wait(epfd, ...);
for (auto& ev : events) {
auto handle = get_coroutine(ev.data.fd);
handle.resume(); // 恢复对应的协程
}
}
}
每个协程对应一个文件描述符。事件循环检测到 fd 就绪,就恢复对应的协程。协程处理完业务逻辑,再次挂起,等待下一个事件。
这种模式的好处是:一个线程可以管理成千上万个协程。每个协程只占几十字节的栈空间(对比线程的几MB栈),内存效率极高。
我的经验:在项目中,我通常用一个线程跑事件循环,多个工作线程处理 CPU 密集型任务。协程只负责 I/O 等待和业务编排。这样既发挥了协程的 I/O 优势,又利用了多核的并行能力。
协程的调试技巧
协程调试是个麻烦事。因为调用栈是断开的——挂起时的栈和恢复时的栈不连续。
我常用的方法:
- 给每个协程加一个名字或ID,打印日志时带上
- 用
std::source_location记录协程创建的位置 - 在
await_suspend和await_resume里加日志 - 用 GDB 的
coroutine命令(GCC 11+)
说实话,协程调试到现在也没有特别好的工具。我一般靠日志和单元测试来保证正确性。
SVG:协程状态流转图
总结
C++20 协程是一套强大的异步编程工具。它让异步代码写起来像同步代码,大大降低了心智负担。
但也要记住:协程不是万能的。它适合 I/O 密集型场景,不适合 CPU 密集型。它需要配合事件循环使用,不能独立工作。它的调试比较困难,需要良好的日志和测试习惯。
我个人觉得,协程是 C++ 异步编程的未来。虽然现在标准库支持还不够完善,但社区已经在快速跟进。如果你现在开始学习协程,两年后你就是老手了。
一句话总结:协程让你用同步的脑子写异步的代码,用顺序的逻辑处理并发的事件。
公众号:蓝海资料掘金营,微信deep3321