协程是 C++20 标准中引入的一项重要特性,旨在提供一种更简洁、更直观的方式来编写异步、惰性计算或生成器逻辑。与传统的回调或 Future 机制相比,协程可以让代码保持同步风格,同时保持异步执行的优势。下面我们将从协程的基本概念、关键语法、实现原理以及实际应用四个方面进行详细剖析。
一、协程基本概念
- 协程(Coroutine):是一段可以在执行过程中暂停并恢复的函数。它通过保存执行上下文(如栈帧、局部变量等)来实现“挂起”和“恢复”。
- 挂起点(Suspension Point):协程内部的
co_await、co_yield或co_return语句是协程的挂起点。 - 协程句柄(Coroutine Handle):
std::coroutine_handle<>用于管理协程生命周期,包括检查是否已完成、手动恢复等。
二、关键语法
1. co_await
- 用于等待一个可等待对象(Awaitable)。
co_await expr会先调用expr.await_ready(),如果返回false,则挂起并把expr的状态保存。- 当可等待对象变为就绪时,协程会被恢复。
2. co_yield
- 用于生成器(Generator)模式。
- 每次
co_yield value会将value产出给调用者,然后挂起。 - 调用者通过
next()或operator++来恢复协程。
3. co_return
- 用于协程的最终返回值。
co_return value会把value传递给外部,然后终止协程。
4. awaitable 类型
- 一个对象要实现
await_ready()、await_suspend()、await_resume()三个成员函数。 await_ready()判断是否立即完成。await_suspend()在挂起时被调用,通常用于注册回调。await_resume()在恢复时被调用,返回最终结果。
三、实现原理
协程的实现依赖于编译器生成的状态机。编译器会把协程函数拆分为若干个状态,生成一个内部结构体(或类)来保存局部变量。每个挂起点对应一个状态转移:
-
状态机生成
- 编译器将协程函数中的所有挂起点映射到状态编号。
- 生成一个
promise_type(约定结构),用于存储协程结果、异常等。
-
挂起和恢复
await_suspend()接收coroutine_handle,可以将该句柄存入事件循环或任务队列。- 当事件完成后,事件循环调用
handle.resume(),恢复协程到下一个挂起点。
-
栈展开
- 协程不会在每次挂起时创建新的栈帧,而是使用统一的状态机对象保存所有局部变量,避免栈空间消耗。
四、实战示例:异步文件读取
下面给出一个使用协程实现异步文件读取的完整示例。代码使用标准库的 std::filesystem、std::fstream 以及自定义的 async_read Awaitable。
#include <coroutine>
#include <exception>
#include <iostream>
#include <fstream>
#include <string>
#include <vector>
#include <filesystem>
#include <future>
// 1. Awaitable 对象
struct async_read {
std::string path;
std::vector <char> buffer;
std::size_t size;
std::coroutine_handle<> handle;
std::promise<std::vector<char>> promise;
async_read(std::string p, std::size_t sz)
: path(std::move(p)), size(sz) {}
bool await_ready() { return false; } // 始终挂起
void await_suspend(std::coroutine_handle<> h) {
handle = h;
std::async(std::launch::async, [this]() {
std::ifstream file(path, std::ios::binary);
buffer.resize(size);
file.read(buffer.data(), size);
if (!file) {
promise.set_exception(std::make_exception_ptr(
std::runtime_error("读取文件失败")));
} else {
promise.set_value(buffer);
}
});
}
std::vector <char> await_resume() {
return promise.get_future().get();
}
};
// 2. 协程函数
std::future<std::vector<char>> read_file(std::string path, std::size_t sz) {
async_read reader(std::move(path), sz);
std::vector <char> data = co_await reader;
co_return data;
}
// 3. 主函数演示
int main() {
try {
auto fut = read_file("example.txt", 1024);
std::vector <char> contents = fut.get(); // 阻塞等待完成
std::cout << "读取到 " << contents.size() << " 字节内容。\n";
} catch (const std::exception& e) {
std::cerr << "错误: " << e.what() << '\n';
}
}
说明
async_read是一个可等待对象,内部使用std::async异步读取文件。await_suspend将协程句柄保存在对象里,以便在异步读取完成后手动恢复。- 在
main中,read_file返回一个std::future,主线程可以get()等待结果。
五、协程 vs. 传统异步方案
| 方案 | 代码风格 | 可维护性 | 性能 | 适用场景 |
|---|---|---|---|---|
| 回调 | 嵌套、层层回调 | 低 | 低 | 低层 IO |
| Future/Promise | 需要链式 then |
中 | 中 | 异步链 |
| Coroutine | 同步风格 | 高 | 高 | 网络、文件、生成器等 |
协程最大的优势在于保持同步的可读性,并且通过编译器生成的状态机实现了高效的上下文切换。
六、常见陷阱与注意事项
- 异常传播
await_resume()中的异常会抛到协程外部,需在调用方使用try/catch或std::future捕获。
- 对象生命周期
- Awaitable 对象必须在协程挂起期间保持生命周期,避免使用局部变量导致悬挂。
- 事件循环
- 在
await_suspend()中不应直接阻塞线程,而是注册到事件循环或线程池。
- 在
七、总结
C++20 的协程为异步编程提供了一条全新的通路:
- 更直观:代码几乎像同步写法,易于理解。
- 更高效:状态机避免了栈展开,异步 I/O 只需一次上下文切换。
- 更灵活:协程可以与
std::future、std::async、网络库(如 Boost.Asio)无缝结合。
随着标准库和第三方库对协程的逐步完善,未来 C++ 开发者将能更专注于业务逻辑,而不必再为复杂的异步流程编写繁琐的回调链。协程的普及,正是 C++ 生态向现代化迈出的重要一步。