C++编程C++ 开发者

当从 **C++20** 协程的 **await_suspend** 返回 **std::coroutine_handle** 时,什么机制防止了无限栈增长?

用 Hintsage AI 助手通过面试

问题的答案

await_suspend 返回 std::coroutine_handle 实现了 对称传输,这是一种保证的 尾调用优化TCO)形式。当 await_suspend 返回 void 时,协程运行时必须在恢复下一个协程之前返回给调用者,这样就创建了一个随着链长度线性增长的嵌套调用栈。通过返回一个句柄,编译器发出一个直接跳转(jmp 指令)到目标协程的恢复点,重用当前的 激活记录,保持恒定的 O(1) 栈深度,无论链的长度如何。

struct SymmetricTransfer { std::coroutine_handle<> next; // 尾调用优化:没有栈增长 std::coroutine_handle<> await_suspend(std::coroutine_handle<>) { return next; } void await_resume() {} bool await_ready() { return false; } };

生活中的情况

我们为专业音乐制作软件开发了一个实时音频处理引擎。该系统使用 C++20 协程表示 500 多个数字信号处理(DSP)效果(过滤器、压缩器、混响)串联在一起的管道。在压力测试期间,应用程序在加载复杂效果架时崩溃,尽管每个单独的协程具有最小的局部状态,仍然出现了 栈溢出

解决方案 1:返回 void 的 await_suspend 与直接恢复 初始实现使用 void await_suspend(std::coroutine_handle<>) 并在内部调用 next.resume()。这种方法提供了直观的顺序代码流,并通过标准栈跟踪进行简单调试。然而,每个 resume() 调用嵌套在上一个协程的挂起逻辑中,每个阶段消耗大约 16KB,共消耗 8MB 的线程栈,仅在 500 个阶段后就耗尽。

解决方案 2:采用异步调度的工作队列 我们考虑用一个集中式任务队列替代直接链接,每个协程提交下一个阶段作为工作项目并立即挂起。这通过将递归转化为迭代来保证恒定的栈使用。缺点是显著的性能下降:队列节点的动态分配、线程争用导致的缓存无法利用和管道阶段之间的缓存局部性丧失,违反了我们的亚毫秒延迟要求。

解决方案 3:通过 coroutine_handle 实现对称传输 我们重构了 await_suspend 以直接返回下一个阶段的 std::coroutine_handle。这指示编译器执行 TCO,合并栈帧。该解决方案保留了协程的零成本抽象,同时确保 O(1) 的内存使用。主要风险涉及生命周期管理:一旦句柄被返回,当前协程被挂起,再访问 this 或局部变量后返回点将导致未定义行为。

选择的解决方案和结果 我们采用了解决方案 3。经过重构,管道成功处理了 512 个连续的效果,仅使用 4KB 的栈空间,消除了崩溃并保持了确定性的实时性能。此更改需要仔细的代码审查,以确保 await_suspend 中不存在返回后的逻辑,但结果是一个稳健、可扩展的架构。

候选人常常忽视的点

为什么对称传输需要返回 std::coroutine_handle 而不使用 co_awaitawait_suspend 中的下一个协程上?await_suspend 中使用 co_await 将要求等待的协程首先完全挂起,然后稍后再继续,这本质上涉及返回到运行时并增长栈。直接返回句柄允许编译器将恢复视为尾调用,而 co_await 生成了一个不对称挂起点,必须保留调用者的帧才能稍后恢复。

如果恢复的协程在到达最终挂起点之前抛出异常,对称传输如何影响异常安全性? 如果被对称传递到的协程抛出异常,异常在概念上通过 await_suspend 帧展开,但由于原始协程已标记为挂起,因此其帧必须在栈展开期间被销毁。这要求编译器生成复杂的异常处理表,销毁被挂起协程的 promise 和捕获的参数。候选人常常忽略自定义的 promise_type 分配器必须正确处理部分构造,否则在异常展开期间可能会出现 双重销毁 的错误。

在实现从递归数据结构中生成值的生成器时,什么阻止了使用对称传输? 生成器依赖 co_yield 将控制权返回给调用者,同时保持其状态。对称传输无条件地将控制权传递给另一个协程,直到整个链完成才返回到原始调用者。因此,生成器必须使用不对称挂起(从 await_suspend 返回 voidtrue)来允许消费者接收输出的值,并可能稍后恢复生成器,而不是强制无可逆地转移到另一个协程。