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;
}
}
コルーチン(を返す関数)の作り方
- コルーチンで使いたい型(以下 MyCoroutineType)を選ぶ
- 関数の戻り値にMyCoroutineTypeを指定するとその関数はコルーチンになる(※)
- 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つの点で間違いである。
- 「関数内でco_yieldなどコルーチンのキーワードを使うとコルーチンになる」が正しい
- コルーチンのキーワードを関数内で使わずにMyCoroutineTypeを指定すると、それはただのMyCoroutineTypeを返す関数になる
- 逆にコルーチンのキーワードを関数内で使いながら戻り値にコルーチン型を指定しなかった場合、コンパイルエラーとなる
- std::coroutine_traitsを特殊化することで、戻り値型だけではなく引数型もコルーチンのルールの決定に加味できるようになる。また、本来コルーチンとして使えない型もコルーチンとして使えるように妥当な実装を与えることができる。面倒なのでここでは紹介しない
コルーチンの使い方
- 「コルーチンの作り方」で作った関数を実行するとMyCoroutineTypeのオブジェクトが返る
- 使いたい型のドキュメントを見ながら使う
// 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のドキュメントを読んでほしい