C++ 协程的取消与生命周期:悬空 coroutine_handle 从哪里来
沿 promise、awaiter、continuation 和 frame 所有权拆开协程销毁契约;用结构化并发封装,并以等待方先销毁、I/O 晚完成三类竞态验收。

协程让异步看起来像同步,也把对象寿命藏进编译器生成的 frame。线上最难查的一类崩溃不是 co_await 写错,而是取消只设了原子标志,执行器稍后仍拿着 coroutine_handle 恢复已经销毁的 frame。症状往往是偶发堆损坏,复现依赖「等待方先走、完成回调后到」的时序。
主线:先固定谁拥有 frame,再定义取消如何阻止恢复,最后用显式生命周期测试锁死竞态。
1. 协程不是函数,是堆上的状态机
co_await / co_return / co_yield 把函数体拆成挂起点。编译器生成的 frame 大致包含:promise 对象、形参副本、局部变量、挂起时的继续点。std::coroutine_handle<Promise> 只是指向该 frame 的句柄:拷贝句柄不拷贝状态,销毁 frame 后所有句柄立即失效。
C++20 标准把变换后的协程行为规定为大致如下的状态机(简化表述):
- 分配 frame(可由
promise_type::operator new定制); - 构造 promise,拷贝/移动形参进 frame;
- 调用
promise.get_return_object(),其结果成为调用方拿到的任务对象; - 调用
initial_suspend():若挂起,调用方先拿到任务对象,协程体尚未执行; - 用户代码运行到
co_await/co_yield/co_return; - 结束时进入
final_suspend();之后必须由某一方destroy(),否则泄漏。
关键 API 语义:
handle.resume():从上次挂起点继续,必须在 frame 仍存活且未在该线程并发destroy时调用;handle.destroy():销毁 frame,此后不可再resume;handle.done():是否已跑到最终暂停点(通常在final_suspend);handle.promise():在 frame 存活期间访问 promise。
标准不保证 resume 与 destroy 的线程安全。多人把 handle 存进 IO 完成队列,又在超时路径上 destroy(),完成回调稍后 resume()——这就是悬空 handle 的标准生产线。
2. await 变换与对称转移
对表达式 co_await expr,编译器大致做:
- 得到 awaitable(可能经
promise.await_transform); - 调用
await_ready():为真则直接await_resume(),不挂起; - 否则进入
await_suspend(handle),其返回类型决定控制流:void:挂起当前协程,由 awaiter 稍后resume;bool:返回false表示不挂起(对称于 ready),true表示挂起;coroutine_handle<P>:对称转移(symmetric transfer)——挂起当前协程并直接 resume 返回的那个 handle,避免「resume 里再 resume」把调用栈叠爆,也避免额外调度延迟。
对称转移是结构化任务嵌套的性能关键点:子任务在 final_suspend 里返回父任务的 handle,父直接接着跑,而不是把父投递回线程池。它也放大生命周期风险——转移目标的 handle 必须在此刻有效。若父任务已取消并销毁,子任务 final_suspend 绝不能返回悬空父 handle。
await_suspend 返回 bool 时有经典双 resume 坑:若 awaiter 在 await_suspend 体内已经异步 resume 了当前协程,又返回 false(表示同步继续),同一协程会被恢复两次。正确模式是:要么把 resume 完全交给外部完成回调并返回 true/void,要么同步完成并返回 false 且不再 enqueue resume,二者择一。
3. Generator 与 Task:两套完全不同的寿命模型
std::generator(C++23)或手写 generator 的典型契约是:拉取方拥有迭代,co_yield 把值交给调用方,generator 对象析构时销毁 frame。它通常跑在同一线程、同步迭代,没有「IO 完成回调持有 handle」的问题。取消语义接近「停止迭代并析构 generator」。
异步 Task<T> 则相反:协程在第一次 co_await 后把控制交还调用方,真正的继续发生在执行器/IO 线程。frame 的存活必须覆盖所有可能 resume 的时间窗。把 generator 的「析构即 destroy」习惯套到 Task 上,就会在未完成的 IO 上制造 UAF。
实践区分:
| 类型 | 谁驱动 | 典型挂起原因 | 析构时必须保证 |
|---|---|---|---|
| Generator | 调用方 next | co_yield | 无外部 resume 在途 |
| Task | 执行器/IO | co_await 异步操作 | 所有 registration 已撤销或共享所有权未放完 |
| 火即忘(detached) | 执行器 | 同上 | 自持有 control block,直到 final_suspend |
火即忘任务尤其危险:没有人 co_await 它,异常可能被吞,取消也无人汇合。若业务允许 detach,必须自带 control block 与完成回调,并在进程关闭路径上排空执行器。没有汇合点的异步工作,本质上是把生命周期问题推迟到关机瞬间再爆发。
4. 所有权链:promise、awaiter、continuation
把一次异步等待拆开:
- 任务对象(如
Task<T>)通常在promise_type里提供get_return_object(),并决定 frame 是否由任务对象 RAII 管理; - awaiter 的
await_suspend(handle)把 handle 注册给执行器/IO; - continuation 是「子协程结束后恢复谁」——常见于
Task嵌套co_await,往往在final_suspend里对称转移。
取消出问题,多半是这三者对「谁有权 destroy」没有单一答案。推荐契约:
- frame 的唯一销毁权归顶层任务对象或显式的
coroutine_scope(经 control block 仲裁); - awaiter 只持有 handle 的观察权,完成时必须先通过「仍注册且未取消」的关卡才能
resume; - 子任务结束时恢复父任务,不得在父任务已销毁后 enqueue continuation。
用不完整的 awaiter 可以说明缺口:
struct IoAwaiter {
std::atomic<bool>* cancel{nullptr};
std::coroutine_handle<> handle{};
bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<> h) {
handle = h;
engine.post_read([this](IoResult r) {
if (cancel && cancel->load(std::memory_order_acquire)) {
return; // 不 resume:但谁负责收尾?
}
result = r;
handle.resume();
});
}
IoResult await_resume() { return result; }
IoResult result{};
};cancel 为真时,谁来 destroy?若没有结构化并发,回调直接 return 会把协程永久挂起,造成泄漏;若另一侧已经 destroy,这里又不能 resume。缺的是单一仲裁者。
5. 完整 control block:把 resume/destroy 收成协议
把 frame 外包一层可共享的控制块,是实践中最稳的骨架之一:
struct TaskControl : std::enable_shared_from_this<TaskControl> {
enum class State { Running, Cancelling, Completed, Destroyed };
std::mutex mu;
State state{State::Running};
std::coroutine_handle<> handle{};
std::stop_source stop;
std::function<void()> continuation;
int io_refs{0}; // 在途异步操作数
bool try_register_io() {
std::lock_guard lock(mu);
if (state != State::Running) return false;
++io_refs;
return true;
}
void complete_io(std::function<void()> resume_body) {
std::coroutine_handle<> h;
{
std::lock_guard lock(mu);
if (state == State::Destroyed || state == State::Cancelling) {
--io_refs;
maybe_destroy_unlocked();
return;
}
--io_refs;
h = handle;
}
resume_body();
h.resume(); // 或投递到约定线程再 resume
}
void request_cancel() {
stop.request_stop();
std::lock_guard lock(mu);
if (state == State::Running) state = State::Cancelling;
maybe_destroy_unlocked();
}
void maybe_destroy_unlocked() {
if (state == State::Cancelling && io_refs == 0 && handle) {
auto h = handle;
handle = nullptr;
state = State::Destroyed;
// 解锁后再 destroy,避免 promise 析构重入同锁
// 实际实现用「解锁 + 局部变量」模式
h.destroy();
}
}
};要点:
- 在途引用计数
io_refs:每个成功注册的异步操作 +1,完成/撤销 -1;仅当取消且计数归零时destroy; - 状态机禁止 Completed/Destroyed 后再
resume; stop_source/stop_token向下传播协作式停止,但不直接等于destroy;resume/destroy发生在锁外,避免 promise / awaiter 析构回调重入死锁。
awaiter 侧改为:先 try_register_io(),失败则同步走取消路径(返回取消错误或抛 operation_canceled);完成回调只调用 complete_io,不再碰裸 handle。
6. 为什么「原子取消标志」不够
原子标志看起来便宜:一个 atomic<bool>,业务循环里轮询即可。它能阻止新的业务逻辑继续跑,但不能自动回收已注册的完成回调,也不能与 destroy 排序。标志与 handle 是两个对象,对它们的读写无法组成硬件级原子事务——这才是竞态的根源。常见失败序列是:
- 超时线程:设
cancel=true,然后handle.destroy(); - IO 线程:读到完成,检查标志——若在 destroy 之前通过检查,随即
resume已销毁 frame。
把检查和 resume 做成「非原子的两步」就必然有窗口。可行修复方向:
- 引用计数 + 延迟销毁:上一节的 control block;
- 取消只请求、执行器统一收口:取消把任务移入「即将销毁」队列,任何 completion 只能投递到执行器,由执行器串行决定 resume 还是 destroy;
- 停止回调注册:
await_suspend返回的 registration 支持同步 unregister;unregister 成功则 completion 必不调用,任务侧可安全 destroy。
第三条最像结构化并发:co_await 的析构/取消路径必须能同步撤销挂起操作,或证明撤销与 completion 互斥。若底层 IO 只提供异步 cancel(例如「取消请求稍后才保证不回调」),则必须用引用计数跨过这段窗口,而不能在 cancel 返回后立刻 destroy。选型时先问驱动:「cancel 返回后,completion 是否仍可能入队?」答案为是,就没有捷径可走,control block 的 io_refs 不是过度设计,而是与驱动契约对齐的最小代价。
7. 与 Asio awaitable、stop_token 结合
在机器人与网关代码里,Asio 往往是实际的异步后端:定时器、串口、TCP、域套接字都经由它投递完成。此时「自研 Task」与「Asio awaitable」混用很常见——也最容易在取消边界扯皮。Boost.Asio / standalone Asio 的 awaitable<T> 与 use_awaitable 把 socket 异步操作接到协程。取消路径上常见组合是:
cancellation_signal/cancellation_slot:Asio 的取消通道;- C++20
std::stop_token:与jthread、结构化 scope 对齐的标准信号。
桥接时注意语义差:Asio 取消通常让未完成操作以 operation_aborted 完成(仍会调用 completion,只是错误码不同);stop_token 只是协作标志。若你在 stop_callback 里直接 destroy 协程,而 Asio 稍后仍以 operation_aborted「完成」并 resume,UAF 依旧。正确做法是:stop_callback 里触发 Asio cancellation_signal.emit(),让操作走正常完成路径回到协程,在 await_resume 里看到 aborted 后再干净退出;frame 的销毁留在任务对象/scope 汇合之后。
asio::awaitable<void> read_loop(tcp::socket& sock, std::stop_token st) {
auto state = co_await asio::this_coro::cancellation_state;
std::stop_callback cb(st, [&] {
// 仅发射 Asio 取消,不 destroy frame
});
for (;;) {
if (st.stop_requested()) co_return;
std::array<char, 1024> buf;
auto [ec, n] = co_await sock.async_read_some(
asio::buffer(buf), as_tuple(asio::use_awaitable));
if (ec == asio::error::operation_aborted) co_return;
if (ec) throw asio::system_error(ec);
// 处理 buf...
}
}绑定 stop_token 与 Asio cancellation 时,要用文档推荐的槽位切换(如 this_coro::cancellation_state),避免每个 awaiter 私自保存 coroutine_handle 再在超时线程硬销毁。
手写桥接时,stop_callback 的寿命必须覆盖整个 co_await 窗口:callback 对象通常是局部变量,正好在 await 期间存活;若把它存到堆上却在 resume 后忘记卸除,可能对已销毁的 socket 二次 emit。反之,若 callback 在 await_suspend 返回前就析构,取消信号可能永远打不进 Asio。寿命规则与 awaiter 本身相同:注册与撤销必须成对。
还要注意执行器亲和:Asio 完成可能在任意 io_context 线程;若你的 Task 约定「只在 strand 上 resume」,桥接层就要把 completion 再 post 一次,而不是在完成回调里直接碰 promise。少这一跳,轻则数据竞争,重则与取消路径的互斥锁形成 AB-BA 死锁。把「resume 线程约定」写成 Task 类型的文档不变量,并在测试里用线程 ID 断言,能避免后继作者「为了省一次 post」拆掉安全网。
8. promise 定制:initial/final_suspend 与返回对象
Task 的寿命骨架很大一部分写在 promise_type 里,而不是业务协程体里。常见且相对稳妥的选择:
initial_suspend→suspend_always:调用方拿到Task后再显式start(),或由 executor 调度第一次 resume;避免「get_return_object未存完就开始跑」。final_suspend→suspend_always:把销毁权交还Task/control block;在await_suspend里对称转移 continuation,或通知 scope「子任务结束」。return_value/return_void:把结果写入 control block,供父任务await_resume读取。unhandled_exception:std::current_exception()存入 control block,绝不在 IO 线程throw穿透。
struct TaskPromise {
std::shared_ptr<TaskControl> ctrl{std::make_shared<TaskControl>()};
Task get_return_object() {
return Task{ctrl, std::coroutine_handle<TaskPromise>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; }
auto final_suspend() noexcept {
struct FinalAwaiter {
TaskControl* ctrl;
bool await_ready() const noexcept { return false; }
std::coroutine_handle<> await_suspend(std::coroutine_handle<>) noexcept {
// 对称转移:父仍在则 resume 父,否则 noop
if (auto p = ctrl->continuation_handle()) return p;
return std::noop_coroutine();
}
void await_resume() const noexcept {}
};
return FinalAwaiter{ctrl.get()};
}
};get_return_object 与 initial_suspend 的时序值得单独测:若 Task 构造函数把 handle 写入 control block 的动作发生在协程体可能执行之后,就会出现「continuation 空窗」。suspend_always 的 initial 挂起是消除该空窗的简单办法。需要懒启动时,明确区分「已创建未启动」与「已挂起在 IO」两个状态,取消路径对二者处理不同——前者可直接 destroy,后者必须走 io_refs 协议。
9. 结构化并发:scope 比散落的 handle 更安全
结构化并发的核心不变量只有一句:父的寿命覆盖子的寿命,父结束前子必须汇合。协程把这句变得难写,是因为「子在跑」往往表现为「别人线程里握着子的 handle」,父的栈帧却可能早已返回。scope 对象就是把这句不变量重新变成 RAII:创建子任务时登记,析构时取消并等待 io_refs 归零。
与其在每个 awaiter 里手写竞态,不如把生命周期收到 scope:
struct AwaitScope {
std::vector<std::shared_ptr<TaskControl>> children;
std::stop_source stop;
template <class Awaitable>
auto spawn(Awaitable a); // 登记子任务,传入 stop.get_token()
void request_cancel() noexcept { stop.request_stop(); }
~AwaitScope() {
request_cancel();
join_all(); // 等到所有 completion 抵达或撤销
}
};规则是:父协程离开作用域前,子协程要么完成要么被取消且回调已排空。这与 std::jthread + stop_token 的思路同构——协作式停止,析构会汇合。协程版的坑在于汇合点必须与执行器线程模型兼容:若在持有执行器锁时 join,容易自死锁;若在 IO 线程上同步等待「同一 IO 线程才能完成的操作」,也会自死锁。汇合应投递到独立线程或使用「可重入的事件循环 pump」,并设超时告警。
stop_token 适合向下传播「请停止」;它不替代 frame 所有权。把 stop_token 塞进 awaiter 后,仍要定义停止后由谁 destroy,以及 completion 如何观测「registration 已失效」。
子任务进一步 spawn 孙任务时,token 应从同一 stop_source 派生,或使用可分层的 cancellation 状态(Asio 的 total/partial 取消层级就是这类需求)。只把 token 传一层、下层又新建独立 stop_source,会导致父取消时树叶仍在跑,scope 汇合永远等不齐。审查嵌套 spawn 时,沿着 token 的传递路径走一遍,比只看裸 handle 更能提前发现漏网子树。
10. 异常、final_suspend 与吞掉的续体
协程内抛异常时,异常存进 promise(unhandled_exception),随后命中 final_suspend。若 final_suspend 返回 suspend_always 且无人再 destroy,frame 泄漏;若自动 destroy 却仍有外部 handle,又回到悬空问题。更干净的做法是:
- 任务对象析构:若未
done(),request_cancel并在安全点destroy; - continuation 只在
final_suspend::await_suspend里恢复父协程,且父协程以 control block 保活; - 异常在
await_resume/result()边界重新抛出,不在 IO 线程直接穿透; final_suspend里做对称转移前,先检查父 control block 状态,取消则返回noop_coroutine()。
coroutine_handle::destroy() 不是线程安全地对任意并发 resume 可重入的。标准没有给你「cancel = destroy」的免费午餐;你要自己做互斥或所有权。unhandled_exception 之后若任务已被 detach 且无人观测结果,至少要打日志或终止——静默吞异常会让生命周期 bug 伪装成「业务没跑完」。
取消与异常的优先级要写进契约:若停止请求与业务异常同时到达,父任务看到的是 operation_canceled 还是原始异常?机器人控制里通常让取消胜出,并把原始异常记入遥测;库代码则应在文档里写死一种,避免调用方各写各的判断。await_resume 里先查 stop_requested 再 rethrow_exception 是一种清晰顺序。无论选哪一侧胜出,都要在单测里构造「异常与取消同达」的注入,防止实现随重构悄悄翻转语义。
11. 竞态测试:比压力测试更能抓住寿命 bug
单元测试应直接编排时序,而不是指望压力碰巧踩中。除等待方先销毁、IO 晚完成、异常传播外,再补对称转移与 Asio 取消。
A. 等待方先销毁
- 启动协程并对慢 IO
co_await; - 立刻销毁任务对象 / 退出 scope;
- 再注入 IO 完成。
期望:无 resume、无 UAF;ASAN/UBSAN 干净;相关 registration 引用计数归零。
B. IO 晚完成与取消交错
在 awaiter 的 completion 回调里插入可调栅栏:让取消线程先 request_cancel,再放行 completion。期望:completion 与 destroy 互斥;最多一次 resume 或零次 resume,且不会双 resume。
C. 异常传播
子协程在 IO 完成后抛错,父协程已发出取消。期望:错误可观测(或被取消语义明确覆盖),不出现「父已销毁仍 resume」;也不出现「异常丢失且 frame 泄漏」。
D. 对称转移目标已取消
子任务完成瞬间父 scope 已取消。期望:final_suspend 返回 noop_coroutine() 或等价安全路径,ASAN 干净。
E. Asio operation_aborted 序
stop_token 触发与 socket 完成交错。期望:只通过 aborted 完成路径回到协程并退出,从不在 stop_callback 里 destroy。
TEST(CoroutineCancel, WaiterDestroyBeforeComplete) {
InjectionEngine engine;
auto task = start_read(engine);
task.request_stop();
task.reset(); // 销毁等待方
engine.complete_last_read(); // 晚到的完成
EXPECT_EQ(engine.outstanding(), 0);
}
TEST(CoroutineCancel, DoubleResumeFromBoolAwaitSuspend) {
// 构造 await_suspend 误返回 false 且已 async resume 的 awaiter
// 期望:检测器或契约断言抓到双 resume,而不是堆损坏
}
TEST(CoroutineCancel, SymmetricTransferAfterParentCancel) {
auto scope = AwaitScope{};
auto child = scope.spawn(slow_child());
scope.request_cancel();
// child 完成后不得 resume 已销毁父 handle
}把这些测试留在 CI(配合 ASAN),比在文档里写「注意生命周期」有用得多。压力测试仍可保留,但只能当补充,不能当首道防线。
注入引擎本身也要可测:它应能记录「完成回调是否被调用」「调用时 control block 状态」「resume 次数」。没有这些计数,测试只能靠「没崩」当绿——ASAN 关着时最容易漏。把 resume_count、destroy_count、io_refs_peak 断言写进用例,回归才稳。
F. 跨线程 destroy 与 resume
故意让线程 A request_cancel→destroy,线程 B 同时走出 completion。期望:互斥或引用计数保证二者不同时进入;用 ThreadSanitizer 跑一轮。若协议声称「所有 resume 都在 strand 上」,测试就应在非 strand 线程调用 completion 包装器并期望其投递,而不是直接 resume。
G. scope 析构自死锁检测
在单线程 io_context 上跑 AwaitScope 析构,子任务的完成仍需该 context 执行。期望:要么 join 内部 poll/run_one,要么文档禁止在该线程析构并在测试中断言超时失败被清晰报出。含糊的永久挂起比崩溃更糟。
TEST(CoroutineCancel, IoRefsGatesDestroy) {
auto ctrl = std::make_shared<TaskControl>();
ASSERT_TRUE(ctrl->try_register_io());
ctrl->request_cancel();
EXPECT_EQ(ctrl->state(), TaskControl::State::Cancelling);
EXPECT_NE(ctrl->state(), TaskControl::State::Destroyed);
ctrl->complete_io([] {}); // 计数归零后才允许 destroy
EXPECT_EQ(ctrl->state(), TaskControl::State::Destroyed);
}12. 内存分配、自定义 allocator 与帧泄漏排查
协程 frame 默认走全局 operator new。高频短任务会让分配器成为热点,也让泄漏更难一眼看出。promise_type::operator new(std::size_t) / operator delete 可以接到池化分配器;此时取消路径仍必须 destroy,否则池会被占满。排查泄漏时不要只看 malloc 总量——要看「已创建未 destroy 的 control block 数」。在 debug 构建里给每个 Task 分配单调 ID,scope 析构时打印未汇合 ID 列表,比等 OOM 更早暴露忘记 co_await 的火即忘任务。
coroutine_handle::done() 为真并不自动等于「可以丢弃所有观察者」:若你在 final_suspend 里对称转移父任务,父恢复期间子 handle 仍可能被谁握着。约定应是:final_suspend 开始后,除 continuation 协议外无人再碰子 handle;父侧 await_resume 只读 control block 里的结果副本。
另一类泄漏来自「取消后永久挂起」:completion 见标志直接 return,却没有任何路径 destroy 或重新调度到清理协程。症状不是崩溃,而是进程句柄数、frame 池占用缓慢爬升。监控上应同时报:活跃 Task 数、在途 IO 数、Cancelling 超过阈值时长的任务数。Cancelling 驻留过久,往往是某条底层取消没有真正完成回调——这时不该在业务层「再 destroy 一次」,而应沿着 IO 驱动查撤销是否生效。
与 generator 混用时还有所有权错位:同步 generator 被 co_await 风格包装后,有人在异步回调里继续 begin/end 迭代,等于让 IO 线程操作本应按拉取方单线程使用的 frame。规则很硬:generator 不进异步完成路径;异步任务不套 generator 的「析构即 destroy」捷径。类型系统若区分 Task 与 Generator 的 awaiter 约束,比靠注释更可靠。
13. 工程清单:审查协程 PR 时看什么
- 是否存在「裸
coroutine_handle成员」跨越等待边界?若有,对应的 control block / unregister 在哪? - 取消路径是否只写了
atomic_bool,却没有汇合或撤销? await_suspend返回bool/coroutine_handle的对称性是否会导致双 resume 或转移进已死父任务?- 析构是否可能在执行器线程上同步等待同一执行器?
- 所有
resume/destroy是否发生在约定线程,或有明确互斥? - Generator 式同步析构是否被误用到异步 Task?
- 与 Asio 集成时,取消是
emit还是硬destroy? - detach 任务是否有人观测异常与进程退出时的排空?
initial_suspend/final_suspend是否让get_return_object与首轮执行之间没有空窗?- 测试是否覆盖「等待方先销毁」「完成与取消交错」「对称转移目标已死」三条主路径?
协程的收益是把回调金字塔拉直;代价是把对象寿命变成分布式协议。取消不是设标志,而是停止恢复权并排空观察者。把这句话落实到 awaiter、control block 与 scope 的接口上,悬空 coroutine_handle 才会从「偶发玄学」变成可测试的不变量。标准只提供状态机零件;安全的取消语义是你在零件之上立下的契约,而契约必须靠注入时序的测试来守,而不是靠代码评审时的一句「注意线程安全」。
回到开题场景:超时线程与 IO 完成线程打架时,若协议是「原子标志 + 直接 destroy」,你已经把正确性赌在内存序与调度运气上。换成 control block 之后,超时只切换到 Cancelling 并 emit 底层取消;IO 完成只走 complete_io;真正的 destroy 发生在观察者清零之后。这条流水线更长,但每一步都可断言。机器人节点里异步读传感器、异步写日志、异步发控制——任何一处火即忘任务漏掉汇合,都会在关机或重连路径上以堆损坏的形式出现。把取消当成生命周期协议而不是布尔量,是这类系统长期可维护的前提。
若只记住三个检查点:第一,谁拥有 destroy 的唯一仲裁;第二,每个 await_suspend 是否能同步或等价地撤销;第三,父 scope 析构是否真的汇合了所有子树。三点都有测试盯着时,悬空 coroutine_handle 会从排障噩梦变成普通的契约违规,并在 CI 里被具体用例点名。对称转移、Asio 取消槽与 stop_token 都只是这条协议上的零件;零件可以换,协议不能缺。缺协议时,再漂亮的 co_await 语法也只是把回调地狱换成了更难复现的寿命地狱。
相关
也可以看看
johan's blog