C++20のコルーチン for アプリケーション


C++20でコルーチンが追加された。
この記事ではアプリケーションユーザ(notライブラリ読者/開発者)向けにC++20のコルーチンについて説明する

C++20のコルーチンの特徴

C++20のコルーチンのめぼしい特徴に以下がある

  • 細かな制御/拡張ポイント
    • 例えば以下のような制御/拡張ポイントがある
      • コルーチンを作成したとき、一番最初の中断点まで進めるか
      • 例外をどう取り扱うか
      • このコルーチンの中断時の挙動はどうするか
  • コールスタックを保存しないことによる高速なコルーチン(stacklessコルーチン)
    • コンパイル時に確保すべきメモリサイズが確定できる & オブジェクト生成時に一括で確保する。
      したがってコルーチン中断時にスタックに積まれたデータなどを動的に退避しなくてよくなる(vs stackfulコルーチン)
    • 欠点は「awaitを呼ぶ(コルーチンではない)関数」のようなことができなくなる
      • ただ、コルーチンからコルーチンを呼ぶことはできるので実用上そこまで問題にはならない

C++のコルーチンに無いもの

  • 「細かな制御/拡張ポイント」のpreset、つまりライブラリ的な部分がほぼ無

でもまぁ、無い部分は自分で書けばいいので、大丈夫だろう。やっていこう

幸い自分で書かなくても cppcoroという便利っぽいライブラリがある
読者がもしコルーチンを使う状況になったとき、そのとき使うコルーチンライブラリがcppcoroなのか、何らかのフレームワークの一部として提供されたものか、それとも標準ライブラリに入ったものなのか、私には知ることができない
しかしcppcoroを使ったサンプルは理解の助けになるだろう

そんなわけでコルーチンやっていこう

文法

ものすごい雑に言うと関数の実装でco_{await,yield,return}を使って戻り値の型に使いたいコルーチン型を指定するとコルーチンになる

サンプル

// コルーチン定義
cppcoro::generator<const std::uint64_t> fibonacci(){
  std::uint64_t a = 0, b = 1;
  while (true){
    co_yield b;
    auto tmp = a;
    a = b;
    b += tmp;
  }
}

//コルーチンを使う
int main(){
    auto g = fibonacci();
    for (auto i : g){
        if (i > 1'000'000) break;
        std::cout << i << std::endl;
    }
}

コルーチン(を返す関数)の作り方

  1. コルーチンで使いたい型(以下 MyCoroutineType)を選ぶ
  2. 関数の戻り値にMyCoroutineTypeを指定するとその関数はコルーチンになる(※)
  3. MyCoroutineTypeのドキュメントを見ながら、コルーチンの実装を書く

となる

// 1. 今回はcppcoroのgeneratorを使って無限フィボナッチ数列を生成するコルーチンを生成してみようと思う
// https://github.com/lewissbaker/cppcoro/tree/69e575480afe97fd342a44f580ba8ff3b4a9a58a#generatort

// 2. 関数の戻り値に使いたい `cppcoro::generator<const std::uint64_t>` を指定する
cppcoro::generator<const std::uint64_t> fibonacci()
// 3. 実装する 
// generatorは pythonにある奴と大体同じ、co_yield に値を渡すとそれをコルーチンの外に渡すやつである
{
  std::uint64_t a = 0, b = 1;
  while (true)
  {
    co_yield b; // b の値が外に渡され、中断される
    auto tmp = a;
    a = b;
    b += tmp;
  }
}

(※) 厳密にはこれは2つの点で間違いである。

  1. 「関数内でco_yieldなどコルーチンのキーワードを使うとコルーチンになる」が正しい
    • コルーチンのキーワードを関数内で使わずにMyCoroutineTypeを指定すると、それはただのMyCoroutineTypeを返す関数になる
    • 逆にコルーチンのキーワードを関数内で使いながら戻り値にコルーチン型を指定しなかった場合、コンパイルエラーとなる
  2. std::coroutine_traitsを特殊化することで、戻り値型だけではなく引数型もコルーチンのルールの決定に加味できるようになる。また、本来コルーチンとして使えない型もコルーチンとして使えるように妥当な実装を与えることができる。面倒なのでここでは紹介しない

コルーチンの使い方

  1. 「コルーチンの作り方」で作った関数を実行するとMyCoroutineTypeのオブジェクトが返る
  2. 使いたい型のドキュメントを見ながら使う
