yohhoyの日記

技術的メモをしていきたい日記

コルーチン×ラムダ式キャプチャ=鼻から悪魔

C++20 コルーチンとキャプチャありラムダ式の組合せは、キャプチャ変数の生存期間(lifetime)切れによる未定義動作(undefined behavior)を引き起こすリスクが高く、原則として併用すべきでない。

要約:ラムダ式でキャプチャした変数はコルーチン中断時にコルーチンフレーム*1に退避されない。キャプチャ変数はコルーチンフレームに退避されない。大事なことなので二回言いました。

C++20現在は標準コルーチンライブラリを提供しないため*2、ここでは次期C++2b(C++23)向け提案(PDF)P2168R3std::generator<T>を用いたコード例示を行う。
2022-08-01追記:C++2b向けに後継提案文書(PDF)P2502R2が採択され、<generator>ヘッダとstd::generator導入が決定している。id:yohhoy:20220801 参照。

#include <iostream>
#include <generator>  // P2168R3

// NG: キャプチャありコルーチンラムダ式
void f(int n)
{
  // (1) co_yield式により クロージャ型::operator() はコルーチンとなる。
  // またラムダ式内部からは「関数fのローカル変数n」へは直接アクセスできず、
  // 「クロージャオブジェクトのメンバ変数として保持されたn」に自動変換される。
  auto gen = [n]() -> std::generator<int> {
    // (3) std::generatorのpromise_type定義よりコルーチン本体実行前の
    // 初期サスペンドポイント(initial_suspend)にて実行を中断する。

    // (6) 「メンバ変数として保持されたn」は生存期間が終了しているため、
    // 下記for文において変数名nへのアクセスが未定義動作を引き起こす!
    for (int i = 0; i < n; i++) {
      co_yield i;
    }
  }();
  // (2) IILE(Immediately-Invoked Lambda Expression)イディオムを用いて、
  // ローカル変数nをキャプチャしたクロージャオブジェクトを即時評価する。

  // (4) この時点でラムダ式によるクロージャオブジェクトは破棄済みとなり、
  // 同メンバ変数として保持されたnの生存期間もまた終了している。

  // (5) 範囲for文によりgen.begin()が呼び出されてコルーチン再開する。
  for (int i: gen) {
    std::cout << i;
  }
}

// OK: キャプチャ無しコルーチンラムダ式
void g(int n)
{
  // (1) 変数キャプチャでなはく引数ありラムダ式として定義する。
  // ラムダ式内部では「コルーチンフレームで保持されるn」へアクセスする。
  auto gen = [](int n) -> std::generator<int> {
    // (3) 初期サスペンドポイント(initial_suspend)にて中断される。
    // 引数nはコピーされてコルーチンフレーム上に格納される。

    // (6) コルーチンフレームで保持されるnへアクセスする。
    for (int i = 0; i < n; i++) {
      co_yield i;
    }
  }(n);
  // (2) IILEイディオムにより変数nを実引数として渡す。

  // (4) この時点でラムダ式によるクロージャオブジェクトは破棄済み。

  // (5) 範囲for文によりgen.begin()が呼び出されてコルーチン再開する。
  for (int i: gen) {
    std::cout << i;
  }
}

ラムダ式キャプチャに関するこの振る舞いは、キャプチャ動作はラムダ式によるクロージャオブジェクト生成時に1回だけである一方、コルーチンラムダ式クロージャ型のoperator())は複数回呼び出されうる*3という差異に起因する。例えば Move-only 型を前者でムーブキャプチャした場合、後者で同オブジェクトをコルーチンフレームへとコピー退避することができない。*4

メモ:次期C++2b(C++23)で導入されるP0847R7 "Deducing this" 機能(→id:yohhoy:20211025)を使っても、本問題は未解決もしくは仕様規定が曖昧に思える。*5
2022-10-05追記:根本的な解決策ではないがReddit関連スレッドにて、[n](this auto self){ ... }ラムダ式中でnの代わりにself.nと記述する方法が紹介されている。

例えば facebook/folly C++ライブラリでは、キャプチャありコルーチンラムダ式を安全に呼び出すためのfolly::coro::co_invokeヘルパ関数を実験的に提供する。*6

Lambdas
You can implement a lambda coroutine however you need to explicitly specify a return type - the compiler is not yet able to deduce the return type of a coroutine from the body.

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.

Use the folly::coro::co_invoke() helper when immediately invoking a lambda coroutine to keep the lambda alive as long as the Task.

https://github.com/facebook/folly/blob/main/folly/experimental/coro/README.md#lambdas

関連URL

*1:C++言語仕様上はコルーチンフレーム(coroutine frame)ではなく coroutine state が正式名称。本文中ではより一般的な “コルーチンフレーム” を用いた。

*2:C++20時点では言語仕様へのコルーチン導入と、コルーチンライブラリ作成者向けの低レイヤ・ライブラリのみが提供される。

*3:本文中コードはIILEイディオムによりコルーチンは1回しか呼び出されないが、C++構文上で1回のみ呼び出し/複数回呼び出しの可能性を判別できない。例えばRust言語では型システムにより両者を判別可能。

*4:https://www.reddit.com/r/cpp/comments/qmes0e/a_capturing_lambda_can_be_a_coroutine_but_you/hj9b352/

*5:https://twitter.com/yohhoy/status/1458091760777900037

*6:https://github.com/facebook/folly/blob/main/folly/experimental/coro/Invoke.h