背景
翻过 cppcoro 的同学应该有留意到 CPPCORO_COMPILER_SUPPORTS_SYMMETRIC_TRANSFER 的宏定义,这是一个解决非对称转移问题的配置宏,新版 Clang 默认打开,GCC 默认关闭。
这一块其实比较老黄历,不值得细究了,但是可以了解一下。
问题
Task inner() { co_return; }
Task outer() {
// Use large number of iterations to trigger stack-overflow
for (int i = 0; i != 50000000; ++i) {
co_await inner();
}
}
C++20 协程的 resume 操作并不能完全保证是尾调用(tail call),也就是编译器可以将其视为普通函数调用(call 而非 jmp 指令)去实现。所以一个基本的问题是即使只有两个协程在反复地 co_await,其调用栈深度也会不断地累加增长,导致栈溢出风险。
NOTE: 需要更详细的解释可以参考文末链接。
方案一
#include <coroutine>
#include <exception>
class Task {
public:
struct promise_type {
Task get_return_object() { return Handle::from_promise(*this); }
struct FinalAwaitable {
bool await_ready() const noexcept { return false; }
// Use symmetric transfer. Resuming coro.promise().m_continuation should
// not require extra stack space
std::coroutine_handle<> await_suspend(
std::coroutine_handle<promise_type> coro) noexcept {
if (coro.promise().m_continuation) {
return coro.promise().m_continuation;
} else {
// The top-level task started from within main() does not have a
// continuation. This will give control back to the main function.
return std::noop_coroutine();
}
}
void await_resume() noexcept {}
};
std::suspend_always initial_suspend() noexcept { return {}; }
FinalAwaitable final_suspend() noexcept { return {}; }
void unhandled_exception() noexcept { std::terminate(); }
void set_continuation(std::coroutine_handle<> continuation) noexcept {
m_continuation = continuation;
}
void return_void() noexcept {}
private:
std::coroutine_handle<> m_continuation;
};
using Handle = std::coroutine_handle<promise_type>;
Task(Handle coroutine) : m_coroutine(coroutine) {}
~Task() {
if (m_coroutine) {
m_coroutine.destroy();
}
}
void start() noexcept { m_coroutine.resume(); }
auto operator co_await() const noexcept { return Awaitable{m_coroutine}; }
private:
struct Awaitable {
Handle m_coroutine;
Awaitable(Handle coroutine) noexcept : m_coroutine(coroutine) {}
bool await_ready() const noexcept { return false; }
// Use symmetric transfer. Resuming m_coroutine should not require extra
// stack space
std::coroutine_handle<> await_suspend(
std::coroutine_handle<> awaitingCoroutine) noexcept {
m_coroutine.promise().set_continuation(awaitingCoroutine);
return m_coroutine;
}
void await_resume() {}
};
Handle m_coroutine;
};
Task inner() { co_return; }
Task outer() {
// Use large number of iterations to trigger stack-overflow
for (int i = 0; i != 50000000; ++i) {
co_await inner();
}
}
int main() {
auto task = outer();
task.start();
}
理论上来说,使用返回 std::coroutine_handle
的 await_suspend()
函数将会由编译器执行对称转移到另一个协程上(详见 P0913)。说白了就是强制的尾调用优化。
而 std::noop_coroutine
也是顺手为该函数签名打上的补丁,可以让你运行时 resume 相当于空操作(只 suspend,见上方示例和提案链接),并且不违背函数签名。
方案二
cppcoro 开发时所在的 Coroutine TS 时代是没有返回 std::coroutine_handle
的 await_suspend()
函数,而是只能存在返回 void 和 bool 的版本。cppcoro 使用了非常迂回的方式去通过 bool 版本实现对 std::coroutine_handle
版本的模拟以解决非对称转移问题。不过这里也没啥好介绍的,因为它不惜动用了 std::atomic 去解决问题(详见 task.hpp),这是非常不值得借鉴(抄)的,只是没有办法时的办法。
实在感兴趣的话,可以参考文末链接,里面有 cppcoro 作者非常详细的说明。
注意点一
// 将 final_suspend::await_ready 换一个 void 版本去实现,会复发问题
void await_suspend(
std::coroutine_handle<promise_type> coro) noexcept {
if (auto h = coro.promise().m_continuation) {
// 没有强制的尾调用保证
h.resume();
}
}
至少要知道 void 版本 await_suspend()
可以认为是非对称转移的(Clang/GCC)。因此上述的改动会引起栈溢出,除非明确使用了优化等级从而恰好避开问题。
注意点二
前面提到,理论上来说,使用返回 std::coroutine_handle
的 await_suspend()
就是用户明确要求对称转移实现的协程。但是很不幸,GCC 仍然存在尾调用保证问题:别问为什么,问就是 bug。而且实测直至 GCC 14 都没有修复(godbolt 对比)。
推荐测试样例时强制使用 GCC -O2 及以上优化等级,或者干脆改用 Clang。
NOTE: 局部使用 [[gnu::optimize("O2")]]
属性并不管用。
// 伪代码,懂意思就好
void run() {
while (schedule()) {
auto handle = scheduler.pop_any();
handle.resume();
}
}
struct awaiter {
// ...
void await_suspend(auto handle) {
// ...
scheduler.push(handle);
}
};
不过一般使用协程的模式都是带有调度器的,即调用栈总是从某个类似 io_context.run()
的函数体内进行 resume 展开,而并非像之前那样不断地下探栈深度。所以问题也并不是一直存在的。
总结
- 如果你有 suspend 当前协程的同时 resume 其他协程的需求,建议强制使用返回
std::coroutine_handle
的await_suspend()
函数版本。 - GCC 特么的有 bug!如果你不想浪费排查时间(我踩过坑),测试时就加上强制的 -O2 吧。
References
C++ Coroutines: Understanding Symmetric Transfer – Asymmetric Transfer
Add symmetric coroutine control transfer – ISOCPP
cppcoro/include/cppcoro/task.hpp – GitHub
Symmetric transfer does not prevent stack-overflow for C++20 coroutines – GCC Bugzilla