C++20协程的Lambda生命周期解释
Lambda lifetime explanation for C++20 coroutines
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()));
}
我用地址清理器编译了以下内容,看看是否有任何悬空引用。
编辑:澄清问题
问题:为什么第二个例子没有触发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();
}
NO ERROR ...并且为了更好的衡量,只需返回一个常量(未捕获 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++ 中,"coroutine" 是 函数 的 属性,不是对象。协程是一种函数,从外部看起来与普通函数相同,但在内部以可以暂停其执行的方式实现。当协程挂起时,执行return直接invoked/resumed协程的函数。
协程的执行可以稍后恢复(这样做的机制不是我要在这里讨论的内容)。当一个协程被挂起时,该协程函数内直到协程挂起点的所有堆栈变量都会被保留。这个事实允许协程恢复工作;这就是使协程代码看起来像普通 C++ 的原因,即使执行可能以非常不相交的方式发生。
协程不是对象,lambda 也不是函数。所以,当我使用看似矛盾的术语 "coroutine lambda" 时,我 实际上 的意思是一个对象,其 operator()
重载恰好是协程。
我们清楚了吗?好的。
重要事实 #1:
当编译器计算 lambda 表达式时,它会创建一个 lambda 类型的纯右值。这个 prvalue 将(最终)初始化一个对象,通常作为评估相关 lambda 表达式的函数范围内的临时对象。但它可能是一个堆栈变量。它是什么并不重要;重要的是,当你评估一个 lambda 表达式时,有一个对象在任何方面都像任何用户定义类型的常规 C++ 对象。这意味着它有一个生命周期。
lambda表达式的值"captured"本质上是lambda对象的成员变量。它们可以是引用或值;这并不重要。当您在 lambda 主体中使用捕获名称时,您实际上是在访问 lambda 对象的命名成员变量。并且lambda对象中关于成员变量的规则与任何用户定义对象中关于成员变量的规则没有区别。
重要事实#2:
协程是一种可以暂停的函数,其 "stack values" 可以保留,以便稍后可以恢复执行。出于我们的目的,"stack values" 包括所有函数参数、在暂停点之前生成的任何临时对象,以及在该点之前在函数中声明的任何函数局部变量。
而 这就是所有 被保留的内容。
成员函数可以是协程,但是协程挂起机制不关心成员变量。暂停仅适用于该功能的执行,不适用于该功能周围的对象。
重要事实 #3:
拥有协程的要点是能够暂停函数的执行并让该函数的执行由其他代码恢复。这可能会出现在程序的某个不同部分,并且通常出现在与 最初 调用协程的地方不同的线程中。也就是说,如果您创建一个协程,您希望该协程的调用者将继续并行执行您的协程函数。如果调用者确实等待您的执行完成,则调用者选择,而不是您的选择。
这就是 为什么 你一开始就把它设为协程。
folly::coro::Task
对象的要点是本质上跟踪协程的 post 暂停执行,以及编组它生成的任何 return 值。它还可以允许在执行它所代表的协程之后安排一些其他代码的恢复。因此 Task
可以代表一长串协程执行,每个执行都将数据传递给下一个。
这里的重要事实是协程像普通函数一样从一个地方开始,但它可以在最初调用它的调用堆栈外部 的某个其他时间点结束.
所以,让我们把这些事实放在一起。
如果您是创建 lambda 的函数,那么您(至少在一段时间内)拥有该 lambda 的纯右值,对吧?您要么自己存储它(作为临时变量或堆栈变量),要么将其传递给其他人。您自己或其他人将在某个时候调用该 lambda 的 operator()
。那时,lambda 对象必须是一个活动的函数对象,否则你手上的问题就大得多了。
所以 lambda 的直接调用者有一个 lambda 对象,并且 lambda 的函数开始执行。如果它是协程 lambda,那么这个协程可能会在某个时候暂停执行。这会将程序控制权转移回直接调用者,即保存 lambda 对象的代码。
这就是我们遇到 IF#3 的后果的地方。看,lambda 对象的生命周期由最初调用 lambda 的代码控制。但是协程 within 的执行 lambda 是由一些任意的外部代码控制的。管理此执行的系统是 Task
对象 return 通过协程 lambda 的初始执行发送给直接调用者。
所以有Task
代表协程函数的执行。但还有 lambda 对象。这些都是对象,但它们是独立的对象,具有不同的生命周期。
IF#1 告诉我们,lambda 捕获是成员变量,C++ 的规则告诉我们,成员的生命周期受其所属对象的生命周期支配。 IF#2告诉我们协程挂起机制并没有保留这些成员变量。 IF#3 告诉我们协程执行受 Task
控制,其执行可能与初始代码(非常)无关。
如果你把这些放在一起,我们发现,如果你有一个捕获变量的协程 lambda,那么被调用的 lambda 对象 必须 继续存在直到Task
(或控制协程持续执行的任何内容)已完成协程 lambda 的执行。如果没有,则协程 lambda 的执行可能会尝试访问生命周期已结束的对象的成员变量。
具体怎么做取决于您。
现在,让我们看看您的示例。
示例 1 因显而易见的原因而失败。调用协程的代码会创建一个表示 lambda 的临时对象。但是那个临时的会立即超出范围。没有努力确保在 Task
执行时 lambda 仍然存在。这意味着协程可以在其所在的 lambda 对象被销毁后恢复。
太糟糕了。
示例 2 实际上同样糟糕。 lambda 临时变量在创建 tasks
后立即被销毁,因此仅仅 co_await
ing 应该无关紧要。然而,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
ing 在 Task
上导致当前协程暂停执行,直到 Task
中的最后一件事完成,并且 "last thing" 是 func
.并且由于协程挂起会保留堆栈对象,所以 func
只要这个协程存在,就会继续存在。
示例 3 与示例 1 相同的原因是错误的。您如何使用协程函数的 return 值并不重要;如果您在协程完成执行之前销毁 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;
}
如果您有自定义 promise 类型,这里有一个解决方法,或者您的 promise 可以在任务完成后将工作排队到 运行。
auto coLambda(auto&& executor) {
return [executor=std::move(executor)]<typename ...Args>(Args&&... args) {
using ReturnType = decltype(executor(args...));
// copy the lambda into a new std::function pointer
auto exec = new std::function<ReturnType(Args...)>(executor);
// execute the lambda and save the result
auto result = (*exec)(args...);
// call custom method to save lambda until task ends
coCaptureVar(result, exec);
return result;
};
}
保存 lambda 变量的自定义方法示例(可能因您的承诺类型而异):
template<typename T>
void coCaptureVar(Task<T> task, auto* var) {
task.finally([var]() {
delete var;
});
}
用法:
// just wrap your lambda in coLambda
coLambda([=]() -> Task<T> {
// ...
// you're free to use captured variables as needed, even if coroutine suspends
})
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()));
}
我用地址清理器编译了以下内容,看看是否有任何悬空引用。
编辑:澄清问题
问题:为什么第二个例子没有触发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();
}
NO ERROR ...并且为了更好的衡量,只需返回一个常量(未捕获 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++ 中,"coroutine" 是 函数 的 属性,不是对象。协程是一种函数,从外部看起来与普通函数相同,但在内部以可以暂停其执行的方式实现。当协程挂起时,执行return直接invoked/resumed协程的函数。
协程的执行可以稍后恢复(这样做的机制不是我要在这里讨论的内容)。当一个协程被挂起时,该协程函数内直到协程挂起点的所有堆栈变量都会被保留。这个事实允许协程恢复工作;这就是使协程代码看起来像普通 C++ 的原因,即使执行可能以非常不相交的方式发生。
协程不是对象,lambda 也不是函数。所以,当我使用看似矛盾的术语 "coroutine lambda" 时,我 实际上 的意思是一个对象,其 operator()
重载恰好是协程。
我们清楚了吗?好的。
重要事实 #1:
当编译器计算 lambda 表达式时,它会创建一个 lambda 类型的纯右值。这个 prvalue 将(最终)初始化一个对象,通常作为评估相关 lambda 表达式的函数范围内的临时对象。但它可能是一个堆栈变量。它是什么并不重要;重要的是,当你评估一个 lambda 表达式时,有一个对象在任何方面都像任何用户定义类型的常规 C++ 对象。这意味着它有一个生命周期。
lambda表达式的值"captured"本质上是lambda对象的成员变量。它们可以是引用或值;这并不重要。当您在 lambda 主体中使用捕获名称时,您实际上是在访问 lambda 对象的命名成员变量。并且lambda对象中关于成员变量的规则与任何用户定义对象中关于成员变量的规则没有区别。
重要事实#2:
协程是一种可以暂停的函数,其 "stack values" 可以保留,以便稍后可以恢复执行。出于我们的目的,"stack values" 包括所有函数参数、在暂停点之前生成的任何临时对象,以及在该点之前在函数中声明的任何函数局部变量。
而 这就是所有 被保留的内容。
成员函数可以是协程,但是协程挂起机制不关心成员变量。暂停仅适用于该功能的执行,不适用于该功能周围的对象。
重要事实 #3:
拥有协程的要点是能够暂停函数的执行并让该函数的执行由其他代码恢复。这可能会出现在程序的某个不同部分,并且通常出现在与 最初 调用协程的地方不同的线程中。也就是说,如果您创建一个协程,您希望该协程的调用者将继续并行执行您的协程函数。如果调用者确实等待您的执行完成,则调用者选择,而不是您的选择。
这就是 为什么 你一开始就把它设为协程。
folly::coro::Task
对象的要点是本质上跟踪协程的 post 暂停执行,以及编组它生成的任何 return 值。它还可以允许在执行它所代表的协程之后安排一些其他代码的恢复。因此 Task
可以代表一长串协程执行,每个执行都将数据传递给下一个。
这里的重要事实是协程像普通函数一样从一个地方开始,但它可以在最初调用它的调用堆栈外部 的某个其他时间点结束.
所以,让我们把这些事实放在一起。
如果您是创建 lambda 的函数,那么您(至少在一段时间内)拥有该 lambda 的纯右值,对吧?您要么自己存储它(作为临时变量或堆栈变量),要么将其传递给其他人。您自己或其他人将在某个时候调用该 lambda 的 operator()
。那时,lambda 对象必须是一个活动的函数对象,否则你手上的问题就大得多了。
所以 lambda 的直接调用者有一个 lambda 对象,并且 lambda 的函数开始执行。如果它是协程 lambda,那么这个协程可能会在某个时候暂停执行。这会将程序控制权转移回直接调用者,即保存 lambda 对象的代码。
这就是我们遇到 IF#3 的后果的地方。看,lambda 对象的生命周期由最初调用 lambda 的代码控制。但是协程 within 的执行 lambda 是由一些任意的外部代码控制的。管理此执行的系统是 Task
对象 return 通过协程 lambda 的初始执行发送给直接调用者。
所以有Task
代表协程函数的执行。但还有 lambda 对象。这些都是对象,但它们是独立的对象,具有不同的生命周期。
IF#1 告诉我们,lambda 捕获是成员变量,C++ 的规则告诉我们,成员的生命周期受其所属对象的生命周期支配。 IF#2告诉我们协程挂起机制并没有保留这些成员变量。 IF#3 告诉我们协程执行受 Task
控制,其执行可能与初始代码(非常)无关。
如果你把这些放在一起,我们发现,如果你有一个捕获变量的协程 lambda,那么被调用的 lambda 对象 必须 继续存在直到Task
(或控制协程持续执行的任何内容)已完成协程 lambda 的执行。如果没有,则协程 lambda 的执行可能会尝试访问生命周期已结束的对象的成员变量。
具体怎么做取决于您。
现在,让我们看看您的示例。
示例 1 因显而易见的原因而失败。调用协程的代码会创建一个表示 lambda 的临时对象。但是那个临时的会立即超出范围。没有努力确保在 Task
执行时 lambda 仍然存在。这意味着协程可以在其所在的 lambda 对象被销毁后恢复。
太糟糕了。
示例 2 实际上同样糟糕。 lambda 临时变量在创建 tasks
后立即被销毁,因此仅仅 co_await
ing 应该无关紧要。然而,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
ing 在 Task
上导致当前协程暂停执行,直到 Task
中的最后一件事完成,并且 "last thing" 是 func
.并且由于协程挂起会保留堆栈对象,所以 func
只要这个协程存在,就会继续存在。
示例 3 与示例 1 相同的原因是错误的。您如何使用协程函数的 return 值并不重要;如果您在协程完成执行之前销毁 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;
}
如果您有自定义 promise 类型,这里有一个解决方法,或者您的 promise 可以在任务完成后将工作排队到 运行。
auto coLambda(auto&& executor) {
return [executor=std::move(executor)]<typename ...Args>(Args&&... args) {
using ReturnType = decltype(executor(args...));
// copy the lambda into a new std::function pointer
auto exec = new std::function<ReturnType(Args...)>(executor);
// execute the lambda and save the result
auto result = (*exec)(args...);
// call custom method to save lambda until task ends
coCaptureVar(result, exec);
return result;
};
}
保存 lambda 变量的自定义方法示例(可能因您的承诺类型而异):
template<typename T>
void coCaptureVar(Task<T> task, auto* var) {
task.finally([var]() {
delete var;
});
}
用法:
// just wrap your lambda in coLambda
coLambda([=]() -> Task<T> {
// ...
// you're free to use captured variables as needed, even if coroutine suspends
})