问题的历史
早期的协程实现是有栈的,每个上下文切换分配了几兆的固定栈空间,这限制了并发只能达到数千个任务。C++20引入了无栈协程,在堆上分配帧,但天真的递归组合仍然有栈溢出的风险,因为不对称传输——从await_suspend返回void或bool——迫使恢复者调用resume(),建立O(N)原生调用栈帧。对称传输被标准化以允许协程A直接恢复协程B,通过强制的尾调用优化放弃A的栈帧。
问题
当协程A对协程B执行co_await,而B又在等待C时,不对称传输要求每个resume()调用在向下更深层次之前返回到其调用者。在递归深度N(例如,遍历50,000+个树节点)时,尽管每个协程帧位于堆上,但这仍会耗尽原生栈,导致SIGSEGV或STATUS_STACK_OVERFLOW。
解决方案
await_suspend必须返回std::coroutine_handle<Promise>(或std::coroutine_handle<>)。编译器将其视为尾调用:它销毁当前激活记录并直接跳转到目标句柄的恢复点,而不增加调用栈。这一机制保证了无论逻辑协程嵌套深度如何,执行的栈深度都是恒定的。
struct Task { struct promise_type { Task get_return_object() { return Task{std::coroutine_handle<promise_type>::from_promise(*this)}; } std::suspend_always initial_suspend() { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void return_void() {} void unhandled_exception() {} }; std::coroutine_handle<> h; }; struct SymmetricAwaiter { std::coroutine_handle<> target; bool await_ready() const noexcept { return false; } // 不对称(不良):void await_suspend(std::coroutine_handle<>) { target.resume(); } // 对称(良好):尾调用优化 std::coroutine_handle<> await_suspend(std::coroutine_handle<>) noexcept { return target; } void await_resume() noexcept {} };
问题描述
在开发一个高频交易引擎时,我们从基于回调的异步I/O迁移到C++20协程,以建模复杂的衍生品定价树。在对包含深度嵌套合成期权的投资组合(50,000+级别)进行压力测试时,系统因栈溢出而崩溃,尽管使用的是在堆上分配的协程帧。罪魁祸首是await_suspend的初始实现返回void,这导致原生栈随着定价模型的深度而增长。
考虑过的不同解决方案
解决方案1:通过ulimit -s或链接器标志增加原生栈大小。
优点是无需代码更改,并在测试期间提供了立即缓解。缺点包括每个线程浪费数GB的虚拟内存,未能解决无界递归场景,并在Linux和Windows之间造成了可移植性噩梦,因为栈分配机制差异显著。
解决方案2:实现一个从不递归的跳板执行循环。
优点包括保持协程语法不变,同时将栈管理移动到中央事件循环。缺点涉及巨大的延迟损失(由于虚拟调度,每个上下文切换数百纳秒),调度器中代码复杂度的增加,以及在挂起点跨越寄存器分配时丧失编译器优化。
解决方案3:通过从await_suspend返回std::coroutine_handle来采用对称传输。
优点提供了零开销抽象(与手写状态机的汇编相同),自然处理无界递归而无需栈增长,并保持可读的协程语法。缺点需要C++20编译器支持(最初在某些嵌入式平台上受限)以及调试的复杂性,因为由于尾调用消除,栈跟踪可能会被截断。
选择了哪个解决方案以及原因
我们选择了解决方案3,因为金融模型固有地要求无界递归深度进行理论定价计算。微秒延迟预算无法容忍跳板的开销,而内存限制禁止了大规模栈的预分配。对称传输提供了唯一的零成本解决方案,既正确又高效。
结果
该引擎成功处理了超过100,000个嵌套层级的投资组合,未崩溃。延迟基准测试显示与手工优化的C状态机的性能相同,且无论递归深度如何,内存使用保持不变。该系统在生产中运行了18个月,未发生任何与栈相关的崩溃。
为什么await_suspend返回void与返回true在协程帧挂起时机方面有所不同,这对线程安全为什么重要?
许多候选人假设void意味着立即挂起并转移控制。实际上,返回void会挂起当前的协程,但控制权返回给resume()的调用者,调用者随后决定下一步执行。返回true也会挂起,但关键是,void保证在await_suspend返回之前协程已被挂起,而使用bool时挂起的确切时机可能因实现而异。这个区别很重要,因为在await_suspend返回void后(例如,从另一个线程),访问协程局部变量只有在达到挂起点后是安全的。使用对称传输(返回一个句柄)时,栈帧在返回时立即被销毁,从而使局部变量瞬间不可访问——候选人常通过在启动对称传输后访问捕获的变量引入数据竞争。
当目标协程抛出异常时,对称传输如何与异常处理交互,这如何使得承诺类型中的unhandled_exception复杂化?
候选人常常忽视,对称传输绕过了等待协程的正常栈展开。当协程A以对称方式将控制权转移给B,并且B抛出异常时,异常会传播到B的unhandled_exception。然而,A的栈帧已经通过尾调用优化被替换,这意味着A无法使用围绕co_await表达式的try/catch捕获来自B的异常。异常反而会传播到A的原始调用者(恢复者),可能跳过A的清理代码,除非A的承诺中的unhandled_exception仅通过堆分配的帧管理状态。因此,初学者常常假设RAII栈保护会在A中触发,导致异常发生在对称链中时的资源泄漏。
std::noop_coroutine()在对称传输链中的重要性是什么,为什么必须返回它而不是默认构造的句柄来指示完成?
默认构造的std::coroutine_handle是一个空句柄,在恢复时会表现出未定义行为。从await_suspend返回它表示“现在不恢复任何东西”,使当前协程挂起而没有后继者,如果调度器期望有效的延续,可能会导致系统挂起。**std::noop_coroutine()**返回一个特殊的单例句柄,当恢复时立即返回其调用者。这对于终止至关重要:当一个叶子协程完成并希望将控制权返回给其父类而不进行手动恢复时,它返回std::noop_coroutine()。这允许父级的await_suspend(曾以对称方式转移给子协程)接收到一个有效的“延续”,它简单地返回,有效地安全地结束链。候选人混淆了空句柄和noop句柄,导致微妙的死锁,即协程系统在空恢复目标上无限等待。