C++20 协程的 Lambda 生命周期解释

Mik*_*Lui 9 c++ folly c++20 c++-coroutine

Folly为 C++20 风格的协程提供了一个可用的库。

在自述文件中,它声称:

重要提示:您需要非常小心临时 lambda 对象的生命周期。调用 lambda 协程会返回一个 folly::coro::Task 捕获对 lambda 的引用,因此如果返回的 Task 没有立即 co_awaited 那么当临时 lambda 超出范围时,任务将留下悬空引用。

我尝试为他们提供的示例制作 MCVE,但对结果感到困惑。为以下所有示例假设以下样板:

#include <folly/experimental/coro/Task.h>
#include <folly/experimental/coro/BlockingWait.h>
#include <folly/futures/Future.h>
using namespace folly;
using namespace folly::coro;

int main() {
    fmt::print("Result: {}\n", blockingWait(foo()));
}
Run Code Online (Sandbox Code Playgroud)

我用地址消毒剂编译了以下内容,以查看是否有任何悬空引用。

编辑:澄清的问题

问题:为什么第二个示例没有触发 ASAN 警告?

根据cppreference

当协程到达 co_return 语句时,它执行以下操作:

...

  • 或为 co_return expr 调用 promise.return_value(expr),其中 expr 具有非 void 类型
  • 以与创建它们相反的顺序销毁所有具有自动存储持续时间的变量。
  • 调用 promise.final_suspend() 和 co_await 的结果。

因此,也许临时 lambda 的状态在返回结果之前实际上不会被破坏,因为foo它本身是一个协程?


ASAN 错误:我假设在等待协程时“i”不存在

auto foo() -> Task<int> {
    auto task = [i=1]() -> folly::coro::Task<int> {
        co_return i;
    }(); // lambda is destroyed after this semicolon
    return task;
}
Run Code Online (Sandbox Code Playgroud)

没有错误——为什么?

auto foo() -> Task<int> {
  auto task = [i=1]() -> folly::coro::Task<int> {
      co_return i;
  }();
  co_return co_await std::move(task);
}
Run Code Online (Sandbox Code Playgroud)

ASAN 错误:与第一个示例相同的问题?

auto foo() -> folly::SemiFuture<int> {
    auto task = [i=1]() -> folly::coro::Task<int> {
        co_return i;
    }();
    return std::move(task).semi();
}
Run Code Online (Sandbox Code Playgroud)

没有错误......为了更好的衡量,只返回一个常量(没有捕获 lambda 状态)工作正常。与第一个示例进行比较:

auto foo() -> Task<int> {
    auto task = []() -> folly::coro::Task<int> {
        co_return 1;
    }();
    return task;
}
Run Code Online (Sandbox Code Playgroud)

Nic*_*las 17

这个问题不是 lambdas 独有的或特定的;它可能会影响任何同时存储内部状态并且恰好是协程的可调用对象。但是这个问题在创建 lambda 时最容易遇到,所以我们将从这个角度来看它。

首先,一些术语。

在 C++ 中,“lambda”是一个对象,而不是一个函数。一个 lambda 对象有一个函数调用 operator 的重载operator(),它调用写入 lambda 主体的代码。这就是 lambda 的全部内容,因此当我随后提到“lambda”时,我指的是 C++ object 而不是 function

在 C++ 中,作为“协程”是函数的属性,而不是对象。协程是一个从外部看起来与普通函数相同的函数,但它是在内部实现的,它的执行可以被挂起。当协程被挂起时,执行返回到直接调用/恢复协程的函数。

协程的执行可以稍后恢复(这样做的机制不是我要在这里讨论的太多内容)。当一个协程被挂起时,该协程函数内的所有堆栈变量都会被保留,直到协程被挂起为止。这个事实是允许协程恢复工作的原因;这就是使协程代码看起来像普通的 C++ 的原因,即使执行可能以非常不相交的方式发生。

协程不是对象,而 lambda 不是函数。所以,当我使用看似矛盾的术语“协程 lambda”时,我真正的意思是一个对象,它的operator()重载恰好是一个协程。

清楚吗?好的。

重要事实#1:

当编译器计算一个 lambda 表达式时,它会创建一个 lambda 类型的纯右值。这个纯右值将(最终)初始化一个对象,通常作为评估相关 lambda 表达式的函数范围内的临时对象。但它可能是一个堆栈变量。它是什么并不重要;重要的是,当您评估 lambda 表达式时,有一个对象在各方面都类似于任何用户定义类型的常规 C++ 对象。这意味着它有一个生命周期。

由 lambda 表达式“捕获”的值本质上是 lambda 对象的成员变量。它们可以是引用或值;没关系。当您在 lambda 主体中使用捕获名称时,您实际上是在访问 lambda 对象的命名成员变量。并且 lambda 对象中的成员变量规则与任何用户定义的对象中的成员变量规则没有什么不同。

重要事实#2:

协程是一个可以挂起的函数,可以保留其“堆栈值”,以便稍后恢复执行。就我们的目的而言,“堆栈值”包括所有函数参数、在暂停点之前生成的任何临时对象,以及在该点之前在函数中声明的任何函数局部变量。

