文章目录
- [C++20 协程从零入门:用 ASCII 图彻底理解 `co_await`、协程帧与异步执行](#C++20 协程从零入门:用 ASCII 图彻底理解
co_await、协程帧与异步执行) - 一、协程到底解决了什么问题
- 二、协程就是"可以暂停和恢复的函数"
- 三、协程不是线程
-
- [3.1 协程不会自动创建线程](#3.1 协程不会自动创建线程)
- [3.2 谁调用 `resume()`,协程就在哪个线程恢复](#3.2 谁调用
resume(),协程就在哪个线程恢复)
- [四、C++20 协程的三个关键字](#四、C++20 协程的三个关键字)
- [五、协程为什么不能直接返回 `void`](#五、协程为什么不能直接返回
void) - 六、协程暂停以后,局部变量放在哪里
-
- [6.1 普通函数的栈帧](#6.1 普通函数的栈帧)
- [6.2 协程帧](#6.2 协程帧)
- [七、`std::coroutine_handle` 是什么](#七、
std::coroutine_handle是什么) - 八、第一份可运行代码:手动暂停和恢复协程
-
- [8.1 `task.hpp`](#8.1
task.hpp) - [8.2 `main.cpp`](#8.2
main.cpp) - [8.3 编译](#8.3 编译)
- [8.1 `task.hpp`](#8.1
- 九、第一份代码的完整执行图
- [十、`initial_suspend()` 到底控制什么](#十、
initial_suspend()到底控制什么) -
- [10.1 返回 `std::suspend_never`](#10.1 返回
std::suspend_never) - [10.2 返回 `std::suspend_always`](#10.2 返回
std::suspend_always)
- [10.1 返回 `std::suspend_never`](#10.1 返回
- [十一、`final_suspend()` 为什么重要](#十一、
final_suspend()为什么重要) -
- [11.1 `final_suspend()` 返回 `suspend_always`](#11.1
final_suspend()返回suspend_always) - [11.2 错误组合](#11.2 错误组合)
- [11.1 `final_suspend()` 返回 `suspend_always`](#11.1
- [十二、`co_await` 到底做了什么](#十二、
co_await到底做了什么) - [十三、Awaiter 的三个核心函数](#十三、Awaiter 的三个核心函数)
-
- [13.1 `await_ready()`](#13.1
await_ready()) - [13.2 `await_suspend()`](#13.2
await_suspend()) - [13.3 `await_resume()`](#13.3
await_resume())
- [13.1 `await_ready()`](#13.1
- [十四、自己实现一个最简单的 Awaiter](#十四、自己实现一个最简单的 Awaiter)
- [十五、实现一个手动事件 `ManualEvent`](#十五、实现一个手动事件
ManualEvent) -
- [15.1 完整代码](#15.1 完整代码)
- [15.2 执行图](#15.2 执行图)
- [十六、这和真实异步网络 I/O 有什么关系](#十六、这和真实异步网络 I/O 有什么关系)
- 十七、实现一个异步定时器
-
- [17.1 为什么不能直接调用 `sleep_for`](#17.1 为什么不能直接调用
sleep_for) - [17.2 `DetachedTask`](#17.2
DetachedTask) - [17.3 `SleepFor` Awaiter](#17.3
SleepForAwaiter) - [17.4 完整定时器程序](#17.4 完整定时器程序)
- [17.5 定时器执行图](#17.5 定时器执行图)
- [17.1 为什么不能直接调用 `sleep_for`](#17.1 为什么不能直接调用
- [十八、为什么不能在 `main` 中固定睡眠四秒](#十八、为什么不能在
main中固定睡眠四秒) - [十九、使用 `co_yield` 实现生成器](#十九、使用
co_yield实现生成器) -
- [19.1 完整生成器代码](#19.1 完整生成器代码)
- [19.2 `co_yield` 的转换过程](#19.2
co_yield的转换过程)
- 二十、协程中的局部变量与生命周期
-
- [20.1 普通值通常可以跨越挂起点](#20.1 普通值通常可以跨越挂起点)
- [20.2 引用不会自动延长对象生命周期](#20.2 引用不会自动延长对象生命周期)
- [20.3 `this` 指针也可能悬空](#20.3
this指针也可能悬空)
- [二十一、协程 Lambda 为什么容易出现问题](#二十一、协程 Lambda 为什么容易出现问题)
- 二十二、协程常见使用场景
-
- [22.1 网络服务器](#22.1 网络服务器)
- [22.2 定时任务](#22.2 定时任务)
- [22.3 游戏脚本](#22.3 游戏脚本)
- [22.4 惰性序列](#22.4 惰性序列)
- 二十三、协程不能自动解决什么问题
-
- [23.1 协程不会自动并行](#23.1 协程不会自动并行)
- [23.2 协程不会把阻塞 I/O 自动变成异步 I/O](#23.2 协程不会把阻塞 I/O 自动变成异步 I/O)
- 二十四、完整项目结构
- 二十五、CMakeLists.txt
- 二十六、学习协程时应该按什么顺序
-
- 第一阶段:理解暂停和恢复
- [第二阶段:理解 Awaiter](#第二阶段:理解 Awaiter)
- 第三阶段:理解外部事件恢复协程
- 第四阶段:理解线程与协程的关系
- 第五阶段:理解生成器
- 二十七、最后建立完整思维模型
C++20 协程从零入门:用 ASCII 图彻底理解 co_await、协程帧与异步执行
适合读者:
会写普通 C++ 函数,但第一次接触 C++20 协程。
本文不要求你提前了解
promise、状态机或异步框架。我们会从"普通函数为什么不能暂停"开始,一步一步写出可以编译运行的协程程序。
一、协程到底解决了什么问题
先看一个普通函数:
cpp
#include <iostream>
void normal_function()
{
std::cout << "步骤 1:开始执行\n";
std::cout << "步骤 2:处理中\n";
std::cout << "步骤 3:执行结束\n";
}
int main()
{
std::cout << "main:调用普通函数\n";
normal_function();
std::cout << "main:普通函数返回\n";
}
普通函数的执行流程是:
text
main()
│
│ 调用 normal_function()
▼
normal_function()
│
├── 执行步骤 1
├── 执行步骤 2
├── 执行步骤 3
│
▼
函数结束,返回 main()
│
▼
main() 继续执行
普通函数具有一个非常重要的特点:
text
调用者
│
│ 调用函数
▼
被调用函数必须执行完毕
│
│ return
▼
调用者才能继续执行
普通函数不能在中间说:
text
我先停在这里。
你先去执行其他代码。
以后再回来从这里继续执行。
而这正是协程能够做到的事情。
二、协程就是"可以暂停和恢复的函数"
协程的执行流程可以是:
text
main()
│
│ 调用协程
▼
协程开始执行
│
├── 执行步骤 1
├── 执行步骤 2
│
├── 遇到 co_await
│
▼
协程暂停
│
└──────────────────────┐
│
main() 继续执行 │
│ │
├── 做其他事情 │
│ │
├── 调用 resume() ──────┘
▼
协程从 co_await 后继续执行
│
├── 执行步骤 3
│
▼
协程结束
所以,可以把协程理解为:
text
协程 = 可以暂停 + 可以保存状态 + 可以恢复的函数
三、协程不是线程
这是初学协程时最容易出现的误解。
3.1 协程不会自动创建线程
下面的协程:
cpp
Task test()
{
std::cout << "协程开始\n";
co_await std::suspend_always{};
std::cout << "协程恢复\n";
}
本身不会创建任何新线程。
它只做两件事:
text
1. 执行到 co_await 时暂停
2. 将来有人调用 resume() 时继续执行
3.2 谁调用 resume(),协程就在哪个线程恢复
假设主线程调用:
cpp
handle.resume();
那么协程就在主线程执行。
text
主线程
│
├── handle.resume()
│
▼
协程在主线程继续执行
如果工作线程调用:
cpp
handle.resume();
那么协程就在工作线程中恢复。
text
工作线程
│
├── handle.resume()
│
▼
协程在工作线程继续执行
因此:
text
协程负责:暂停、保存状态、恢复
线程负责:真正运行代码
事件循环负责:决定什么时候恢复协程
四、C++20 协程的三个关键字
C++20 提供了三个协程关键字:
| 关键字 | 作用 |
|---|---|
co_await |
等待某个操作,必要时暂停当前协程 |
co_return |
结束协程,或者返回协程结果 |
co_yield |
产生一个值,然后暂停协程 |
例如:
cpp
Task example()
{
// 等待某个操作。
co_await some_operation();
// 结束协程。
co_return;
}
只要函数体中出现以下任意一个关键字:
cpp
co_await
co_return
co_yield
编译器就可能把这个函数转换成协程。
五、协程为什么不能直接返回 void
初学者可能会这样写:
cpp
void test()
{
co_await std::suspend_always{};
}
这通常不能编译。
原因是协程执行到一半可以暂停,所以调用者必须能够获得一个对象,用来管理协程。
这个对象通常叫:
text
Task
Generator
Awaitable
AsyncTask
例如:
cpp
Task test()
{
co_await std::suspend_always{};
}
调用者拿到 Task 后,可以通过它:
text
恢复协程
判断协程是否结束
销毁协程帧
取得返回值
处理异常
六、协程暂停以后,局部变量放在哪里
考虑下面的代码:
cpp
Task test()
{
int number = 100;
co_await std::suspend_always{};
std::cout << number << '\n';
}
问题是:
text
协程执行到 co_await 后,函数暂时返回了。
但是以后恢复时,number 为什么还存在?
答案是:
编译器会为协程创建一块特殊内存,叫作协程帧。
英文名称:
text
Coroutine Frame
6.1 普通函数的栈帧
普通函数调用时,局部变量通常保存在函数栈帧中:
text
线程栈
高地址
┌────────────────────────┐
│ main() 的局部变量 │
├────────────────────────┤
│ test() 的局部变量 │
│ number = 100 │
├────────────────────────┤
│ 返回地址 │
└────────────────────────┘
低地址
当 test() 返回后,它的栈帧通常失效:
text
test() 返回
│
▼
test() 的普通局部变量生命周期结束
6.2 协程帧
协程可能在函数"返回"以后继续执行,因此跨越挂起点的数据需要被保存。
编译器会生成类似下面的结构:
text
协程帧 Coroutine Frame
┌────────────────────────────────┐
│ promise_type 对象 │
├────────────────────────────────┤
│ 协程函数参数 │
├────────────────────────────────┤
│ 跨越挂起点的局部变量 │
│ number = 100 │
├────────────────────────────────┤
│ 当前执行位置 │
│ 例如:暂停在第一个 co_await │
├────────────────────────────────┤
│ 当前 Awaiter 对象 │
├────────────────────────────────┤
│ 异常状态、返回值等 │
└────────────────────────────────┘
协程暂停后:
text
普通函数调用暂时返回
│
▼
协程帧仍然存在
│
├── 局部变量仍然存在
├── 当前执行位置仍然存在
└── promise_type 仍然存在
恢复协程时:
text
coroutine_handle
│
▼
找到协程帧
│
▼
从保存的执行位置继续执行
七、std::coroutine_handle 是什么
协程句柄可以理解为:
指向协程帧的控制器。
可以把关系画成:
text
Task 对象
┌──────────────────────────┐
│ coroutine_handle handle │
└─────────────┬────────────┘
│
│ 指向
▼
协程帧
┌──────────────────────────┐
│ promise_type │
│ 局部变量 │
│ 参数 │
│ 当前执行位置 │
└──────────────────────────┘
通过句柄可以进行三个常见操作:
cpp
handle.resume(); // 恢复协程
handle.done(); // 判断协程是否已经结束
handle.destroy(); // 销毁协程帧
可以把它们理解为:
text
resume() :继续运行
done() :查询是否执行完
destroy() :释放协程帧内存
八、第一份可运行代码:手动暂停和恢复协程
首先实现一个最小的 Task。
项目结构:
text
cpp20_coroutine/
├── task.hpp
└── main.cpp
8.1 task.hpp
cpp
#pragma once
#include <coroutine>
#include <exception>
#include <utility>
// Task 表示一个"无返回值的协程任务"。
//
// 它主要负责:
// 1. 保存协程句柄。
// 2. 恢复协程。
// 3. 判断协程是否结束。
// 4. 在析构时销毁协程帧。
// 5. 保存并重新抛出协程中的异常。
class Task
{
public:
// 先声明 promise_type。
//
// 编译器发现某个协程函数返回 Task 时,
// 会查找 Task::promise_type。
struct promise_type;
// handle_type 是一个"带 promise_type 类型信息"的协程句柄。
//
// 它不仅能恢复和销毁协程,
// 还可以通过 promise() 访问协程帧中的 promise_type。
using handle_type = std::coroutine_handle<promise_type>;
public:
// Task 由协程句柄构造。
explicit Task(handle_type handle) noexcept
: handle_(handle)
{
}
// Task 负责协程帧的所有权。
//
// 当 Task 被销毁时,也要销毁协程帧。
~Task()
{
if (handle_)
{
handle_.destroy();
}
}
// 禁止拷贝。
//
// 原因:
// 如果两个 Task 保存同一个协程句柄,
// 那么两个 Task 析构时都会调用 destroy(),
// 导致重复释放协程帧。
Task(const Task&) = delete;
Task& operator=(const Task&) = delete;
// 允许移动构造。
//
// 移动后,协程句柄的所有权转移给新对象。
Task(Task&& other) noexcept
: handle_(std::exchange(other.handle_, {}))
{
}
// 允许移动赋值。
Task& operator=(Task&& other) noexcept
{
if (this != &other)
{
// 当前 Task 可能已经管理一个协程帧,
// 需要先销毁原来的协程帧。
if (handle_)
{
handle_.destroy();
}
// 接管 other 的协程句柄。
handle_ = std::exchange(other.handle_, {});
}
return *this;
}
// 判断协程是否执行结束。
[[nodiscard]] bool done() const noexcept
{
// 没有句柄,也可以认为任务不可再执行。
return !handle_ || handle_.done();
}
// 恢复协程。
//
// 返回值:
// true :恢复后协程仍未结束。
// false :协程不存在,或者已经执行结束。
bool resume()
{
// 不能恢复空句柄。
if (!handle_)
{
return false;
}
// 已经执行结束的协程不能再次 resume。
if (handle_.done())
{
return false;
}
// 从上一次挂起点继续执行。
handle_.resume();
// 如果协程已经执行结束,并且其中保存了异常,
// 就把异常重新抛到调用 resume() 的位置。
if (handle_.done() &&
handle_.promise().exception_)
{
std::rethrow_exception(
handle_.promise().exception_
);
}
return !handle_.done();
}
public:
// promise_type 是编译器与 Task 之间的接口。
//
// 编译器通过它决定:
// 1. 协程返回什么对象。
// 2. 协程开始前是否挂起。
// 3. 协程结束后是否挂起。
// 4. co_return 如何处理。
// 5. 异常如何处理。
struct promise_type
{
// 保存协程中没有被捕获的异常。
std::exception_ptr exception_;
// 创建调用者最终拿到的 Task 对象。
Task get_return_object() noexcept
{
// 当前 promise_type 位于协程帧中。
//
// from_promise(*this) 可以通过 promise_type
// 找到它所属的协程帧,并创建协程句柄。
return Task{
handle_type::from_promise(*this)
};
}
// initial_suspend 决定:
// 协程创建后,是否在进入函数体之前先暂停。
//
// suspend_never 表示"不暂停",立即执行协程函数体。
std::suspend_never initial_suspend() const noexcept
{
return {};
}
// final_suspend 决定:
// 协程函数体执行结束后,是否保留协程帧。
//
// suspend_always 表示协程结束后先挂起,
// 不立即自动销毁协程帧。
//
// 最后由 Task 析构函数调用 handle.destroy()。
std::suspend_always final_suspend() const noexcept
{
return {};
}
// 处理无返回值的 co_return。
//
// 对于下面的代码:
//
// co_return;
//
// 编译器最终会调用 return_void()。
void return_void() const noexcept
{
}
// 处理协程中未捕获的异常。
void unhandled_exception() noexcept
{
exception_ = std::current_exception();
}
};
private:
// Task 持有的协程句柄。
handle_type handle_{};
};
8.2 main.cpp
cpp
#include "task.hpp"
#include <coroutine>
#include <iostream>
// 只要函数体中出现 co_await、co_return 或 co_yield,
// 这个函数就可能被编译器转换成协程。
Task basic_coroutine()
{
std::cout << "2. 协程:开始执行\n";
int number = 100;
std::cout << "3. 协程:number = "
<< number
<< '\n';
std::cout << "4. 协程:即将执行 co_await\n";
// std::suspend_always 是标准库提供的等待器。
//
// 它的含义是:
// 执行到这里时,协程一定挂起。
//
// 协程暂停后,控制权回到 main。
co_await std::suspend_always{};
// 只有外部调用 resume() 后,才会执行到这里。
std::cout << "7. 协程:已经恢复执行\n";
// number 跨越了挂起点。
//
// 它通常保存在协程帧中,
// 所以恢复后仍然可以正常访问。
std::cout << "8. 协程:number 仍然等于 "
<< number
<< '\n';
// 无返回值地结束协程。
co_return;
}
int main()
{
std::cout << "1. main:准备调用协程\n";
// 调用 basic_coroutine() 时:
//
// 1. 创建协程帧。
// 2. 创建 promise_type。
// 3. 创建 Task。
// 4. initial_suspend() 返回 suspend_never。
// 5. 立即进入协程函数体。
// 6. 一直执行到第一个真正的挂起点。
Task task = basic_coroutine();
// 协程执行到 co_await suspend_always 后暂停,
// 所以程序回到 main。
std::cout << "5. main:重新获得控制权\n";
std::cout << "6. main:调用 task.resume()\n";
// 恢复协程。
task.resume();
std::cout << "9. main:协程是否已经结束:"
<< std::boolalpha
<< task.done()
<< '\n';
// main 结束时,task 被析构。
//
// Task::~Task() 会执行 handle.destroy(),
// 释放协程帧。
return 0;
}
8.3 编译
GCC:
bash
g++ -std=c++20 -Wall -Wextra -pedantic \
main.cpp -o coroutine_demo
运行:
bash
./coroutine_demo
可能输出:
text
1. main:准备调用协程
2. 协程:开始执行
3. 协程:number = 100
4. 协程:即将执行 co_await
5. main:重新获得控制权
6. main:调用 task.resume()
7. 协程:已经恢复执行
8. 协程:number 仍然等于 100
9. main:协程是否已经结束:true
九、第一份代码的完整执行图
调用:
cpp
Task task = basic_coroutine();
编译器大致执行:
text
main()
│
│ 调用 basic_coroutine()
▼
创建协程帧
┌─────────────────────────────┐
│ promise_type │
│ number │
│ 当前执行位置 │
│ 其他协程状态 │
└─────────────────────────────┘
│
▼
调用 promise.get_return_object()
│
▼
创建 Task
│
▼
调用 initial_suspend()
│
├── 返回 suspend_never
│
▼
立即进入协程函数体
执行到:
cpp
co_await std::suspend_always{};
流程变成:
text
basic_coroutine()
│
├── number = 100
│
├── 执行 co_await
│
▼
协程暂停
│
├── 保存当前执行位置
├── 保存 number
└── 保留协程帧
│
▼
控制权返回 main()
调用:
cpp
task.resume();
流程为:
text
main()
│
├── task.resume()
│
▼
Task::resume()
│
├── handle_.resume()
│
▼
通过句柄找到协程帧
│
▼
从 co_await 后继续执行
│
├── 输出 number
├── 执行 co_return
│
▼
调用 return_void()
│
▼
调用 final_suspend()
│
├── 返回 suspend_always
│
▼
协程结束,但协程帧暂时保留
最后:
text
main() 结束
│
▼
Task 析构
│
├── handle.destroy()
│
▼
协程帧被释放
十、initial_suspend() 到底控制什么
initial_suspend() 控制:
text
协程创建后,要不要在进入函数体之前先暂停?
10.1 返回 std::suspend_never
cpp
std::suspend_never initial_suspend() noexcept
{
return {};
}
执行过程:
text
调用协程函数
│
▼
创建协程帧
│
▼
不暂停
│
▼
立即进入协程函数体
这种模式通常叫:
text
Eager Coroutine
急切启动协程
调用协程后,协程会立即开始执行。
10.2 返回 std::suspend_always
cpp
std::suspend_always initial_suspend() noexcept
{
return {};
}
执行过程:
text
调用协程函数
│
▼
创建协程帧
│
▼
在函数体执行前暂停
│
▼
返回 Task 给调用者
此时协程函数体一行都没有执行。
只有调用:
cpp
task.resume();
才会真正进入函数体。
这种模式通常叫:
text
Lazy Coroutine
惰性启动协程
十一、final_suspend() 为什么重要
final_suspend() 控制:
text
协程函数体结束后,协程帧是否立即自动销毁。
11.1 final_suspend() 返回 suspend_always
cpp
std::suspend_always final_suspend() noexcept
{
return {};
}
执行流程:
text
协程函数体结束
│
▼
进入 final_suspend
│
▼
暂停在最终挂起点
│
▼
协程帧继续保留
│
▼
以后由 Task 调用 destroy()
这种情况下:
cpp
~Task()
{
handle.destroy();
}
是合理的。
11.2 错误组合
下面的写法非常危险:
cpp
std::suspend_never final_suspend() noexcept
{
return {};
}
~Task()
{
handle.destroy();
}
因为:
text
协程函数体结束
│
▼
final_suspend 返回 suspend_never
│
▼
协程帧可能已经自动销毁
│
▼
Task 析构再次调用 destroy()
│
▼
重复销毁
│
▼
未定义行为
所以需要记住:
text
Task 持有句柄并负责 destroy()
│
▼
final_suspend 通常使用 suspend_always
十二、co_await 到底做了什么
下面是整个协程机制中最重要的一部分。
代码:
cpp
int value = co_await operation;
大致可以理解为:
cpp
auto awaiter = 获取 operation 对应的 Awaiter;
if (!awaiter.await_ready())
{
awaiter.await_suspend(当前协程句柄);
// 当前协程可能在这里暂停。
}
// 协程恢复后执行。
int value = awaiter.await_resume();
注意,这只是帮助理解的伪代码,不是标准规定的完整编译器转换结果。
十三、Awaiter 的三个核心函数
一个典型 Awaiter 包含:
cpp
struct Awaiter
{
bool await_ready();
void await_suspend(
std::coroutine_handle<> handle
);
int await_resume();
};
执行顺序为:
text
co_await operation
│
▼
获取 Awaiter
│
▼
await_ready()
│
├── true ──────────────┐
│ │
└── false │
│ │
▼ │
await_suspend(handle) │
│ │
▼ │
协程暂停 │
│ │
外部恢复协程 │
│ │
└────────────────┤
▼
await_resume()
│
▼
得到 co_await 结果
13.1 await_ready()
cpp
bool await_ready();
它负责回答:
text
等待的操作是不是已经完成了?
返回 true:
text
操作已经完成
不需要暂停
直接执行 await_resume()
返回 false:
text
操作还没有完成
执行 await_suspend()
协程可能暂停
13.2 await_suspend()
cpp
void await_suspend(
std::coroutine_handle<> handle
);
参数 handle 是:
text
当前正在执行 co_await 的协程句柄
Awaiter 可以保存这个句柄:
cpp
saved_handle = handle;
等异步操作完成以后,再执行:
cpp
saved_handle.resume();
13.3 await_resume()
cpp
int await_resume();
协程恢复后会调用它。
它通常负责:
text
取得结果
返回结果
检查错误
抛出异常
执行恢复后的清理
例如:
cpp
int await_resume()
{
return result_;
}
那么:
cpp
int value = co_await operation;
中的 value 最终就来自:
cpp
awaiter.await_resume();
十四、自己实现一个最简单的 Awaiter
先实现一个只负责打印流程的等待器。
cpp
#include "task.hpp"
#include <coroutine>
#include <iostream>
struct DebugAwaiter
{
// 第一步:检查操作是否已经完成。
bool await_ready() const noexcept
{
std::cout
<< "DebugAwaiter::await_ready()\n";
// 返回 false,表示操作尚未完成,
// 协程需要进入 await_suspend。
return false;
}
// 第二步:协程即将暂停。
void await_suspend(
std::coroutine_handle<> handle
) const noexcept
{
std::cout
<< "DebugAwaiter::await_suspend()\n";
// 这个示例中不保存 handle,
// 所以外部不能通过 Awaiter 自动恢复协程。
//
// 协程之后需要通过 Task::resume() 手动恢复。
(void)handle;
}
// 第三步:协程恢复后调用。
int await_resume() const noexcept
{
std::cout
<< "DebugAwaiter::await_resume()\n";
// co_await 表达式的结果就是这个返回值。
return 123;
}
};
Task awaiter_demo()
{
std::cout << "1. 协程:执行 co_await 前\n";
int result = co_await DebugAwaiter{};
std::cout << "5. 协程:co_await 返回结果 = "
<< result
<< '\n';
}
int main()
{
Task task = awaiter_demo();
std::cout << "4. main:准备恢复协程\n";
task.resume();
}
输出顺序:
text
1. 协程:执行 co_await 前
DebugAwaiter::await_ready()
DebugAwaiter::await_suspend()
2. main:准备恢复协程
DebugAwaiter::await_resume()
3. 协程:co_await 返回结果 = 123
完整流程:
text
协程执行 co_await DebugAwaiter{}
│
▼
调用 await_ready()
│
├── 返回 false
│
▼
调用 await_suspend(handle)
│
▼
协程暂停,main 继续执行
│
▼
main 调用 task.resume()
│
▼
协程恢复
│
▼
调用 await_resume()
│
├── 返回 123
│
▼
result = 123
十五、实现一个手动事件 ManualEvent
现在实现一个更接近真实异步模型的例子。
目标:
text
协程等待事件
│
▼
协程暂停
│
▼
main 触发事件
│
▼
事件自动恢复协程
15.1 完整代码
cpp
#include "task.hpp"
#include <coroutine>
#include <iostream>
#include <utility>
class ManualEvent
{
public:
// Awaiter 是真正参与 co_await 协议的对象。
class Awaiter
{
public:
explicit Awaiter(
ManualEvent& event
) noexcept
: event_(event)
{
}
// 如果事件已经触发,则不需要暂停协程。
bool await_ready() const noexcept
{
return event_.signaled_;
}
// 如果事件还没有触发,
// 就保存当前协程句柄。
void await_suspend(
std::coroutine_handle<> handle
) noexcept
{
event_.waiter_ = handle;
}
// 这个事件没有返回值,
// 所以 await_resume 返回 void。
void await_resume() const noexcept
{
}
private:
ManualEvent& event_;
};
public:
// 当代码写:
//
// co_await event;
//
// 编译器可以通过 operator co_await()
// 得到真正的 Awaiter。
Awaiter operator co_await() noexcept
{
return Awaiter{*this};
}
// 触发事件。
void set()
{
signaled_ = true;
if (waiter_)
{
// 先取出并清空 waiter_。
//
// 这样可以避免恢复协程后,
// 事件对象中继续保留旧句柄。
auto handle =
std::exchange(waiter_, {});
// 恢复等待此事件的协程。
handle.resume();
}
}
private:
// 事件是否已经触发。
bool signaled_ = false;
// 正在等待该事件的协程句柄。
//
// 这个简单版本只支持一个等待者。
std::coroutine_handle<> waiter_{};
};
Task wait_for_event(ManualEvent& event)
{
std::cout << "1. 协程:开始等待事件\n";
// 如果 event 尚未 set(),
// 协程会在这里暂停。
co_await event;
// event.set() 恢复协程后,
// 才会执行到这里。
std::cout << "4. 协程:事件已经触发\n";
}
int main()
{
ManualEvent event;
// 协程立即启动。
//
// 它执行到 co_await event 时暂停。
Task task = wait_for_event(event);
std::cout << "2. main:协程已经挂起\n";
std::cout << "3. main:调用 event.set()\n";
// set() 内部会调用协程句柄的 resume()。
event.set();
std::cout << "5. main:协程是否结束 = "
<< std::boolalpha
<< task.done()
<< '\n';
}
15.2 执行图
text
main()
│
├── 创建 ManualEvent event
│
├── 调用 wait_for_event(event)
│
▼
协程开始执行
│
├── 输出"开始等待事件"
│
├── co_await event
│
▼
event.operator co_await()
│
▼
创建 ManualEvent::Awaiter
│
▼
Awaiter::await_ready()
│
├── event.signaled_ == false
│
└── 返回 false
│
▼
Awaiter::await_suspend(handle)
│
├── event.waiter_ = handle
│
▼
协程暂停
│
▼
main() 继续执行
│
├── event.set()
│
▼
ManualEvent::set()
│
├── signaled_ = true
├── 取出 waiter_
├── waiter_.resume()
│
▼
协程恢复
│
├── await_resume()
├── 执行 co_await 后面的代码
│
▼
协程结束
十六、这和真实异步网络 I/O 有什么关系
真实网络异步读取大致是:
cpp
std::string data =
co_await socket.async_read();
内部可能执行:
text
协程调用 async_read()
│
▼
检查 Socket 是否已经有数据
│
├── 有数据
│ │
│ ▼
│ 直接返回数据
│
└── 没有数据
│
▼
把 Socket 注册到 epoll
│
▼
保存当前协程句柄
│
▼
协程暂停
│
▼
epoll_wait 等待事件
│
▼
Socket 变为可读
│
▼
事件循环调用 handle.resume()
│
▼
协程恢复
│
▼
await_resume() 读取数据
│
▼
co_await 表达式得到结果
所以,协程并没有取代 epoll。
它只是把原本的回调写法:
cpp
async_read(socket, [](std::string data) {
async_write(socket, data, [](bool success) {
// 继续处理。
});
});
改造成更接近同步代码的写法:
cpp
Task handle_client(Socket socket)
{
std::string data =
co_await socket.async_read();
co_await socket.async_write(data);
}
底层仍然需要:
text
epoll
IOCP
线程池
事件循环
操作系统异步接口
十七、实现一个异步定时器
下面使用工作线程模拟异步定时器。
目标:
cpp
co_await SleepFor{1000ms};
执行效果:
text
协程暂停一秒
主线程不会被阻塞
一秒后工作线程恢复协程
17.1 为什么不能直接调用 sleep_for
下面的代码虽然是协程,但依旧会阻塞当前线程:
cpp
Task wrong_sleep()
{
std::this_thread::sleep_for(
std::chrono::seconds(1)
);
co_return;
}
流程是:
text
当前线程
│
├── 调用 sleep_for()
│
▼
当前线程真的睡眠一秒
│
▼
一秒后继续执行
协程关键字不会把普通阻塞函数自动转换成异步函数。
17.2 DetachedTask
异步定时器示例使用一个自动销毁的协程类型:
cpp
#include <coroutine>
#include <exception>
struct DetachedTask
{
struct promise_type
{
// 调用者不需要保存任何返回对象。
DetachedTask get_return_object() const noexcept
{
return {};
}
// 创建后立即执行协程函数体。
std::suspend_never initial_suspend() const noexcept
{
return {};
}
// 协程结束后不保留协程帧。
//
// 协程帧会自动销毁。
std::suspend_never final_suspend() const noexcept
{
return {};
}
// 处理无返回值 co_return。
void return_void() const noexcept
{
}
// 这个简单示例遇到异常时直接终止程序。
void unhandled_exception() const noexcept
{
std::terminate();
}
};
};
它的生命周期模式是:
text
DetachedTask 不保存协程句柄
│
▼
final_suspend 返回 suspend_never
│
▼
协程执行结束后自动销毁协程帧
17.3 SleepFor Awaiter
cpp
#include <chrono>
#include <coroutine>
#include <thread>
class SleepFor
{
public:
explicit SleepFor(
std::chrono::milliseconds duration
) noexcept
: duration_(duration)
{
}
// 如果等待时间小于或等于 0,
// 可以认为操作已经完成,不需要挂起。
bool await_ready() const noexcept
{
return duration_.count() <= 0;
}
// 保存协程句柄,并创建工作线程。
void await_suspend(
std::coroutine_handle<> handle
) const
{
const auto duration = duration_;
std::thread(
[duration, handle]() mutable
{
// 工作线程负责等待。
std::this_thread::sleep_for(duration);
// 等待结束后,由工作线程恢复协程。
handle.resume();
}
).detach();
}
// 定时器没有需要返回的数据。
void await_resume() const noexcept
{
}
private:
std::chrono::milliseconds duration_;
};
17.4 完整定时器程序
cpp
#include <chrono>
#include <coroutine>
#include <exception>
#include <iostream>
#include <latch>
#include <thread>
using namespace std::chrono_literals;
struct DetachedTask
{
struct promise_type
{
DetachedTask get_return_object() const noexcept
{
return {};
}
std::suspend_never initial_suspend() const noexcept
{
return {};
}
std::suspend_never final_suspend() const noexcept
{
return {};
}
void return_void() const noexcept
{
}
void unhandled_exception() const noexcept
{
std::terminate();
}
};
};
class SleepFor
{
public:
explicit SleepFor(
std::chrono::milliseconds duration
) noexcept
: duration_(duration)
{
}
bool await_ready() const noexcept
{
return duration_.count() <= 0;
}
void await_suspend(
std::coroutine_handle<> handle
) const
{
const auto duration = duration_;
std::thread(
[duration, handle]() mutable
{
std::this_thread::sleep_for(duration);
handle.resume();
}
).detach();
}
void await_resume() const noexcept
{
}
private:
std::chrono::milliseconds duration_;
};
DetachedTask timer_demo(std::latch& finished)
{
std::cout
<< "协程开始,当前线程 ID = "
<< std::this_thread::get_id()
<< '\n';
// 协程在这里暂停。
//
// SleepFor 会启动工作线程,
// 工作线程一秒后调用 handle.resume()。
co_await SleepFor{1000ms};
std::cout
<< "第一次等待结束,当前线程 ID = "
<< std::this_thread::get_id()
<< '\n';
co_await SleepFor{1000ms};
std::cout
<< "第二次等待结束,当前线程 ID = "
<< std::this_thread::get_id()
<< '\n';
// 通知 main:
// 整个协程已经执行完毕。
finished.count_down();
}
int main()
{
// 初始计数为 1。
//
// 只有协程调用 count_down() 后,
// main 中的 wait() 才会结束。
std::latch finished{1};
std::cout
<< "main 线程 ID = "
<< std::this_thread::get_id()
<< '\n';
// timer_demo 是立即启动的协程。
timer_demo(finished);
// 协程已经在第一个 co_await 处暂停,
// 所以 main 可以继续执行。
std::cout
<< "main:协程等待期间,"
<< "我仍然可以继续执行\n";
// 等待协程真正完成。
finished.wait();
std::cout << "main:协程已经执行结束\n";
}
编译:
bash
g++ -std=c++20 -Wall -Wextra -pedantic \
-pthread async_timer.cpp \
-o async_timer
17.5 定时器执行图
text
主线程
│
├── 调用 timer_demo()
│
▼
协程开始执行
│
├── 输出线程 ID
├── co_await SleepFor{1000ms}
│
▼
SleepFor::await_ready()
│
├── 返回 false
│
▼
SleepFor::await_suspend(handle)
│
├── 创建工作线程 A
│
▼
协程暂停
│
▼
主线程继续执行
│
├── 输出"可以继续执行"
├── finished.wait()
│
▼
主线程等待完成信号
工作线程 A:
text
工作线程 A
│
├── sleep_for(1 秒)
│
▼
调用 handle.resume()
│
▼
协程在线程 A 中恢复
│
├── await_resume()
├── 输出第一次等待结束
├── co_await SleepFor{1000ms}
│
▼
创建工作线程 B
│
▼
协程再次暂停
│
▼
工作线程 A 结束
工作线程 B:
text
工作线程 B
│
├── sleep_for(1 秒)
│
▼
调用 handle.resume()
│
▼
协程在线程 B 中恢复
│
├── 输出第二次等待结束
├── finished.count_down()
│
▼
协程结束并自动销毁协程帧
最后:
text
finished.count_down()
│
▼
main 的 finished.wait() 返回
│
▼
main 结束
十八、为什么不能在 main 中固定睡眠四秒
错误示例:
cpp
int main()
{
start_async_task();
std::this_thread::sleep_for(
std::chrono::seconds(4)
);
}
这相当于假设:
text
异步任务一定能在四秒内完成
但真实程序中可能发生:
text
线程调度延迟
网络响应变慢
磁盘读取变慢
异步操作失败
系统负载过高
任务被取消
更严重的问题是:
text
main 睡眠结束
│
▼
对象被析构
│
▼
协程帧被销毁
│
▼
异步线程稍后调用旧 handle.resume()
│
▼
访问已经释放的协程帧
│
▼
未定义行为
所以应该使用明确的完成通知:
text
std::latch
std::condition_variable
std::future
任务调度器
事件循环
完成回调
十九、使用 co_yield 实现生成器
生成器是一种特殊协程:
text
每次产生一个值
然后暂停
下一次请求时继续产生下一个值
例如:
cpp
co_yield 3;
co_yield 4;
co_yield 5;
执行过程:
text
调用 next()
│
▼
执行到 co_yield 3
│
├── 保存 3
└── 暂停
│
▼
调用者读取 3
│
▼
再次调用 next()
│
▼
从上次位置继续
│
▼
执行到 co_yield 4
19.1 完整生成器代码
cpp
#include <coroutine>
#include <exception>
#include <iostream>
#include <utility>
class IntGenerator
{
public:
struct promise_type;
using handle_type =
std::coroutine_handle<promise_type>;
public:
explicit IntGenerator(
handle_type handle
) noexcept
: handle_(handle)
{
}
~IntGenerator()
{
if (handle_)
{
handle_.destroy();
}
}
IntGenerator(const IntGenerator&) = delete;
IntGenerator& operator=(
const IntGenerator&
) = delete;
IntGenerator(
IntGenerator&& other
) noexcept
: handle_(
std::exchange(other.handle_, {})
)
{
}
// 让生成器继续运行到下一个 co_yield。
bool next()
{
if (!handle_ || handle_.done())
{
return false;
}
handle_.resume();
// 如果协程内部发生异常,
// 在调用 next() 的位置重新抛出。
if (handle_.promise().exception_)
{
std::rethrow_exception(
handle_.promise().exception_
);
}
// 如果 resume 后协程还没有结束,
// 表示它暂停在某个 co_yield。
return !handle_.done();
}
// 获取最近一次 co_yield 产生的值。
int value() const
{
return handle_.promise().current_value_;
}
public:
struct promise_type
{
// 保存最近一次 co_yield 的值。
int current_value_ = 0;
// 保存协程异常。
std::exception_ptr exception_;
IntGenerator get_return_object() noexcept
{
return IntGenerator{
handle_type::from_promise(*this)
};
}
// 生成器通常采用惰性启动。
//
// 调用 range() 时不会立即执行函数体,
// 第一次调用 next() 时才开始执行。
std::suspend_always
initial_suspend() const noexcept
{
return {};
}
// 协程结束后保留协程帧,
// 最后由 IntGenerator 析构函数销毁。
std::suspend_always
final_suspend() const noexcept
{
return {};
}
// co_yield value 最终会调用 yield_value(value)。
std::suspend_always
yield_value(int value) noexcept
{
// 保存当前产生的值。
current_value_ = value;
// 返回 suspend_always,
// 表示产生一个值后立即暂停生成器。
return {};
}
void return_void() const noexcept
{
}
void unhandled_exception() noexcept
{
exception_ = std::current_exception();
}
};
private:
handle_type handle_{};
};
// 生成 [begin, end) 范围内的整数。
IntGenerator range(int begin, int end)
{
for (int value = begin;
value < end;
++value)
{
// 保存当前值,并暂停生成器。
co_yield value;
}
}
int main()
{
// 此时只创建协程帧。
//
// 因为 initial_suspend 返回 suspend_always,
// 所以 range() 函数体尚未真正开始执行。
IntGenerator numbers = range(3, 7);
// 每调用一次 next(),
// 生成器就继续运行到下一个 co_yield。
while (numbers.next())
{
std::cout
<< "当前值:"
<< numbers.value()
<< '\n';
}
}
输出:
text
当前值:3
当前值:4
当前值:5
当前值:6
19.2 co_yield 的转换过程
代码:
cpp
co_yield value;
可以大致理解为:
cpp
co_await promise.yield_value(value);
我们的 yield_value():
cpp
std::suspend_always
yield_value(int value) noexcept
{
current_value_ = value;
return {};
}
完整流程:
text
执行 co_yield 3
│
▼
调用 promise.yield_value(3)
│
├── current_value_ = 3
│
└── 返回 suspend_always
│
▼
生成器暂停
│
▼
调用者读取 current_value_
│
▼
再次调用 next()
│
▼
handle.resume()
│
▼
从上次 co_yield 后继续
二十、协程中的局部变量与生命周期
20.1 普通值通常可以跨越挂起点
cpp
Task test()
{
int value = 100;
co_await std::suspend_always{};
std::cout << value << '\n';
}
大致内存结构:
text
协程帧
┌───────────────────────┐
│ promise_type │
├───────────────────────┤
│ value = 100 │
├───────────────────────┤
│ 当前执行位置 │
└───────────────────────┘
所以恢复后 value 仍然有效。
20.2 引用不会自动延长对象生命周期
危险代码:
cpp
Task print_later(const std::string& text)
{
co_await std::suspend_always{};
std::cout << text << '\n';
}
调用:
cpp
Task task;
{
std::string message = "hello";
task = print_later(message);
}
// message 已经销毁。
task.resume();
可能形成:
text
协程帧中的 text
│
│ 引用
▼
外部 message 对象
│
▼
message 已经销毁
│
▼
text 成为悬空引用
正确思路之一是按值传递:
cpp
Task print_later(std::string text)
{
co_await std::suspend_always{};
std::cout << text << '\n';
}
这样字符串对象可以被保存到协程帧中。
20.3 this 指针也可能悬空
危险代码:
cpp
class Worker
{
public:
Task run()
{
co_await std::suspend_always{};
// this 指向的 Worker 可能已经销毁。
std::cout << value_ << '\n';
}
private:
int value_ = 100;
};
调用:
cpp
Task task;
{
Worker worker;
task = worker.run();
}
// worker 已经析构。
task.resume();
协程帧中保存的只是:
text
this 指针
并不会自动延长 Worker 对象的生命周期。
二十一、协程 Lambda 为什么容易出现问题
例如:
cpp
int value = 100;
auto lambda = [&value]() -> Task
{
co_await std::suspend_always{};
std::cout << value << '\n';
};
需要同时考虑两个对象:
text
Lambda 闭包对象
外部 value 对象
生命周期关系:
text
协程帧
│
├── 可能保存闭包对象的 this 指针
│
└── 闭包对象内部保存 value 的引用
│
▼
外部 value 对象
只要其中一个提前销毁,恢复协程时就可能发生悬空访问。
对于入门阶段,建议:
text
协程 Lambda 尽量避免引用捕获
需要跨越挂起点的数据尽量按值传递
明确保证对象生命周期长于协程
二十二、协程常见使用场景
22.1 网络服务器
cpp
Task handle_client(Socket socket)
{
while (true)
{
std::string request =
co_await socket.async_read();
std::string response =
process_request(request);
co_await socket.async_write(response);
}
}
执行模型:
text
等待客户端数据
│
▼
协程暂停
│
▼
事件循环处理其他连接
│
▼
客户端数据到达
│
▼
恢复对应协程
22.2 定时任务
cpp
Task update_loop()
{
while (true)
{
update();
co_await sleep_for(100ms);
}
}
22.3 游戏脚本
cpp
Task character_action()
{
play_animation("attack");
co_await wait_animation_finished();
apply_damage();
co_await sleep_for(500ms);
return_to_idle();
}
22.4 惰性序列
cpp
Generator<int> fibonacci()
{
int a = 0;
int b = 1;
while (true)
{
co_yield a;
int next = a + b;
a = b;
b = next;
}
}
二十三、协程不能自动解决什么问题
23.1 协程不会自动并行
cpp
Task heavy_work()
{
calculate_for_ten_seconds();
co_return;
}
如果 calculate_for_ten_seconds() 在主线程执行,那么主线程仍然会被阻塞十秒。
协程不是:
text
线程池
多线程
多进程
SIMD
GPU 并行
23.2 协程不会把阻塞 I/O 自动变成异步 I/O
cpp
Task read_file()
{
std::ifstream file("data.txt");
file.read(...);
co_return;
}
如果 file.read() 是阻塞调用,那么当前线程仍然会阻塞。
真正异步需要底层机制支持,例如:
text
Linux io_uring
Windows IOCP
异步 Socket
epoll + 非阻塞 I/O
线程池包装阻塞操作
二十四、完整项目结构
text
cpp20_coroutine_tutorial/
├── CMakeLists.txt
├── include/
│ └── task.hpp
├── 01_basic.cpp
├── 02_debug_awaiter.cpp
├── 03_manual_event.cpp
├── 04_async_timer.cpp
└── 05_generator.cpp
二十五、CMakeLists.txt
cmake
cmake_minimum_required(VERSION 3.20)
project(
cpp20_coroutine_tutorial
LANGUAGES CXX
)
# 使用 C++20。
set(CMAKE_CXX_STANDARD 20)
# 必须支持 C++20,不能自动降级。
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 禁用 GNU 扩展,使用标准 C++。
set(CMAKE_CXX_EXTENSIONS OFF)
# 查找线程库。
find_package(Threads REQUIRED)
add_executable(
01_basic
01_basic.cpp
)
target_include_directories(
01_basic
PRIVATE include
)
add_executable(
02_debug_awaiter
02_debug_awaiter.cpp
)
target_include_directories(
02_debug_awaiter
PRIVATE include
)
add_executable(
03_manual_event
03_manual_event.cpp
)
target_include_directories(
03_manual_event
PRIVATE include
)
add_executable(
04_async_timer
04_async_timer.cpp
)
target_link_libraries(
04_async_timer
PRIVATE Threads::Threads
)
add_executable(
05_generator
05_generator.cpp
)
构建:
bash
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Debug
cmake --build build
二十六、学习协程时应该按什么顺序
不要一开始就去写完整异步框架。
推荐顺序如下。
第一阶段:理解暂停和恢复
只学习:
text
co_await suspend_always
coroutine_handle.resume()
协程帧
Task 生命周期
对应示例:
text
01_basic.cpp
第二阶段:理解 Awaiter
掌握:
cpp
await_ready()
await_suspend()
await_resume()
对应示例:
text
02_debug_awaiter.cpp
第三阶段:理解外部事件恢复协程
掌握:
text
Awaiter 保存协程句柄
外部事件调用 handle.resume()
对应示例:
text
03_manual_event.cpp
第四阶段:理解线程与协程的关系
掌握:
text
协程在哪个线程恢复
取决于哪个线程调用 resume()
对应示例:
text
04_async_timer.cpp
第五阶段:理解生成器
掌握:
text
co_yield
yield_value()
惰性执行
对应示例:
text
05_generator.cpp
二十七、最后建立完整思维模型
看到下面这行代码:
cpp
Result result = co_await operation;
你应该在脑中展开成:
text
1. 获取 operation 对应的 Awaiter
│
▼
2. 调用 await_ready()
│
├── true:操作已经完成
│ │
│ └──────────────┐
│ │
└── false │
│ │
▼ │
3. 调用 await_suspend(handle) │
│ │
▼ │
4. 保存或转交当前协程句柄 │
│ │
▼ │
5. 当前协程暂停 │
│ │
▼ │
6. 调用线程继续执行其他工作 │
│ │
▼ │
7. 外部异步操作完成 │
│ │
▼ │
8. 外部系统调用 handle.resume() │
│ │
└───────────────────┤
▼
9. 调用 await_resume()
│
▼
10. 获取结果或抛出异常
│
▼
11. result 得到返回值
│
▼
12. 协程继续执行后续代码
同时记住下面六句话:
text
1. 协程不是线程。
2. 协程不会自动创建线程。
3. 协程不会自动把阻塞函数变成异步函数。
4. 协程帧负责保存局部变量、执行位置和 promise_type。
5. coroutine_handle 负责恢复和销毁协程。
6. Awaiter 决定协程是否暂停,以及将来如何恢复。
最终可以把 C++20 协程归纳为:
text
C++20 协程
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
协程帧 promise_type Awaiter
│ │ │
│ │ ├── await_ready
│ │ ├── await_suspend
│ │ └── await_resume
│ │
│ ├── initial_suspend
│ ├── final_suspend
│ ├── return_void/value
│ └── unhandled_exception
│
├── 保存局部变量
├── 保存函数参数
├── 保存执行位置
└── 保存等待状态
协程真正的核心并不是记住所有模板代码,而是理解:
text
函数运行到某个位置
│
▼
保存当前状态
│
▼
暂停并交还控制权
│
▼
外部操作完成
│
▼
通过句柄恢复函数
│
▼
从原来的位置继续执行
后续教程应继续扩展 Task<T>、协程之间互相 co_await、continuation、对称转移,以及基于 epoll 的真实异步 Socket 示例。