在现代 C++ 中,协程(coroutine)提供了一种轻量级、可组合的异步编程模型。相较于传统的基于回调或线程的方式,协程能够让我们在保持同步代码可读性的同时,获得非阻塞 I/O 的性能优势。本文将从协程的基本概念、实现细节以及在异步 IO 场景中的实际应用展开讨论,并给出完整的代码示例。
1. 协程基础
协程是一个可以暂停和恢复执行的函数。C++20 标准库通过 std::generator、std::task、std::suspend_always、std::suspend_never 等类型定义了协程的骨架。关键点在于:
- 挂起点:通过
co_await或co_yield把协程挂起,等待外部事件完成。 - 恢复点:当外部事件准备好后,协程继续执行,从挂起点恢复。
- 状态机:编译器把协程实现为状态机,隐藏了上下文切换的细节。
2. 协程与异步 IO 的契合
在 I/O 密集型应用中,最常见的瓶颈是同步 I/O 调用阻塞线程。传统解决方案有:
- 线程池 + 阻塞 I/O
- 事件循环 + 回调(如 libuv)
- 组合式异步框架(Boost.Asio 等)
协程提供了 “协作式异步” 的思路:使用 co_await 等待 I/O 结果,等待期间协程挂起,线程继续执行其他任务。当 I/O 完成后,线程调度器恢复协程,继续执行后续逻辑。这样既避免了线程切换的开销,也保持了代码的直线流程。
3. 设计异步 IO 协程的关键组件
下面列出实现异步 I/O 协程时常见的组件:
| 组件 | 作用 |
|---|---|
Awaitable |
封装异步操作的对象,提供 await_ready、await_suspend、await_resume 方法 |
I/O Loop |
负责收集可读/可写事件并通知对应协程 |
Scheduler |
负责管理协程队列与线程池,决定何时恢复协程 |
Buffer |
数据缓冲区,防止一次性读写造成的复制成本 |
4. 示例:基于 epoll 的 TCP 客户端
下面给出一个最小化的基于 epoll 的 TCP 客户端示例,演示如何把异步读写封装成协程。
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <coroutine>
#include <iostream>
#include <vector>
#include <optional>
#include <cstring>
// 设置 socket 为非阻塞
void set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
// 简单的 awaitable,等待文件描述符可读
struct ReadAwaitable {
int fd;
std::vector <char> buffer;
bool await_ready() const noexcept { return false; }
std::coroutine_handle<> await_suspend(std::coroutine_handle<> h) const noexcept {
// 将协程挂起,交给 epoll 监听可读事件
struct epoll_event ev{};
ev.events = EPOLLIN | EPOLLET;
ev.data.ptr = new std::pair<std::coroutine_handle<>, int>(h, fd); // 关联协程和 fd
epoll_ctl(g_epoll_fd, EPOLL_CTL_ADD, fd, &ev);
return h;
}
std::optional<std::size_t> await_resume() const noexcept {
std::size_t n = ::read(fd, buffer.data(), buffer.size());
return n > 0 ? std::optional<std::size_t>(n) : std::nullopt;
}
};
// 全局 epoll 文件描述符(简化示例)
int g_epoll_fd;
// 事件循环线程
void epoll_loop() {
constexpr int MAX_EVENTS = 64;
std::vector<struct epoll_event> events(MAX_EVENTS);
while (true) {
int n = epoll_wait(g_epoll_fd, events.data(), MAX_EVENTS, -1);
for (int i = 0; i < n; ++i) {
auto* pair = static_cast<std::pair<std::coroutine_handle<>, int>*>(events[i].data.ptr);
int fd = pair->second;
pair->first.resume(); // 恢复协程
epoll_ctl(g_epoll_fd, EPOLL_CTL_DEL, fd, nullptr);
delete pair;
}
}
}
// 协程任务
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 client_task(const char* host, uint16_t port) {
int sock = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
struct sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
inet_pton(AF_INET, host, &addr.sin_addr);
connect(sock, (sockaddr*)&addr, sizeof(addr));
// 简单检查连接完成
co_await std::suspend_always{};
// 发送数据
const char* msg = "Hello, world!";
::write(sock, msg, strlen(msg));
// 等待响应
ReadAwaitable reader{sock, std::vector <char>(1024)};
if (auto opt = co_await reader) {
std::cout << "Received " << *opt << " bytes: " << std::string(reader.buffer.data(), *opt) << '\n';
} else {
std::cout << "Read failed\n";
}
close(sock);
}
int main() {
g_epoll_fd = epoll_create1(0);
std::thread(epoll_loop).detach();
client_task("127.0.0.1", 8080);
std::this_thread::sleep_for(std::chrono::seconds(2)); // 给协程执行时间
}
说明
- 由于篇幅限制,示例省略了错误处理与完整的连接状态判断。
ReadAwaitable在await_suspend时把协程与文件描述符关联,放进 epoll。- 事件循环线程负责
epoll_wait并恢复对应协程。
5. 性能与可维护性
- 性能:协程减少了线程切换和上下文保存的开销;在 I/O 等待时,线程可以处理其他任务。
- 可维护性:协程代码几乎保持同步写法,逻辑更直观;错误处理可统一使用
try/catch。 - 可扩展性:可以在协程中嵌套多个
co_await,实现并发请求、流水线处理等复杂场景。
6. 进一步阅读与实践
- 《C++ Concurrency in Action, 2nd Edition》: 详细讲解协程与异步编程。
- Boost.Asio: 原生支持协程的异步 I/O 库。
- libuv / libevent: 传统事件驱动库,了解其底层实现有助于更好地把握协程设计。
通过以上设计与代码示例,你已经掌握了如何利用 C++20 协程实现高性能、可读性强的异步 I/O。接下来可以尝试在生产环境中替换传统回调,或结合 std::execution::par 与协程实现真正的异步并行。祝你编码愉快!