c++ - C++ 20协程的Lambda生命周期说明

标签 c++ c++20 folly c++-coroutine

Folly有一个适用于C++ 20样式协程的库。

在自述文件中声称:

IMPORTANT: You need to be very careful about the lifetimes of temporary lambda objects. Invoking a lambda coroutine returns a folly::coro::Task that captures a reference to the lambda and so if the returned Task is not immediately co_awaited then the task will be left with a dangling reference when the temporary lambda goes out of scope.



我尝试为他们提供的示例制作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()));
}

我使用address sanitizer编译了以下内容,以查看是否有任何悬挂的引用。

编辑:澄清问题

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

根据cppreference:

When a coroutine reaches the co_return statement, it performs the following:

...

  • or calls promise.return_value(expr) for co_return expr where expr has non-void type
  • destroys all variables with automatic storage duration in reverse order they were created.
  • calls promise.final_suspend() and co_await's the result.


因此,也许临时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;
}

没有错误-为什么?

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

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

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

没有错误 ...而且在很好的情况下,只需返回一个常量(未捕获任何lambda状态)即可。比较第一个示例:

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

最佳答案

此问题不是lambda特有的或不是特定的。它可能会影响同时存储内部状态并且恰好是协程的任何可调用对象。但是在制作lambda时最容易遇到这个问题,因此我们将从这个角度来研究它。

首先,一些术语。

在C++中,“lambda”是对象,而不是函数。 lambda对象的函数调用操作符operator()具有重载,该调用调用写入lambda主体的代码。那就是lambda的全部内容,因此当我随后引用“lambda”时,我在说的是C++对象而不是函数。

在C++中,成为“协程”是函数的属性,而不是对象的属性。协程是从外部看起来与正常功能相同的功能,但在内部以可以暂停其执行的方式实现。当协程被挂起时,执行返回到直接调用/恢复协程的函数。

协程的执行可以在以后恢复(执行机制并不是我将在此处讨论的更多内容)。当协程暂停时,该协程中的所有堆栈变量都将保留,直到协程暂停为止。这个事实使协程恢复正常工作。这就是使协程代码看起来像普通C++的原因,即使执行可以以非常不相交的方式发生。

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

清楚吗?好的。

重要事实1:

编译器评估lambda表达式时,将创建lambda类型的prvalue。此prvalue将(最终)初始化一个对象,通常将其作为评估所讨论的lambda表达式的函数范围内的一个临时对象。但这可能是一个堆栈变量。到底什么都不重要;重要的是,当您评估lambda表达式时,有一个对象在任何方面都类似于任何用户定义类型的常规C++对象。这意味着它有一生。

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

重要事实2:

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

这就是所有保留下来的东西。

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

重要事实3:

拥有协程的主要目的是能够挂起函数的执行,并通过其他一些代码恢复该函数的执行。这可能会出现在程序的某些不同部分,通常是在与最初调用协程的地方不同的线程中。也就是说,如果创建一个协程,则希望该协程的调用方将继续与协程函数的执行并行执行。如果调用方确实等待执行完成,那么调用方将自行选择执行此操作,而不是您自己选择。

这就是为什么要将它作为协程开始的原因。
folly::coro::Task对象的重点是实质上跟踪协程的暂停后执行以及将其生成的所有返回值编码起来。它也可以允许人们在执行它表示的协程之后安排恢复一些其他代码。因此,Task可以代表一连串的协程执行,每个执行都将数据馈送到下一个。

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

因此,让我们将这些事实放在一起。

如果您是一个创建lambda的函数,那么您(至少一段时间)具有该lambda的prvalue,对吗?您可以自己存储(作为临时变量或堆栈变量),也可以将其传递给其他人。您自己或其他人将在某个时候调用该lambda的operator()。那时,lambda对象必须是 Activity 的功能对象,否则您将面临更大的麻烦。

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

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

因此,存在Task,它代表协程函数的执行。但是,还有lambda对象。这些都是对象,但是它们是单独的对象,具有不同的生存期。

IF#1告诉我们,lambda捕获是成员变量,而C++规则告诉我们,成员的生存期由它所属的对象的生存期决定。 IF#2告诉我们,协程悬挂机制并未保留这些成员变量。 IF#3告诉我们协程的执行受Task约束,Task的执行可能(非常)与初始代码无关。

如果将所有这些放在一起,我们会发现,如果您有一个捕获变量的协程lambda,则被调用的lambda对象必须继续存在,直到Task(或控制持续协程执行的任何内容)完成了协程lambda的操作为止。执行。如果不是,则协程lambda的执行可能会尝试访问生存期已结束的对象的成员变量。

具体如何操作取决于您。

现在,让我们看一下您的示例。

示例1失败的原因很明显。调用协程的代码创建一个代表lambda的临时对象。但是那个临时性立即超出了范围。在tasks执行期间,不做任何努力来确保lambda仍然存在。这意味着,协程程序有可能在其所驻留的lambda对象被销毁后恢复。

那很糟。

示例2实际上同样糟糕。 lambda临时文件在创建co_await后立即被销毁,因此仅在其上进行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);
}

然后代码就可以了。原因是对TaskTask编码会导致当前协程暂停执行,直到func中的最后一件事情完成,并且“最后一件事情”就是func为止。而且由于堆栈对象是通过协程暂停来保留的,因此只要此协程存在,ojit_code就会继续存在。

出于与示例1相同的原因,示例3较差。无论如何使用协程函数的返回值都无所谓。如果在协程完成执行之前销毁了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;
}

关于c++ - C++ 20协程的Lambda生命周期说明,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/60592174/

相关文章:

c++ - 如果使用constexpr,是否可以删除控制流语句?

c++ - 使用范围保护时如何避免警告?

c++ - 如何迭代 long long 的二进制掩码?

C++ 在另一个对象中引用一个对象的当前状态

c++ - 修改内存区域 - 返回 0xCC VC++

c++ - 函数采用对范围而不是 View 的转发引用是否有好处?

c++ - 字符数组排序和去重

c++ - 使用大括号初始化进行赋值的类型推导规则是什么?

c++ - Folly 项目的 Cmake 文件

c++ - 安装 Folly(c++ 库)通过 vcpkg 出错