这是所有该被保留。

成员函数可以是协程,但是协程挂起机制并不关心成员变量。暂停仅适用于该函数的执行,不适用于该函数周围的对象。

重要事实#3:

拥有协程的主要目的是能够暂停函数的执行并让该函数的执行由其他一些代码恢复。这可能位于程序的某个不同部分,并且通常位于与最初调用协程的位置不同的线程中。也就是说,如果您创建一个协程,您希望该协程的调用者将继续与您的协程函数的执行并行执行。如果调用者确实在等待您的执行完成,则调用者会选择这样做,而不是您的选择

这就是为什么你一开始就把它变成了一个协程。

folly::coro::Task对象的重点是从本质上跟踪协程的暂停后执行,以及编组由它生成的任何返回值。它还可以允许在执行它所代表的协程之后安排一些其他代码的恢复。所以 aTask可以代表一长串协程执行,每个协程都向下一个提供数据。

这里的重要事实是协程像普通函数一样从一个地方开始,但它可以在最初调用它的调用堆栈之外的其他时间点结束。

所以,让我们把这些事实放在一起。

如果您是一个创建 lambda 的函数,那么您(至少在一段时间内)拥有该 lambda 的纯右值,对吗?您要么自己存储它(作为临时变量或堆栈变量),要么将其传递给其他人。您自己或其他人将在某个时候调用该operator()lambda 的 。那时,lambda 对象必须是一个活的、功能性的对象,否则你手头上的问题会大得多。

所以 lambda 的直接调用者有一个 lambda 对象,并且 lambda 的函数开始执行。如果它是一个协程 lambda,那么这个协程可能会在某个时候暂停它的执行。这会将程序控制权转移回直接调用者,即保存 lambda 对象的代码。

这就是我们遇到 IF#3 后果的地方。看, lambda 对象的生命周期由最初调用 lambda 的代码控制。但是该lambda协程的执行由一些任意的外部代码控制。管理此执行的系统是Task通过协程 lambda 的初始执行返回给直接调用者的对象。

所以有Task代表协程函数执行的 。但也有 lambda 对象。它们都是对象,但它们是独立的对象,具有不同的生命周期。

IF#1 告诉我们 lambda 捕获是成员变量,而 C++ 的规则告诉我们成员的生命周期由它所属的对象的生命周期控制。IF#2 告诉我们,协程挂起机制不会保留这些成员变量。并且 IF#3 告诉我们协程执行由 控制Task,其执行可以(非常)与初始代码无关。

如果你把这一切放在一起,我们发现,如果你有一个捕获变量的协程 lambda,那么被调用的 lambda 对象必须继续存在,直到Task(或任何控制继续协程执行的东西)完成协程 lambda 的执行. 如果不是,则协程 lambda 的执行可能会尝试访问生命周期已结束的对象的成员变量。

具体如何操作取决于您。


现在,让我们看看你的例子。

示例 1 失败的原因很明显。调用协程的代码会创建一个表示 lambda 的临时对象。但是这个临时文件会立即超出范围。没有努力确保 lambda 在Task执行时保持存在。这意味着协程可以在其所在的 lambda 对象被销毁后恢复。

那很糟。

示例 2 实际上同样糟糕。lambda 临时对象在创建后立即销毁tasks,因此仅co_await对其进行操作无关紧要。然而,ASAN 可能根本没有发现它,因为它现在发生在协程内部。如果您的代码改为:

Task<int> foo() {
  auto func = [i=1]() -> folly::coro::Task<int> {
      co_return i;
  };

  auto task = func();

  co_return co_await std::move(task);
}
Run Code Online (Sandbox Code Playgroud)

那么代码就好了。原因是co_awaita 上的 ingTask导致当前协程暂停其执行,直到完成中的最后一件事Task,而“最后一件事”是func. 并且由于堆栈对象由协程挂起保留,func只要该协程存在,堆栈对象就会继续存在。

示例 3 与示例 1 的原因一样糟糕。如何使用协程函数的返回值并不重要;如果您在协程完成执行之前销毁 lambda,您的代码就会被破坏。

从技术上讲,示例 4 与其他示例一样糟糕。但是,由于 lambda 是无捕获的,因此它永远不需要访问 lambda 对象的任何成员。它实际上从未访问任何生命周期已结束的对象,因此 ASAN 永远不会注意到协程周围的对象已死亡。它是 UB,但它是 UB 不太可能伤害你。如果您已经从 lambda 中显式提取了一个函数指针,那么即使 UB 也不会发生:

Task<int> foo() {
    auto func = +[]() -> folly::coro::Task<int> { //The + extracts a function pointer from a captureless lambda for complex, convoluted reasons.
        co_return 1;
    };
    auto task = func();
    return task;
}
Run Code Online (Sandbox Code Playgroud)

  • 您到底为什么认为最后一个示例具有未定义的行为?我们可以“delete this;”,所以简单地“存在”在一个死对象的成员函数中就可以了;成员函数(最初)在 lambda 被销毁之前被调用,并且我没有看到任何规则表明“恢复”这样的函数是不好的。 (2认同)