11

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()));
}

我用地址清理器编译了以下内容,看看是否会有任何悬空引用。

编辑:澄清问题

问题:为什么第二个示例没有触发 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;
}

没有错误——为什么?

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;
}
4

2 回答 2

23

这个问题不是 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 的初始执行返回给直接调用者的对象。

所以有Taskwhich 代表协程函数的执行。但也有 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;
}
于 2020-03-09T15:50:25.180 回答
0

如果您有自定义承诺类型,这是一种解决方法,或者您的承诺可以在任务完成后排队运行。

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 var 的自定义方法示例(可能因您的承诺类型而异):

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
})
于 2021-08-03T03:50:27.390 回答