簡體   English   中英

C++20 協程的 Lambda 生命周期解釋

[英]Lambda lifetime explanation for C++20 coroutines

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

這個問題不是 lambdas 獨有的或特定的; 它可能會影響任何同時存儲內部狀態並且恰好是協程的可調用對象。 但是這個問題在創建 lambda 時最容易遇到,所以我們將從這個角度來看它。

首先,一些術語。

在 C++ 中,“lambda”是一個對象,而不是一個函數。 lambda 對象具有函數調用運算符operator()的重載,它調用寫入 lambda 主體的代碼。 這就是 lambda 的全部內容,因此當我隨后提到“lambda”時,我指的是 C++ object 而不是 function

在 C++ 中,作為“協程”是函數的屬性,而不是對象。 協程是一個從外部看起來與普通函數相同的函數,但它是在內部實現的,它的執行可以被掛起。 當協程被掛起時,執行返回到直接調用/恢復協程的函數。

協程的執行可以稍后恢復(這樣做的機制不是我要在這里討論的太多內容)。 當一個協程被掛起時,該協程函數內的所有堆棧變量都會被保留,直到協程被掛起為止。 這個事實是允許協程恢復工作的原因; 這就是使協程代碼看起來像普通的 C++ 的原因,即使執行可能以非常不相交的方式發生。

協程不是對象,而 lambda 不是函數。 所以,當我使用看似矛盾的術語“協程 lambda”時,我真正的意思是一個對象,其operator()重載恰好是一個協程。

清楚嗎? 好的。

重要事實#1:

當編譯器計算一個 lambda 表達式時,它會創建一個 lambda 類型的純右值。 這個純右值將(最終)初始化一個對象,通常作為評估相關 lambda 表達式的函數范圍內的臨時對象。 但它可能是一個堆棧變量。 它是什么並不重要; 重要的是,當您評估 lambda 表達式時,有一個對象在各方面都類似於任何用戶定義類型的常規 C++ 對象。 這意味着它有一個生命周期。

由 lambda 表達式“捕獲”的值本質上是 lambda 對象的成員變量。 它們可以是引用或值; 沒關系。 當您在 lambda 主體中使用捕獲名稱時,您實際上是在訪問 lambda 對象的命名成員變量。 並且 lambda 對象中的成員變量規則與任何用戶定義的對象中的成員變量規則沒有什么不同。

重要事實#2:

協程是一個可以掛起的函數,可以保留其“堆棧值”,以便稍后恢復執行。 就我們的目的而言,“堆棧值”包括所有函數參數、在暫停點之前生成的任何臨時對象,以及在該點之前在函數中聲明的任何函數局部變量。

這就是所有被保存下來的東西。

成員函數可以是協程,但是協程掛起機制並不關心成員變量 暫停僅適用於該函數的執行,不適用於該函數周圍的對象。

重要事實#3:

擁有協程的主要目的是能夠暫停函數的執行並讓該函數的執行由其他一些代碼恢復。 這可能位於程序的某個不同部分,並且通常位於與最初調用協程的位置不同的線程中。 也就是說,如果您創建一個協程,您希望該協程的調用者將繼續與您的協程函數的執行並行執行。 如果調用者確實在等待您的執行完成,則調用者會選擇這樣做,而不是您的選擇

這就是為什么你一開始就把它變成了一個協程。

folly::coro::Task對象的重點是本質上跟蹤協程的暫停后執行,以及編組它生成的任何返回值。 它還可以允許在執行它所代表的協程之后安排一些其他代碼的恢復。 因此,一個Task可以代表一長串協程執行,每個協程向下一個提供數據。

這里的重要事實是協程像普通函數一樣從一個地方開始,但它可以在最初調用它的調用堆棧之外的其他時間點結束。

所以,讓我們把這些事實放在一起。

如果您是一個創建 lambda 的函數,那么您(至少在一段時間內)擁有該 lambda 的純右值,對嗎? 您要么自己存儲它(作為臨時變量或堆棧變量),要么將其傳遞給其他人。 您自己或其他人將在某個時候調用該 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 對象必須繼續存在,直到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);
}

那么代碼就好了。 原因是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;
}

如果您有自定義承諾類型,或者您的承諾可以在任務完成后將工作排隊運行,這是一種解決方法。

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

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM