这个问题不是 lambda 独有的或特定的;它可能会影响任何同时存储内部状态并且恰好是协程的可调用对象。但是这个问题在制作 lambda 时最容易遇到,所以我们将从这个角度来看。
首先,一些术语。
在 C++ 中,“lambda”是一个对象,而不是一个函数。lambda 对象具有函数调用 operator 的重载operator()
,它调用写入 lambda 主体的代码。这就是 lambda 的全部内容,所以当我随后提到“lambda”时,我指的是 C++ object 而不是 function。
在 C++ 中,作为“协程”是函数的属性,而不是对象。协程是一个从外部看起来与普通函数相同的函数,但它在内部以这样一种方式实现,即它的执行可以暂停。当协程挂起时,执行返回到直接调用/恢复协程的函数。
协程的执行可以在以后恢复(这样做的机制我不会在这里讨论太多)。当一个协程被挂起时,该协程函数中直到协程挂起点的所有堆栈变量都将被保留。这个事实是允许协程恢复工作的原因;这就是使协同程序代码看起来像普通 C++ 的原因,即使执行可能以非常不连贯的方式发生。
协程不是对象,lambda 也不是函数。所以,当我使用看似矛盾的术语“coroutine lambda”时,我真正的意思是一个对象,其operator()
重载恰好是一个协程。
清楚吗?好的。
重要事实 #1:
当编译器计算一个 lambda 表达式时,它会创建一个 lambda 类型的纯右值。此纯右值将(最终)初始化一个对象,通常作为评估相关 lambda 表达式的函数范围内的临时对象。但它可能是一个堆栈变量。它是什么并不重要;重要的是,当您评估 lambda 表达式时,有一个对象在各方面都像任何用户定义类型的常规 C++ 对象。这意味着它有一生。
lambda 表达式“捕获”的值本质上是 lambda 对象的成员变量。它们可以是引用或值;没关系。当您在 lambda 主体中使用捕获名称时,您实际上是在访问 lambda 对象的命名成员变量。并且 lambda 对象中的成员变量规则与任何用户定义对象中的成员变量规则没有什么不同。
重要事实 #2:
协程是一个可以挂起的函数,可以保留其“堆栈值”,以便以后可以恢复执行。出于我们的目的,“堆栈值”包括所有函数参数、在暂停点之前生成的任何临时对象以及在该点之前在函数中声明的任何函数局部变量。
这就是所有被保留的东西。
成员函数可以是协程,但协程挂起机制并不关心成员变量。暂停仅适用于该函数的执行,不适用于该函数周围的对象。
重要事实 #3:
拥有协程的主要目的是能够暂停一个函数的执行,并让该函数的执行由其他一些代码恢复。这可能会在程序的某个不同部分中,并且通常在与最初调用协程的位置不同的线程中。也就是说,如果您创建一个协程,您希望该协程的调用者将继续其执行与您的协程函数的执行并行。如果调用者确实等待您的执行完成,则调用者会根据自己的选择而不是您的选择这样做。
这就是为什么你一开始就让它成为协程。
该folly::coro::Task
对象的重点是本质上跟踪协程的暂停后执行,以及编组由它生成的任何返回值。它还可以允许在它所代表的协程执行之后安排一些其他代码的恢复。所以 aTask
可以代表一长串的协程执行,每个都将数据提供给下一个。
这里的重要事实是协程像普通函数一样从一个地方开始,但它可以在最初调用它的调用堆栈之外的某个时间点结束。
所以,让我们把这些事实放在一起。
如果您是一个创建 lambda 的函数,那么您(至少在一段时间内)拥有该 lambda 的prvalue,对吗?您可以自己存储它(作为临时变量或堆栈变量),也可以将其传递给其他人。您自己或其他人会在某个时候调用该operator()
lambda 的 。那时,lambda 对象必须是一个活的、功能性的对象,否则你手上就有一个更大的问题。
所以 lambda 的直接调用者有一个 lambda 对象,并且 lambda 的函数开始执行。如果它是一个协程 lambda,那么这个协程可能会在某个时候暂停它的执行。这会将程序控制权转移回直接调用者,即保存 lambda 对象的代码。
这就是我们遇到 IF#3 后果的地方。看,lambda 对象的生命周期是由最初调用 lambda 的代码控制的。但是该 lambda 中协程的执行是由一些任意的外部代码控制的。控制此执行的系统是Task
由协程 lambda 的初始执行返回给直接调用者的对象。
所以有Task
which 代表协程函数的执行。但也有 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);
}
然后代码就可以了。原因是co_await
在 a 上 ingTask
会导致当前协程暂停执行,直到完成最后一件事Task
,而“最后一件事”是func
. 并且由于堆栈对象是由协程挂起保留的,func
只要这个协程存在,它就会继续存在。
示例 3 不好,原因与示例 1 相同。如何使用协程函数的返回值无关紧要;如果您在协程完成执行之前销毁 lambda,您的代码将被破坏。
示例 4 在技术上与其他示例一样糟糕。但是,由于 lambda 是无捕获的,它永远不需要访问 lambda 对象的任何成员。它实际上永远不会访问任何生命周期已结束的对象,因此 ASAN 永远不会注意到协程周围的对象已死。是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;
}