LoginSignup
47
37

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-06-25

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

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

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

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

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

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

:innocent:

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

幸い自分で書かなくても 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のドキュメントを読んでほしい

参考

47
37
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
47
37