Skip to content
Go back

C++20 Coroutines

· Updated:

C++20のコルーチンについて個人的にまとめたものです。内容には間違いが含まれている場合があります。

概要

  • コルーチンとは「中断suspendしたり再開resumeしたりできる関数」のこと
  • C++20のコルーチンは”スタックレス”
    • 中断時のスタックの状態を保存しない方式
  • 以下を満たす関数がコルーチンになる
    • 以下のいずれかを含む:
      • co_await:式の評価が完了するまで関数を中断する
      • co_return:関数を終了して、戻り値を返す
      • co_yield:関数を中断して、戻り値を返す
    • 戻り値の型と引数の型からstd::coroutine_traits<>を介してPromise型を取得できる
  • コルーチンのステートは(基本的には)動的に割り当てられる
    • Promiseオブジェクト、コルーチンのパラメータ、中断地点の情報、中断時に有効なローカル変数が保持される
    • ただし、ステートの寿命が呼び出し元の寿命よりも短く、割当サイズが呼び出し側でわかっていれば、スタック上に割り当てられる

構成

  • Promise:
    • 内側からのコルーチン操作に用いるユーザー定義クラス
    • 以下のメンバ関数を定義する:
      • コンストラクタ:
        • コルーチンのパラメータをすべて受け取るコンストラクタか、デフォルトコンストラクタのいずれかがあれば良い
      • get_return_object()
        • コルーチンの戻り値を作る
        • 基本は、std::coroutine_handle<Promise>::from_promise(*this)でハンドルを返す
      • (任意)static get_return_object_on_allocation_failure()
        • コルーチンステートの割当に失敗したときのコルーチンの戻り値を作る
        • これが定義されていると、nothrow版のnewで割当を行うようになる
      • initial_suspend()
        • 初期化後に呼び出し元に戻るときに呼び出される
        • この戻り値がco_awaitされる
      • final_suspend()
        • 終了前に呼び出し元に戻るときに呼び出される
        • この戻り値がco_awaitされる
      • yield_value(expr)
        • co_yieldされた値を引数にして呼び出される
        • この戻り値がco_awaitされる
      • return_void(), return_value(expr)
        • co_returnされた値を引数にして呼び出される
      • (任意)unhandled_exception()
        • コルーチン内で例外がキャッチされなかったときに、代わりにシステムがキャッチしてこれを呼び出す
      • (任意)await_transform(expr)
        • co_awaitされた値をawaitableに変換する
        • これが定義されていなければ、co_awaitされた値がそのままawaitableとして扱われる
      • (任意)operator new()
        • これが定義されていれば、ステートを格納するためのメモリを割り当てるときに呼び出される
        • size_tとコルーチンのすべてのパラメータを引数に取ることもできる
  • Awaitable:
    • co_awaitされた値から変換された中間的なオブジェクト
    • awaitable.operator co_await()operator co_await(Awaitable&&)でAwaiterに変換される
      • これらが定義されていなければ、awaitableがそのままawaiterとして扱われる
  • Awaiter:
    • 中断の挙動を定義するユーザー定義クラス
    • 以下のメンバ関数が定義される:
      • await_ready()
        • 中断前に呼び出され、中断状態に入るか判断する
        • trueを返すと、そのまま再開後処理に合流する
        • falseを返すと、中断状態に移行する
      • await_suspend(handle)
        • 中断後に呼び出され、制御を返す先を決定する
        • 戻り値がvoidtrueの場合、呼び出し元に制御を返す
        • 戻り値がfalseの場合、現在のコルーチンを再開する
        • 戻り値がハンドルの場合、そのハンドルのコルーチンを再開する
        • 例外を投げると、現在のコルーチンを再開して、そちらでrethrowする
      • await_resume()
        • 再開後に呼び出され、その戻り値がco_awaitの結果として渡される
    • 定義済みのAwaiterとして、常に中断するstd::suspend_alwaysと常に中断しないstd::suspend_neverがある
  • コルーチンの戻り値の型:
    • 外側からのコルーチン操作に用いるユーザー定義クラス
    • std::coroutine_traits<>で対応するPromise型を取得できる必要がある
      • クラス内にpromise_typeという型を定義するか、std::coroutine_traits<>を特殊化するか
    • 基本は、std::coroutine_handle<>を保持して、コルーチンの実行状態を管理する

