CppCon 2023 学习: Coroutine Patterns and How to Use Them: Problems and Solutions Using Coroutines In
保存协程状态的对象默认 lazy 执行能变 eager会继承 executor保证对象活得比所有未完成的成员协程更久保证 lambda 对象的生命周期足够长对象必须活到所有成员协程结束lambda 生命周期必须覆盖协程周期所有后台任务必须在离开作用域前完成(join)1. 类中用 cleanup() 清理异步任务避免对象析构时还有任务在运行。2.绝不要在析构中使用 blockingWait()⇒阻
Motivation ——动机
1. 40 年的经验与积累
- 协程是一个新的抽象,但 不是凭空出现的
- 许多概念可以站在已有知识之上构建
也就是说,协程虽然看着“新”,但它解决的是老问题,只是更干净、更高效。
**2. Coroutines introduce new paradigm
协程引入新的编程范式**
协程让:
- 异步代码可以像同步一样写
- 逻辑变得线性
- 错误传播、资源管理、调度策略——都可以封装起来
这是一种 新的范式 (paradigm),类似现代 C++ 引入 RAII、模板元等概念那样。
**3. Build shared knowledge
构建共享知识体系**
作者希望:
- 总结并整理「在使用协程时容易踩的坑」
- 提供一套“模式”和“范式”
让整个社区对协程形成共享理解,而不是每家写一个版本的框架。
Overview ——综述
Folly 库
github.com/facebook/folly
作者以 Folly 的协程系统为例,因为:
- 工业强度(太大规模的线上应用)
- 设计非常成熟
- 许多协程库(如 cppcoro、asio、unifex)也有类似结构
所以理解 Folly 的模式,就等于理解“现代协程编程的核心设计”。
Key Concepts ——关键概念
主要两个:
- Task
- Executor
① Task ——任务(协程本体)
Task 是 Folly 协程系统的核心抽象。
Task 的几个重要特性
**1. Owns the coroutine state
Task 拥有协程状态**
一个 C++ 协程会生成一段内部状态(stack frame / promise / local variables),Task 就是保存和管理这些状态的对象。
换句话说:
Task 就是协程的“容器”或“句柄”。
2. Lazy ——懒执行
非常关键。
例如:
Task<> foo() {
println("Hello");
co_await sleep(1);
}
auto t = foo();
println("world");
co_await move(t);
执行顺序是:
- 调用
foo()不会执行"Hello"
——因为 Task 是 lazy 的 - 打印
"world" - 当
co_await move(t)时 Task 才开始执行
→ 这时才打印"Hello"
也就是:
Task is lazy: execution starts only when awaited. \text{Task is lazy: } \text{execution starts only when awaited.} Task is lazy: execution starts only when awaited.
**3. Convertible to eager
可转换成 eager 任务**
Task 虽然默认 lazy,但可以转换成 eager:
- eager 任务会在创建时立即调度执行
- 这有时能减少 latency 或隐藏调度开销
类似于你在 Rust Future 里把 lazy future 用spawn推出去立即运行。
**4. Propagates executor
会自动传播 Executor**
这意味着:
- 一个 Task 在某个 executor 上运行
- 子 task 和所有 continuation 都会继承这个 executor
这保持调度的一致性。
**5. Sticky to executor
粘在 executor 上运行**
Task 一旦绑定了 executor,就不会在 await 之间突然跳到别的线程池里。
这在许多业务场景至关重要:
- UI 线程
- Reactor event loop
- 单线程逻辑
② Executor ——执行器
Executor 的作用
- 用来执行 同步函数
- 可能是多线程
- 协程在运行时会被拆成多个 “片段” 的同步函数执行
例如:
Task<> foo() {
int a = 42;
a -= 10;
int bytes = co_await send(a);
if (bytes == -1) {
handle_error();
}
}
这里:
int a = 42;是同步代码co_await send(a);会挂起执行- 当 send 结束时,executor 会继续执行:
if (bytes == -1) ...
Folly 的协程调度模型是:
Coroutine=many synchronous functions connected by suspension points \text{Coroutine} = \text{many synchronous functions connected by suspension points} Coroutine=many synchronous functions connected by suspension points
而 executor 负责执行这些“片段”。
Executor 执行流程(重点)
协程被分割成两段:
** 1. 执行到 co_await 之前的部分(synchronous before suspension)**
这里 executor 执行:
int a = 42;
a -= 10;
int bytes = co_await send(a);
当遇到 co_await send(a) 时协程挂起。
** 2. continuation ——继续执行**
当异步操作完成后:
executor 再执行:
if (bytes == -1) {
handle_error();
}
也就是说:
Executor executes: first part →suspend→resume part. \text{Executor executes: } \text{first part } \rightarrow \text{suspend} \rightarrow \text{resume part}. Executor executes: first part →suspend→resume part.
这就是现代 C++ 协程的核心调度模型。
总结(你可以贴下一页我继续)
Task 是:
- 保存协程状态的对象
- 默认 lazy 执行
- 能变 eager
- 会继承 executor
Executor 是:
- 执行同步代码的调度器
- 协程由多个同步片段组成
- executor 负责在“挂起点与恢复点”之间调度执行
Overview – Executor(执行器)
这一页展示了一个非常关键的协程调度模型图:
Executor
│
▼
Queue
├── foo()_2
└── foo()_1
Thread
让我们逐层解释。
1. Executor 是什么?
Executor(执行器)是 协程调度的核心组件。
它的作用:
- 提供线程池或单线程环境
- 接收协程的待执行“片段”
- 决定这些“片段”何时在哪个线程上执行
形式化地说,一个 executor 实现一个队列:
Executor=(queue,,thread(s),,scheduling policy) \text{Executor} = (\text{queue},, \text{thread(s)},, \text{scheduling\ policy}) Executor=(queue,,thread(s),,scheduling policy) - queue = 任务队列
- thread(s) = 用来执行任务的线程
- sched. policy = 任务调度策略(FIFO、LIFO、优先级等)
2. Queue ——任务排队区
“Queue” 是 executor 中最核心的结构。
当协程执行到某个 co_await,并且之后要恢复执行时,恢复点的代码会被放入 executor 的队列。
例如:
co_await send(a);
当 send 完成后,executor 收到 “继续执行 foo() 协程的下一个片段” 的任务:
foo()_2
如果 foo 已经被挂起过多次,那么可能会有多个 continuation:
foo()_1
foo()_2
因此图里的:
foo()_1
foo()_2
代表:
- foo() 的第一次挂起后的 continuation
- foo() 的第二次挂起后的 continuation
这些 continuation 是 synchronous functions,即:
Coroutine=a sequence of synchronous functions f1,f2,… \text{Coroutine} = \text{a sequence of synchronous functions }{ f_1, f_2, \ldots } Coroutine=a sequence of synchronous functions f1,f2,…
每次挂起/恢复就相当于把下一个同步函数放入队列。
3. Thread ——实际执行代码的线程
Executor 最后需要一个或多个线程来执行队列里的任务:
- 单线程 executor:所有 continuation 在同一个线程执行
- 多线程 executor:可能在不同线程执行(不保证顺序)
执行过程如下:
Thread pulls next task from Queue→execute→yield or finish \text{Thread pulls next task from Queue} \rightarrow \text{execute} \rightarrow \text{yield or finish} Thread pulls next task from Queue→execute→yield or finish
你可以把它理解为:
协程不是由 C++ 调度的,而是由 executor 调度的。
4. 整体运作流程(完整解释)
Suppose 我们有协程:
Task<> foo() {
step1; // sync code
co_await A(); // suspend #1
step2; // sync code
co_await B(); // suspend #2
step3; // sync code
}
协程的执行被拆分为:
- f1f_1f1: 运行 step1,然后挂起
- f2f_2f2: A 完成后恢复 -> 运行 step2,然后挂起
- f3f_3f3: B 完成后恢复 -> 运行 step3,结束协程
然后:
- 执行起点 f1f_1f1
- 当 A 完成 → executor 将 f2f_2f2 入队
- 当 B 完成 → executor 将 f3f_3f3 入队
图中 queue 里出现:
foo()_1 = continuation f_2
foo()_2 = continuation f_3
最终 executor 的线程按序执行队列。
5. 为什么图中是 foo()_1 和 foo()_2?
因为:
- 每一次
co_await都会产生 “下一段需要执行的 continuation” - continuation 被封装成任务
- 这些任务被放入 executor 的队列
所以 foo() 协程实际上被“拆开”成多个小函数:
foo()⇒foo()1,,foo()2,,… foo() \Rightarrow { foo()_1,, foo()_2,, \ldots } foo()⇒foo()1,,foo()2,,…
这些都是同步代码块,由 executor 顺序或并行执行。
概览
- Lifetime(对象/协程的生命周期)
- Exceptions(异常)
- RAII(资源获取即初始化)
- Synchronization(同步)
这里我先把 Lifetime 的内容逐条拆解、分析,并给出安全实践与示例改写。
1) 为什么 Lifetime 很重要 — “Lots of worries”
协程会被拆成多个“同步片段”并在挂起点之间恢复。如果协程访问某个对象的成员,而该对象在协程挂起期间被销毁,就会导致未定义行为(UB)。这是协程编程里最常见也最危险的问题之一。
作者给出的建议是:使用 Structured Concurrency(结构化并发)来减少这类错误。并指出工具层面上 ASAN(AddressSanitizer) 对发现这类内存错误非常有帮助。
2) 成员协程隐式捕获 this
“Member coroutines implicitly capture this”
含义是:当你把协程写成类的成员函数时,协程的 frame(状态)里会隐式保存this指针(或等价引用),以便在恢复时访问成员变量或成员函数。
因此若对象被销毁,协程 frame 中的this就成为悬空指针。
3) 通过具体示例看问题(里的代码)
原示例():
struct Bar {
int data = 0;
Task<int> foo() {
co_await sleep(1);
co_return data;
}
};
Task<int> mul_2(Task<int> t){
int val = co_await move(t);
co_return 2 * val;
}
问题场景(危险用法):
Task<int> t = Bar{}.foo();
co_await move(t);
Bar{}是一个临时对象,表达式结束临时会被销毁。foo()返回的Task<int>的 coroutine frame 隐式保存了this(指向临时 Bar)。如果在co_await执行前临时已经销毁,就会悬空访问data-> UB。- 同样
Bar a; Task<int> t = a.foo(); /* a 的作用域结束 */ co_await move(t);也会引起问题。
4) 另一类示例(容器与元素访问)
里还给了 vector 的例子:
std::vector<int> vec;
vec.front() += 1;
或
std::vector<int> vec;
vec.clear();
这是在提醒一个普遍原则:在访问容器元素或引用之前必须保证容器还包含那个元素。在协程场景中,清空或改变容器可能发生在挂起期间的其它逻辑中,从而使先前保存的引用/迭代器失效。
5) 形式化地描述生命周期要求
你可以把“对象必须在所有未完成方法执行完之前存活”写成一个集合包含关系:
lifetime(O)⊇⋃m∈Mexecution(m) \text{lifetime}(O) \supseteq \bigcup_{m \in M} \text{execution}(m) lifetime(O)⊇m∈M⋃execution(m)
其中 OOO 是对象,MMM 是所有对该对象的尚未完成的方法(包括挂起的协程)。意思是:对象的存活时间应包含所有这些方法的执行期间。
6) 常见解决方案与安全模式(实用建议)
以下按重要性和可实现性列出常用做法与示例代码。
A — 结构化并发(最好)
结构化并发(structured concurrency)要求:启动的任务在离开作用域前必须完成或被显式取消。这把“谁负责等待/销毁任务”明确化,避免悬空协程引用已销毁对象。
例如在一个函数作用域内 spawn/等待所有子任务,或者使用 scoped task API。
优点:语义清晰、错误少。
缺点:需要框架/库支持或手动约束调用者。
B — 保证对象以 shared_ptr 方式延长生命周期
把对象以 std::shared_ptr 持有,并在协程里持有 shared_ptr 的拷贝,保证对象在协程完成前不被销毁。
示例(需类支持 shared_from_this):
struct Bar : std::enable_shared_from_this<Bar> {
int data = 0;
Task<int> foo() {
auto self = shared_from_this(); // keep object alive
co_await sleep(1);
co_return self->data;
}
};
// 调用方
auto p = std::make_shared<Bar>();
Task<int> t = p->foo();
// p 可以被 reset,但 foo 中的 self 保证 Bar 存活到协程结束
C — 在协程内复制必要数据(按值捕获)
如果协程只需要对象上的某些数据快照,可以在协程开始时复制一份到局部变量:
Task<int> foo() {
int local = data; // 复制 member 的值
co_await sleep(1);
co_return local; // 安全:不再访问 this
}
这种方式简单、安全,但注意语义:你得到的是调用时刻的值快照,而不是恢复时刻的最新值。
D — 避免把 member coroutine 返回到调用者并在外部长期保存
不要写出 API 允许 Task(由成员函数创建)在对象外长期存活的场景,除非你能保证对象也同时被延长生命周期,或任务被立即 co_await。也可以把成员协程设计为 立即 eager 运行,不把它的 Task 返回给外部。
E — 明确文档与所有权契约
在库或类的 API 文档里写明:该成员协程要求对象在任务完成前存活,或明确指出使用 shared_ptr/结构化并发等要求。清晰的所有权契约能避免用户误用。
F — 使用工具(ASAN/UBSAN 等)与单元测试
正如提到:ASAN works great。在 CI 中启用 ASAN/UBSAN 可以早期捕获悬空指针、越界等内存错误。配合单元测试覆盖挂起/恢复路径。
7) 对中其他代码行的逐点解释
Bar a; co_await a.foo();- 安全(只要在
co_await返回之前a仍然在作用域)。
- 安全(只要在
Bar a; Task<int> t = a.foo(); co_await move(t);- 只要
a在co_await前没有被销毁则安全;危险是a可能在创建t后退出作用域。
- 只要
Task<int> t = Bar{}.foo(); co_await move(t);- 危险:临时
Bar{}在 full-expression 结束时销毁,协程可能引用已销毁的this。
- 危险:临时
co_await Bar{}.f();- 也有危险,取决于协程是否在返回前访问成员。
8) 推荐的“安全 API” 模式(样例)
模式 1:非成员协程 + 显式数据传递
Task<int> foo_by_value(int data_snapshot) {
co_await sleep(1);
co_return data_snapshot;
}
// 调用
Bar a;
Task<int> t = foo_by_value(a.data); // 传值
co_await move(t);
模式 2:成员协程但返回时保证拥有 shared_ptr
struct Bar : std::enable_shared_from_this<Bar> {
int data;
Task<int> foo() {
auto self = shared_from_this();
co_await sleep(1);
co_return self->data;
}
};
9) 小结(要点回顾)
- 成员协程隐式捕获
this—— 导致对象被销毁时出现悬空访问是常见问题。 - 原则:对象必须存活直到所有对它的方法(包括挂起的协程)完成。 可以形式化为
lifetime(O)⊇⋃m∈Mexecution(m). \text{lifetime}(O) \supseteq \bigcup_{m \in M} \text{execution}(m). lifetime(O)⊇m∈M⋃execution(m). - 推荐做法(按优先级):
- 使用 结构化并发(最稳妥)。
- 使用
shared_ptr/enable_shared_from_this延长对象生命期。 - 在协程开始时 按值复制 所需数据(如果语义允许)。
- 设计 API 以避免返回会让协程超过对象作用域的
Task。
- 在 CI 中启用 ASAN/UBSAN,并写针对挂起/恢复路径的单元测试。
Lifetime — “When does this concretely happen?”
(什么时候真的会发生生命周期问题?)
协程生命周期问题在实际代码中具体发生在哪里?
答案:
- 循环中(loops)
- lambda 表达式(lambdas)
- 后台调度(start-in-background)
下面逐条分析。
1. 协程生命周期问题在循环中
代码示例:
vector<Task<>> tasks;
for (int index = …) {
Foo foo{index};
tasks.push_back(foo.bar());
}
co_await collectAll(tasks);
问题是什么?
在循环里:
Foo foo{index};是一个局部变量foo.bar()是一个 成员协程(隐式捕获this)
每次循环末尾,foo会被 立即销毁。
但是:foo.bar()返回的Task包含协程 frame,frame 里隐式 capture 了this指针。
于是我们得到:
lifetime(foo)<lifetime of foo.bar() coroutine \text{lifetime}(foo) < \text{lifetime of } foo.bar() \text{ coroutine} lifetime(foo)<lifetime of foo.bar() coroutine
导致协程恢复时访问悬空指针 → 未定义行为(UB)。
这是生产环境最常见的协程生命周期 bug。
2. 协程生命周期问题在 Lambda 中
代码:
Task<int> d = mul_2([=]() { co_return i; }());
co_await move(d);
为什么这里 Lambda 也会出问题?
因为:
- lambda 是个临时对象
- 表达式结束后被销毁
- 但协程 frame 捕获了 lambda(或者捕获了 lambda 捕获的变量)
于是协程恢复时会访问已被销毁的 lambda 对象或内部捕获。
同类问题:
co_await mul_2([=]() { co_return i; }());
auto lam = [=]() { co_return i; };
co_await mul_2(lam());
第二种写法看起来更直观,但仍要注意:lam() 返回的是一个协程,这个协程 frame 里捕获了 lambda 的拷贝。如果 lambda 在 co_await 前被销毁,协程将访问悬空的捕获对象。
这是一个非常容易忽略的“隐式生命周期泄漏”。
3. 使用 co_invoke 来解决 Lambda 生命周期问题
Task<int> d = mul_2(co_invoke([=]() { co_return i; }));
co_await move(d);
co_invoke 的作用:
把 lambda 的生命周期延长到整个协程执行完毕。
也就是说co_invoke(...)会把 lambda 包裹成一个延长 lifetime 的 object,使之不会在表达式结束时被销毁。
可以理解为:
lifetime of lambda⊇lifetime of coroutine \text{lifetime of lambda} \supseteq \text{lifetime of coroutine} lifetime of lambda⊇lifetime of coroutine
4. Lifetime — Shared Knowledge 总结(中段)
幻灯片总结目前的知识:
- Keep object alive until all methods have completed
保证对象活得比所有未完成的成员协程更久 - Ensure lambda objects are kept alive (using co_invoke)
保证 lambda 对象的生命周期足够长
5. 更直观的类比:引用生命周期问题
这张幻灯片:
Think: task reference this pointer
vector<tuple<int&>> vec;
for(int index = …) {
vec.emplace_back(index);
}
Who wouldn’t spot this?
意思是:
协程隐式捕获
this,就好像你把一个局部变量的引用存在了容器里一样。
等价于:
int& ref = index; // index 在循环里是局部变量
// 把引用放进 vector(危险)
这是所有 C++ 程序员都会立刻看出的问题。
类比:
协程隐式捕获
this也有同样的问题,只是更隐蔽。
6. Scheduling in background — 后台调度出现严重生命周期问题
例子:
{
}
Foo a;
startInBackground(a.bar());
co_await do_other_stuff();
问题:
a.bar()是成员协程,隐式捕获this- startInBackground 会把协程扔到后台线程/任务执行
- 当前作用域可能先结束(或函数结束)
a被销毁
然而后台协程还在运行,会访问已销毁的对象。
这就是:
lifetime(a)<lifetime of background coroutine \text{lifetime}(a) < \text{lifetime of background coroutine} lifetime(a)<lifetime of background coroutine
结果是 致命 UB。
作者说:
Big no no!
Need to join background work
7. 必须在离开作用域前 join 后台任务
改成:
Foo a;
auto bgWork = startInBackground(a.bar());
co_await do_other_stuff();
co_await move(bgWork);
这确保:
lifetime(a)⊇execution of bgWork \text{lifetime}(a) \supseteq \text{execution of bgWork} lifetime(a)⊇execution of bgWork
即:
对象 a 在后台任务结束前不会被销毁。
这是 结构化并发(Structured concurrency) 核心思想之一。
8. 多任务 join — 用 AsyncScope
更安全的写法:
Foo a;
AsyncScope s;
for (int index = …) {
co_await s.schedule(a.bar(index));
}
co_await do_other_stuff();
co_await s.join();
AsyncScope 保证:
- 所有
s.schedule(...)启动的任务都被追踪 s.join()会等待这些任务全部完成
生命周期关系变成:
lifetime(a)⊇join(s) \text{lifetime}(a) \supseteq \text{join}(s) lifetime(a)⊇join(s)
结构化并发 again.
9. Lifetime Shared Knowledge — 最终总结(第二次出现)
最终三条规则:
- Keep object alive until all methods have completed
对象必须活到所有成员协程结束 - Ensure lambda objects are kept alive (using co_invoke)
lambda 生命周期必须覆盖协程周期 - Always join work before leaving the scope
所有后台任务必须在离开作用域前完成(join)
10. Exception Safety:如果 do_other_stuff() 抛异常怎么办?
最后一张幻灯片问:
Anyone sees a problem with the code above?
What if do_other_stuff() throws?
Still need to join!
如果这样写:
auto bgWork = startInBackground(a.bar());
co_await do_other_stuff(); // <-- 如果这里抛异常
co_await move(bgWork); // <-- 根本不会执行
后台协程永远不会 join。
于是:
a的生命周期可能结束- 后台协程仍运行,访问悬空对象
- UB
因此必须保证:
无论是否抛异常,都要 join。
即:
join must run even if exceptions happen \text{join must run even if exceptions happen} join must run even if exceptions happen
实际处理手段: - try/catch + join
- 使用 RAII(ScopeGuard)
- 或使用支持 structured concurrency 的库(类似 folly、cppcoro、Rust 的 tokio/async scope)
最终总结(Lifetime 的全部核心要点)
协程生命周期问题主要来自三类:
① 隐式捕获 this —— 成员协程
② 隐式捕获 lambda —— 临时 lambda / 捕获对象过早销毁
③ 后台调度 —— 未 join 导致对象先销毁
必须保证:
lifetime of object;⊇;lifetime of all coroutine fragments \text{lifetime of object} ;\supseteq; \text{lifetime of all coroutine fragments} lifetime of object;⊇;lifetime of all coroutine fragments
并采取措施确保:
- 对象的生命周期足够长
- lambda 的生命周期足够长(
co_invoke) - 所有后台任务在退出作用域前都完成(join)
Exceptions(异常处理)—— 详细理解
46 — Await in catch(在 catch 中使用 co_await)
示例代码:
try {
co_await do_other_stuff();
} catch (...) {
co_await asyncScope.join();
}
编译失败!
因为 C++ 规定 catch 块中不能出现 co_await。
为什么不允许?
C++ 的异常模型要求:
- catch 块中必须是同步代码
- 异常传播路径必须是可静态分析的
co_await会将执行让出给调度器,破坏异常模型的语义
所以语言层面禁止在catch里co_await。
47 — Capture the exception(捕获异常,再处理)
因为不能直接 co_await,你只能:
- 在 catch 中保存异常
- catch 外再 co_await
- 如果需要再 rethrow
代码:
exception_ptr eptr;
try {
co_await do_other_stuff();
} catch (...) {
eptr = current_exception();
}
if (eptr) {
co_await asyncScope.join();
rethrow_exception(eptr);
}
核心思想:
- catch 内只保存异常,不做 await
- catch 外根据需要执行异步清理逻辑
- 最后重新抛出异常
48 — Capture the exception (2) — 更高级写法
更方便的做法是使用一个库函数,例如:
co_awaitTry(...)
它会捕获异常并返回一个结果对象(类似 std::expected):
auto res = co_await co_awaitTry(do_other_stuff());
if (res.hasException()) {
co_await asyncScope.join();
}
// 没有异常则返回值,有异常则在这里重新抛出
res.value();
res 的语义:
- res.hasException()=trueres.hasException() = \text{true}res.hasException()=true →
do_other_stuff()抛异常 - res.hasValue()=trueres.hasValue() = \text{true}res.hasValue()=true → 没有异常,可以继续执行
res.value()- 如果无值 → 自动 rethrow 捕获到的异常
- 如果有值 → 返回真正的结果
这是 异步异常安全模式 的常见写法。
49 — Shared knowledge(共同经验总结)
要在 catch 中做异步操作时,要用包装器捕获异常
因为不能直接在 catch 中 co_await,必须:
- 捕获异常(
exception_ptr或库提供的 wrapper) - 在 catch 外 co_await 异步清理逻辑
- 必要时重抛异常
使用库来简化异常捕获与处理
例如:
co_awaitTrytask<T>::result()expected-like类型
这些封装你用起来更安全、更简洁。
核心总结(你必须记住的)
| 问题 | 原因 | 解决方案 |
|---|---|---|
catch 中不能 co_await |
C++ 语言限制 | 捕获异常 → catch 外 await |
| 异步操作需要在异常时清理 | 否则会造成资源泄漏 | 使用 exception_ptr 或库的包装器 |
| 想优雅处理异常 | 手写太麻烦 | 使用 co_awaitTry 这类工具 |
| 本页核心就在于: |
异步代码 + 异常 = 不能用传统 catch,需要用包装器配合 await。
RAII(资源获取即初始化)在协程里的新问题
在普通 C++ 中,RAII 只处理:
- 析构时释放资源
- 自动变量自动离开作用域
- 异常安全
但在 协程场景,RAII 遇到两个大问题:
- 异步任务不能在析构函数里 co_await
- 任务已启动但对象已经析构 → 悬空引用(UB!)
所以需要新的“协程 RAII 模式”。
## 51 — RAII:不同的解决方案
协程 RAII 主要要解决两种对象的生命周期:
- 类的成员
- 自动变量 / 局部对象
两者都可能: - 在析构时还有未完成的 async 工作
- 但又不能在析构里 co_await
## 52 — RAII for Classes:异步 cleanup 模式
核心思想:为每个类显式定义 cleanup() 异步方法
class Foo {
Task<> cleanup() {
co_await collectAll(as.join(), m1.cleanup());
}
Bar m1;
AsyncScope as;
};
解释:
AsyncScope as;管理 Foo 内部启动的所有异步任务m1.cleanup()清理子成员collectAll(...)等待所有异步任务完成
也就是说:
析构前必须手动调用一次 cleanup()
否则内部还在运行的 async 任务可能继续触发 UB。
## 53 — cleanup() 规则
cleanup() 不应该抛异常
因为 cleanup 通常在资源释放阶段执行,抛异常容易导致双重异常(terminate)。
Tip:在析构函数中 assert cleanup() 已被调用
例如:
~Foo() {
assert(cleanup_was_called && "You MUST call cleanup() before destruction!");
}
因为这是手动 RAII,需要开发者确保正确调用。
这确实「有点费劲」(manual work),但这是协程模型的限制。
## ** RAII Scopes:确保 cleanup() 被调用**
你必须在作用域结束前确保:
cleanup() 被调用一次 cleanup() \text{ 被调用一次} cleanup() 被调用一次
常见方法:
co_await obj.cleanup();- 在调用链的最高层统一收尾
目标:
防止:
- 异步任务没结束
- 对象已销毁
- 仍访问成员 → 未定义行为(UB)
## 56 — Shared Knowledge(经验总结)
在类中使用 cleanup 模式管理异步任务
父类调用子对象 cleanup(递归)
对象销毁前必须 join 所有 async 工作
## 57–59 — 不要在析构函数里 blockingWait()!
很多人第一反应:
“那我在析构时直接 blockingWait() 不就好了?”
例如:
~MyClass() {
blockingWait(collectAll(as.join(), m1.cleanup()));
}
这是错误做法!CppCon 演讲直接写:DON’T!!
## 为什么 blockingWait() 是灾难?(三步解释)
1. blockingWait() 会阻塞当前线程
例如线程 T 被 blockingWait() 卡住。
2. continuation 调度需要线程去执行
协程的异步 continuation 需要线程继续跑:
A→coawaitScheduler runs next step A \xrightarrow{co_await} \text{Scheduler runs next step} AcoawaitScheduler runs next step
3. 但线程被 blockingWait 卡死,没人执行 continuation
于是:
- 异步任务永远不完成
- 析构永远卡住
- 程序死锁
图示:
Executor
Queue
A ← continuation 需要执行
Thread ← 线程被 blockingWait 卡死
blockingWait(A)
最终结果:死锁(Deadlock)
## 60 — 真实例子:析构死锁
struct Foo {
Task<> do_work() {
co_await as.schedule(sleep(1s));
}
Task<> cleanup() {
co_await as.joinAsync();
}
~Foo() {
blockingWait(cleanup()); // 死锁点
}
AsyncScope as;
};
工作流程:
- Foo 析构
- 析构中 blockingWait(cleanup())
- cleanup() 需要执行 as 内等待任务
- 但 continuation 需要线程
- 线程被 blockingWait 占着不放
- 死锁
## ** Shared Knowledge(最终总结)**
1. 类中用 cleanup() 清理异步任务
避免对象析构时还有任务在运行。
2. 绝不要在析构中使用 blockingWait()
原因:
blockingWait⇒阻塞线程 \text{blockingWait} \Rightarrow \text{阻塞线程} blockingWait⇒阻塞线程
异步任务 continuation⇒需要线程 \text{异步任务 continuation} \Rightarrow \text{需要线程} 异步任务 continuation⇒需要线程
没有线程⇒死锁 \text{没有线程} \Rightarrow \text{死锁} 没有线程⇒死锁
最终一句话总结
在协程世界里,RAII 不能依靠析构函数,必须显式写 cleanup(),并在析构前 co_await 它;析构中千万不要 blockingWait()。
# 协程中的 Synchronization(同步)
协程带来的最大危险之一:
你以为代码是串行执行的,但实际上可能被切换到不同线程执行。
所以是否需要同步要看:
- 是否存在共享数据
- 执行器是否是多线程(MT Executor)?
## ** Synchronization Needed? 是否需要同步?**
核心判断:
如果没有共享数据 → 不需要同步
举例:
int a = 10;
co_await reschedule_on_current_executor;
// 这可能切换到另一个线程执行
do_stuff(a);
分析:
a是局部变量- 没有其他任务访问它
- 即使
co_await切换线程,也没有数据竞争
所以:不需要同步
## Shared Access? 要看 Executor 类型
关键问题:数据是否被多个任务同时访问?
ST(单线程)executor
如果 executor 是单线程(Single Thread Executor):
- 虽然存在多个任务,但它们永远不会并发执行
- 所以:
ST Executor⇒不需要同步 \text{ST Executor} \Rightarrow \text{不需要同步} ST Executor⇒不需要同步
MT(多线程)executor
如果是多线程 executor:
可能多个 coroutine 同时运行,需要同步。
示例:
int a = 10;
co_await collectAll(
increaseBy(a, 1),
increaseBy(a, 2)
);
increaseBy(a, …) 同时运行,会产生数据竞争,因此:
MT Executor⇒需要同步 \text{MT Executor} \Rightarrow \text{需要同步} MT Executor⇒需要同步
## 66: 用哪些同步工具?
两种:
regular mutex(普通 mutex)
- std::mutex
- folly 的读写锁等
coro mutex(协程专用 mutex)
- folly::coro::Mutex
- folly::coro::SharedMutex
- 支持
co_await、不会阻塞线程
## 67: Regular Mutex(普通 mutex)禁止跨协程挂起点
禁止:持有普通锁跨过 suspension point
例如:
std::mutex m;
std::lock_guard g(m);
co_await something(); // UB
为什么?因为:
- 普通 mutex 是阻塞式的
- 若协程挂起并切线程,则:
- 锁被当前线程持有
- continuation 在另一线程运行,试图加锁 → 死锁
- 或 UB
推导:
普通锁是线程相关的 协程挂起可能切换线程 ⇒锁跨挂起点 = UB \text{普通锁是线程相关的} \ \text{协程挂起可能切换线程} \ \Rightarrow \text{锁跨挂起点 = UB} 普通锁是线程相关的 协程挂起可能切换线程 ⇒锁跨挂起点 = UB
## 68: Regular Mutex 建议:使用 lambda 格式
例如 folly 的 synchronized:
synchronized.withWLock([&](auto&) {
...
});
好处:
- lambda 内部无
co_await - 编译器强制禁止你在锁内执行异步逻辑(否则编译失败)
- 保证锁只持有短时间的同步逻辑
不要用 guard pattern:
auto g = m.lock(); // risky!
co_await foo(); // UB
## 69: Regular Mutex — Shared Knowledge
结论:
不要在 suspension points 之间持有普通 mutex
## 70–72: Coroutine Mutex(coro mutex)
这是协程推荐的锁方式。
特点:
- 它的锁锁住的是 协程,不是 线程
co_await获取锁- 挂起不会阻塞线程
例如:
coro::Mutex m;
co_await m.lock();
...
m.unlock();
coro mutex 支持 RAII!
{
auto g = co_await m.scopedLock();
// 使用保护数据
}
锁在 guard 离开作用域后自动释放。
## 71–72: 在析构函数中访问 mutex 保护的数据?
析构函数不能 co_await,所以只能:
使用 try_lock()(非阻塞)来尝试获取 coro mutex
这样保证不会:
- 死锁
- 持续 block 线程
## Shared Knowledge(同步部分最终总结)
不要在 suspension point 之间持有普通 mutex
普通 mutex 仅能用于 短同步代码段。
协程内使用 coro mutex(支持 co_await)
它不会阻塞线程,是协程友好的同步工具。
在析构函数中使用 try_lock()
因为析构函数中不能 co_await coro mutex。
## 总结:协程同步的核心
用一句数学公式总结:
普通 mutex:线程绑定协程 mutex:协程绑定 \text{普通 mutex:线程绑定} \\ \text{协程 mutex:协程绑定} 普通 mutex:线程绑定协程 mutex:协程绑定
普通 mutex 不能跨 suspension point,因为:
协程可切换线程普通锁不可跨线程传递 \text{协程可切换线程} \\ \text{普通锁不可跨线程传递} 协程可切换线程普通锁不可跨线程传递
协程 mutex 则天然适配协程的调度模型。
更多推荐

所有评论(0)