// 1. generator<const std::uint64_t>型が返る
auto g = fibonacci();

// 2. ドキュメントを読んで便利に使おう
// generatorはrangeのように使える
for (auto i : g)
{
    if (i > 1'000'000) break;
    std::cout << i << std::endl;
}

co_await,co_yield,co_return 詳細

コルーチンでは新たに以下の3つのキーワードが登場する

  • co_await
  • co_yield
  • co_return

これらの挙動は使うコルーチン型や引数などによっていろいろカスタマイズ可能であり、「co_awaitはこう動く」みたいなことはここでは言いづらい
「正確な挙動はライブラリのドキュメントを読もう」という感じなのだが
これらについて一般的に言えることを述べる

co_await式

bytesRead = co_await sock.recv(buffer.get(), bufferSize);

コルーチンを中断するやつ。呼び出されるとコルーチンを中断する

一般的にAwaiterと呼ばれる型、もしくはコルーチンを引数にとる
co_awaitの引数の型等によっては再開時に演算子から返り値が返る

co_yield式

yield である。 一般的に呼び出されると外にその値を渡し、コルーチンを中断する
使用するコルーチン等によっては再開時に演算子から返り値が返る

co_return 文

co_return expr;
co_return;

コルーチン版return。直感的にはreturnと同じ挙動をする
つまり、引数を外に返し、コルーチンを終了する

cppcoroのgeneratorとtaskの使い方

最後に cppcoroのコルーチン型を2つ紹介する
この記事の読者がcppcoroを使うかは分からない
しかし具体的なサンプルを見ることでコルーチンの各々がどのように使われるかについて、理解の助けになると思われる

generator

generatorは値を生成する関数の結果をイテレータの組(range)にして返してくれるやつである
pythonやluaにあるgeneratorと同じものだ
https://github.com/lewissbaker/cppcoro/tree/69e575480afe97fd342a44f580ba8ff3b4a9a58a#generatort


cppcoro::generator<int> range(){
    int n = 5000;
    while(1){
        if(n==0) co_return; // n==0になったらコルーチンを脱出する。co_return文は値をとらない
        co_yield n/3;  // 値を渡し
        std::cout<<"ho!!!"<<std::endl;  // 遅延評価されてることを確認するために副作用を起こしてみる
        n/=3;
    }
}

int main(){
    for(auto x: range()){
        std::cout<<"#"<<x<<std::end;
    }
}

cppcoro::generatorでは主にco_yieldをつかう
co_awaitは使わない(使えない)
また、co_returnは値をとらない

動くサンプル: https://wandbox.org/permlink/MYa1qv2grlgsO36i

task

taskはco_awaitを使って非同期処理を見た目同期的にやるためのコルーチンである
https://github.com/lewissbaker/cppcoro/tree/69e575480afe97fd342a44f580ba8ff3b4a9a58a#taskt

cppcoro::task<int> count_lines(std::string path)
{
  auto file = co_await cppcoro::read_only_file::open(path);  // 開くまでコルーチンは再開できない

  int lineCount = 0;

  char buffer[1024];
  size_t bytesRead;
  std::uint64_t offset = 0;
  do
  {
    bytesRead = co_await file.read(offset, buffer, sizeof(buffer));  // 読み終わるまでコルーチンは再開できない
    lineCount += std::count(buffer, buffer + bytesRead, '\n');
    offset += bytesRead;
  } while (bytesRead > 0);

  co_return lineCount; // task<T>ではT型の値を返せる(T=voidを指定すれば返さないこともできる)
}

// taskをcppcoro::sync_wait()等に渡すといい感じに再開処理を実行する

cppcoro::taskでは主にco_awaitを使う
co_awaitは 前述の通り Awaiter と呼ばれる型をとる。Awaiterは次を制御できる

  • このコルーチンは中断すべきか(中断しないことも可能)
  • 中断したとしてどのような副作用(等)をおこすか(例えばメッセージ投げたりとかsleepしたりとか)
  • co_await演算子の戻り値に何を返すか

また、task<>自身もAwaiter(への変換が可能)であるため、co_awaitに渡すことができる(というかそれを主にやっていく)

Awaiterはcppcoroにいくつか定義されている(socketとか)
それらについて説明を始めると本当に大変になってしまう+コルーチンそのものの説明から大きく脱線してしまうので、興味がある方はcppcoroのドキュメントを読んでほしい

参考