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

13

Folly拥有一个可用的C++20风格协程库。

在Readme中,它声称:

重要提示:您需要非常小心临时lambda对象的生命周期。调用lambda协程会返回一个捕获对lambda的引用的folly::coro::Task,因此,如果未立即对返回的Task进行co_await,则当临时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,其中expr具有非void类型,返回promise.return_value(expr)
  • 按相反的顺序销毁所有自动存储期的变量。
  • 调用promise.final_suspend()并等待结果。

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

1
auto task = [i=1]() -> folly::coro::Task<int> { co_await something; co_return i; } 会触发UAF。 - Raymond Chen
我不介意被踩为反馈,但我不确定为什么会被踩。问题太模糊了吗?研究不够? - Mike Lui
C++社区... - DejanLekic
2个回答

27

这个问题并不是特定于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对象必须是一个活动的,可用的对象,否则你将遇到更大的问题。
因此,lambda的直接调用者拥有一个lambda对象,并且lambda的函数开始执行。如果它是一个协程lambda,那么这个协程可能在某个时间点暂停执行。这将程序控制权转移到了直接调用者,即持有lambda对象的代码。
这就是我们遇到IF#3的后果的地方。你看,lambda对象的生命周期由最初调用lambda的代码控制。但是,在该lambda内部执行的协程的执行受某些任意的外部代码控制。管理这个执行的系统是通过初始执行协程lambda时返回给直接调用者的Task对象来实现的。
因此,有表示协程函数执行的Task,还有lambda对象。它们都是对象,但它们是独立的对象,具有不同的生命周期。
IF#1告诉我们,lambda捕获是成员变量,而C++的规则告诉我们,成员的生命周期由它所属的对象的生命周期控制。IF#2告诉我们,这些成员变量不会被协程挂起机制保留。IF#3告诉我们,协程执行由Task控制,其执行可能(非常)与初始代码无关。
如果将所有这些内容结合在一起,我们发现,如果您有一个捕获变量的协程lambda,那么调用该lambda的lambda对象必须继续存在,直到Task(或任何控制继续协程执行的东西)完成协程lambda的执行。如果没有,那么协程lambda的执行可能尝试访问生命周期已经结束的对象的成员变量。
如何确保这一点取决于您。
现在,让我们看一下您的示例。
示例1因明显原因而失败。调用协程的代码创建了一个表示lambda的临时对象。但是这个临时对象会立即超出作用域。没有努力确保lambda在Task执行时仍然存在。这意味着在lambda对象被销毁后,协程可能会恢复执行。
那很糟糕。
实例2实际上也同样糟糕。在创建tasks之后,lambda临时对象立即被销毁,因此仅仅使用co_await
Task<int> foo() {
  auto func = [i=1]() -> folly::coro::Task<int> {
      co_return i;
  };

  auto task = func();

  co_return co_await std::move(task);
}

然后代码就没问题了。原因在于,在 Task 上进行 co_await 会导致当前协程挂起其执行,直到 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;
}

1
@MikeLui:立即调用lambda与您在示例中所做的完全相同 - Nicol Bolas
1
限制成员函数协程的一种方法是保留它们隐式的 this 参数在协程状态中,而不是它所引用的对象(当然,该对象已经有其自己无关的存储和生命周期)。 - Davis Herring
2
你为什么认为最后一个例子存在未定义行为呢?我们可以delete this;,所以在死亡对象的成员函数中“存在”是可以的;成员函数在lambda被销毁之前(最初)被调用,我没有看到任何规则表明恢复这样的函数是不好的。 - Davis Herring
@lufinkey:它并没有什么不同,但它可以像其他指针/引用一样悬挂。如果需要,可以捕获*this - Davis Herring
1
捕获的引用是否总是被复制到协程本身?当我查看 C++ 文档时,发现它说:“对于通过引用捕获的实体,未指定是否在闭包类型中声明其他未命名的非静态数据成员。”这意味着捕获的引用可能被复制到协程本身,但它们也可能是 lambda 对象的成员。如果 lambda 超出范围,则闭包类型上对引用成员的引用将指向已释放的内存。 - lufinkey
显示剩余10条评论

0

如果您有自定义的 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 变量的自定义方法示例(根据您的 Promise 类型可能会有所不同):

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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接