動作

  • 開始時:
    • ステートをnewする
      • Promise::operator new()Promise::operator new(size_t, Args...)::operator new()のいずれかを呼ぶ
        • Argsはコルーチンの引数すべてと一致する必要がある
      • Promise::get_return_object_on_allocation_failure()を定義していれば、代わりにnothrow版を使う - 失敗時、この関数を呼び、その戻り値とともに呼び出し元に返る
    • コルーチンの引数をステートにコピー・ムーブする
      • 参照は参照のまま維持される
    • Promiseをステート上に構築する
      • Promise::Promise(Args...)Promise::Promise()のいずれかを呼ぶ
        • Argsはコルーチンの引数すべてと一致する必要がある
        • 渡される引数はステート上にある
    • promise.get_return_object()を呼ぶ
      • 初回中断時、この戻り値とともに呼び出し元に返る
    • promise.initial_suspend()を呼び、その戻り値をco_awaitする
  • co_await時:
    • exprをawaitableに変換する
      • promise.await_transform(expr)があれば呼ぶ
      • なければ、exprがそのままawaitableになる
    • awaitableをawaiterに変換する
      • awaitable.operator co_await()operator co_await(Awaitable&&)のいずれかがあれば呼ぶ
      • なければ、awaitableがそのままawaiterになる
    • awaiter.await_ready()を呼ぶ
      • 戻り値がtrueなら、awaiter.await_resume()を呼び、その戻り値とともにco_awaitに返る
      • 戻り値がfalseなら、現在のコルーチンを中断する
    • awaiter.await_suspend(handle)を呼ぶ
      • handleは現在のコルーチンを指すハンドル
      • 戻り値がvoidtrueなら、呼び出し元に制御を返す
      • 戻り値がfalseなら、現在のコルーチンを再開し、awaiter.await_resume()を呼び、その戻り値とともにco_awaitに返る
      • 戻り値がハンドルなら、そのハンドルの指すコルーチンを再開する
      • 例外を投げると、現在のコルーチンを再開し、その中でrethrowする
    • コルーチンを再開すると、awaiter.await_resume()を呼び、その戻り値とともにco_awaitに返る
  • co_yield時:
    • promise.yield_value(expr)を呼び、その戻り値をco_awaitする
  • co_return時:
    • promise.return_void()promise.return_value(expr)のいずれかを呼ぶ
    • promise.final_suspend()を呼び、その戻り値をco_awaitする
      • ここで中断した場合、このコルーチンを再開することは未定義動作になるので、呼び出し元でハンドルをdestroyして処分する必要がある
    • ステートをdeleteし、呼び出し元に制御を返す
  • 例外がキャッチされなかった場合:
    • 代わりにシステムがキャッチして、promise.unhandled_exception()を呼び出す
    • ステートをdeleteし、呼び出し元に制御を返す

注意点

  • コルーチンのパラメータは参照を参照のまま保持するので、コルーチンが動いている間に参照先の変数の寿命を切らさないようにしなければならない
    • std::threadのように、参照渡しした引数をdecay-copyしたりしない
  • クラスのメンバー関数をコルーチンにする場合には、コルーチンが動いている間にそのインスタンスの寿命を切らさないようにしなければならない
    • メンバー関数をコルーチンにしても、そのインスタンスがコルーチンステートに保存されたりしない
    • クロージャーをコルーチンにする場合も同様に、そのクロージャーオブジェクトの寿命に注意しなければならない
  • final_suspend()で中断しなかった場合、呼び出し元はそのハンドルを操作してはいけない
    • 呼び出し元に制御が戻された時点でステートは破棄されている
    • 残されたハンドルからコルーチンが完了したかを知るすべはない
  • final_suspend()で中断した場合、そのコルーチンを再開してはいけない
    • この状態のコルーチンを再開することは未定義動作になる
    • 呼び出し元はpromise経由で処理結果を受け取るなどしたら、handle.destroy()で速やかに破棄する必要がある

参考文献