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 ——关键概念

主要两个:

  1. Task
  2. 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);

执行顺序是:

  1. 调用 foo() 不会执行 "Hello"
    ——因为 Task 是 lazy
  2. 打印 "world"
  3. 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 suspendresume 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 Queueexecuteyield 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,结束协程
    然后:
  1. 执行起点 f1f_1f1
  2. 当 A 完成 → executor 将 f2f_2f2 入队
  3. 当 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)mMexecution(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);
    • 只要 aco_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)mMexecution(m).
  • 推荐做法(按优先级):
    1. 使用 结构化并发(最稳妥)。
    2. 使用 shared_ptr/enable_shared_from_this 延长对象生命期。
    3. 在协程开始时 按值复制 所需数据(如果语义允许)。
    4. 设计 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 lambdalifetime 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 会将执行让出给调度器,破坏异常模型的语义
    所以语言层面禁止在 catchco_await

47 — Capture the exception(捕获异常,再处理)

因为不能直接 co_await,你只能:

  1. 在 catch 中保存异常
  2. catch 外再 co_await
  3. 如果需要再 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()=truedo_other_stuff() 抛异常
  • res.hasValue()=trueres.hasValue() = \text{true}res.hasValue()=true → 没有异常,可以继续执行
  • res.value()
    • 如果无值 → 自动 rethrow 捕获到的异常
    • 如果有值 → 返回真正的结果
      这是 异步异常安全模式 的常见写法。

49 — Shared knowledge(共同经验总结)

要在 catch 中做异步操作时,要用包装器捕获异常

因为不能直接在 catch 中 co_await,必须:

  1. 捕获异常(exception_ptr 或库提供的 wrapper)
  2. 在 catch 外 co_await 异步清理逻辑
  3. 必要时重抛异常

使用库来简化异常捕获与处理

例如:

  • co_awaitTry
  • task<T>::result()
  • expected-like 类型
    这些封装你用起来更安全、更简洁。

核心总结(你必须记住的)

问题 原因 解决方案
catch 中不能 co_await C++ 语言限制 捕获异常 → catch 外 await
异步操作需要在异常时清理 否则会造成资源泄漏 使用 exception_ptr 或库的包装器
想优雅处理异常 手写太麻烦 使用 co_awaitTry 这类工具
本页核心就在于:

异步代码 + 异常 = 不能用传统 catch,需要用包装器配合 await。

RAII(资源获取即初始化)在协程里的新问题

在普通 C++ 中,RAII 只处理:

  • 析构时释放资源
  • 自动变量自动离开作用域
  • 异常安全
    但在 协程场景,RAII 遇到两个大问题:
  1. 异步任务不能在析构函数里 co_await
  2. 任务已启动但对象已经析构 → 悬空引用(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} Acoawait Scheduler 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;
};

工作流程:

  1. Foo 析构
  2. 析构中 blockingWait(cleanup())
  3. cleanup() 需要执行 as 内等待任务
  4. 但 continuation 需要线程
  5. 线程被 blockingWait 占着不放
  6. 死锁

## ** 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 是阻塞式的
  • 若协程挂起并切线程,则:
    1. 锁被当前线程持有
    2. continuation 在另一线程运行,试图加锁 → 死锁
    3. 或 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 则天然适配协程的调度模型。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