如何检测 C++20 协程中的堆栈展开?

How to detect stack unwinding in C++20 coroutines?

C++ 中的典型建议是使用 std::uncaught_exceptions() 检测析构函数中的堆栈展开,请参阅 https://en.cppreference.com/w/cpp/error/uncaught_exception 中的示例:

struct Foo {
    int count = std::uncaught_exceptions();
    ~Foo() {
        std::cout << (count == std::uncaught_exceptions()
            ? "~Foo() called normally\n"
            : "~Foo() called during stack unwinding\n");
    }
};

但是这个建议看起来不再适用于 C++20 协同程序,它们可以暂停和恢复,包括在堆栈展开期间。考虑以下示例:

#include <coroutine>
#include <iostream>

struct ReturnObject {
  struct promise_type {
    ReturnObject get_return_object() { return { std::coroutine_handle<promise_type>::from_promise(*this) }; }
    std::suspend_always initial_suspend() { return {}; }
    std::suspend_always final_suspend() noexcept { return {}; }
    void unhandled_exception() {}
    void return_void() {}
  };
  std::coroutine_handle<promise_type> h_;
};

struct Foo {
    int count = std::uncaught_exceptions();
    Foo() { std::cout << "Foo()\n"; }
    ~Foo() {
        std::cout << (count == std::uncaught_exceptions()
            ? "~Foo() called normally\n"
            : "~Foo() called during stack unwinding\n");
    }
};

struct S
{
    std::coroutine_handle<ReturnObject::promise_type> h_;
    ~S() { h_(); }
};

int main()
{
  auto coroutine = []() -> ReturnObject { Foo f; co_await std::suspend_always{}; };
  auto h = coroutine().h_;

  try
  {
      S s{ .h_ = h };
      std::cout << "Exception being thrown\n";
      throw 0; // calls s.~S() during stack unwinding
  }
  catch( int ) {}
  std::cout << "Exception caught\n";

  h();
  h.destroy();
}

它在协程内部使用相同的class Foo,它被正常销毁(不是由于异常期间堆栈展开),但仍然打印:

Exception being thrown
Foo()
Exception caught
~Foo() called during stack unwinding

演示:https://gcc.godbolt.org/z/Yx1b18zT9

如何重新设计 class Foo 以正确检测协程中的堆栈展开?

想要知道函数是否由于堆栈展开而正在执行的典型原因是为了回滚数据库事务之类的事情。所以情况看起来像这样:

您的函数执行一些数据库工作。它创建一个由 RAII 对象管理的数据库事务。该对象位于函数的堆栈上(直接或间接地作为某个其他堆栈对象的子对象)。你做了一些事情,当 RAII 对象离开堆栈时,数据库事务应该提交或回滚,这取决于它是正常离开堆栈还是因为异常分别通过函数本身传递。

一切都非常整洁。函数本身不需要明确的清理代码。

这对协程意味着什么?这变得非常复杂,因为协程可以由于 外部 自身执行的原因而终止。

对于普通函数,它要么完成要么抛出异常。如果这样的函数失败,它会在函数内部发生。协程不是那样工作的。在挂起点之间,安排协程恢复的代码可能 自身 失败。

考虑异步文件加载。您将延续函数传递给文件 reader,延续将在读取文件数据时获得文件数据以进行处理。部分通过此过程,会发生文件读取错误。但这发生在访问文件的外部代码中,而不是 消耗 它的延续函数。

所以外部代码需要告诉消费函数发生了错误,它应该中止它的进程。这不会通过异常发生(至少不是默认情况下);这两段代码之间的接口必须有一种机制来传输进程失败。有一些方法可以让这个接口在延续函数本身内实际抛出一个异常(即:延续得到一些它调用的对象来访问当前读取的数据,如果发生读取错误它就会抛出),但这仍然是一个 合作机制。

它不会自己发生。

因此,即使您可以在协程中解决这个问题,您仍然需要考虑协程由于内部抛出的异常之外的原因需要终止的情况.由于您将需要显式代码来执行 cleanup/rollbacks/etc 无论如何 ,因此依靠纯粹的 RAII 机制来执行此操作毫无意义。

为了更直接地回答这个问题,如果你还想这样做,你需要把挂起点之间的代码当作它们自己的函数来对待。每个挂起点实际上是一个单独的函数调用,具有自己的异常计数等。

因此,要么 RAII 对象完全存在于挂起点之间,要么您需要在每次挂起点开始时更新异常计数。