背景

翻过 cppcoro 的同学应该有留意到 CPPCORO_COMPILER_SUPPORTS_SYMMETRIC_TRANSFER 的宏定义,这是一个解决非对称转移asymmetric 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_handleawait_suspend() 函数将会由编译器执行对称转移到另一个协程上(详见 P0913)。说白了就是强制的尾调用优化。

std::noop_coroutine 也是顺手为该函数签名打上的补丁,可以让你运行时 resume 相当于空操作(只 suspend,见上方示例和提案链接),并且不违背函数签名。

方案二

cppcoro 开发时所在的 Coroutine TS 时代是没有返回 std::coroutine_handleawait_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_handleawait_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_handleawait_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