Edited at

C++ でコルーチン (async/await 準備編)


はじめに

C# で 初めて (だったと思う) 導入された async/await 構文 (やそれに類似する仕組み) はその後様々なプログラミング言語にも導入されてきて、 async/await が使えることが言語選択の一つの基準になってきているような感じすらします。

(※2019/5/9) コメントでご指摘いただきましたが F# の方が先だったようです。

C++ にも C++20 でコルーチン及び async/await が正式に導入されることになっているようですが、最近のコンパイラは割と実装済だったりするようなので今から触っておいて理解を深めておこうという気持ちで始めてみたので、ここに整理しておこうと思います。

当初は 1 記事で全てまとめようと思ったのですが、ちょっと長めになりそうだったのでコルーチン編と async/await 編の 2 回に分けようと思います。

今回参考にさせていただいた記事は以下のものです。ありがとうございます。

C++ のコルーチン関係は結構歴史が長く、予約語や構文は最終的に落ち着くまで変化してきていますが、考え方があんまり変わっていない事もあり、古い記事も意外と参考になったりします (直接的には使えないので注意が必要ですが) 。

ドラフトはこちら。

今回は今のところ Visual Studio 2017 (15.8) での確認で書いていますが、他コンパイラも追って確認はしたいと思います。ほぼ落ち着いているとは思いますが、まだ expermintal なので最終的にどうなるかはわかりませんが、ご了承ください。


Visual Studio 2017 での準備

VS2017 ではコンパイラのオプションで /await を指定しないとコルーチン機能は使えません。

/await に対応する IDE のスイッチはないので、コマンドラインに /await を追加する必要があります。

181027_CppCoroutine.png


コルーチンを使う

コルーチンとはなにか、はここでは書きません。知らない方は別途調べてください。

C++ でコルーチンを使うのは簡単です。例えば C# での下記のコード

IEnumerable<int> TestCoroutine()

{
for (int i = 0; i < 10; i++)
{
if (i > 5)
{
yield break;
}
yield return i;
}
}

foreach (var n in TestCoroutine())
{
Console.WriteLine(n);
}

とほぼ同じ事を C++ で書く場合は次のようにします。

std::experimental::generator<int> TestCoroutine()

{
for (int i = 0; i < 10; i++)
{
if (i > 5)
{
co_return;
}
co_yield i;
}
}

for (auto n : TestCoroutine())
{
printf("%d\n", n);
}

予約語が違うだけで構造自体は全く同じに見えるくらいに似ています。


C++ におけるコルーチンの仕組み

C++ では co_yield, co_await, co_return という予約語を使用したメソッドがコルーチンの関数になります。

コルーチン関数として成立させるには特定の条件を満たす必要があります。あるコルーチン関数が下記のような定義だったとして

template<typename R, typename Args...>

R f(Args... args);


  • a) R は インナークラスとして所定のメソッドを実装した promise_type クラスを持つ型とする

  • b) std::experimental::coroutine_traits<R, Args...> テンプレートクラスを定義し、そのインナークラスとして所定のメソッドを実装した promise_type を定義する

のどちらかを満たす必要があります。どちらかを満たした関数じゃないと co_yield 等は使用できません。

これらの事を特にこれといった名称がないようなのですが、"Coroutine traits" パターンと呼ぶのが近いような感じです。 co_yield を使う関数は Coroutine traits を満たしている必要がある、みたいな?


Corutine traits を独自に実装する

普通にコルーチンとして使うだけでしたら std::expermintal::generator で十分だと思いますが、それでは要件を満たせない場合は独自に Corutine traits を実装をする必要が出てきます。

例えば async/await を使う場合、実は std::future は await 可能なように実装されていますが、 (致し方ないところではありますが) おそらく期待した動作にはなりません。そういった場合は自前で await 対応の Coroutine traits を実装する必要が出てきます (C++/WinRT のように await 対応のライブラリを使う場合は別ですが) 。

今回は前項 a) パターンで実装します。 a) パターンでは R 型 (戻り値型) のインナークラスとして promise_type という型を定義、実装する必要があります。 b) パターンで generator 実装をするのはちょっと無理かなーと思ったので b) パターンでの実装例は次回の async/await の記事で書きたいと思います。


promise_type を実装する

promise_type ではそのコルーチンでの挙動を定義します。 promise_type は実装において 名前さえあっていればよく、特定のクラスを継承するといったようなことは不要です。 promise_type の基底クラスを用意し、その派生クラスを定義してバリエーションを作成するという方法もありです。


get_return_object

promise_promise_type の実装の中で一番重要なメソッドです。ここで返す型は R 型である必要があります。 a) パターンにおいては promise_type を持つ親クラスである必要があります。

典型的な a) パターン実装では coroutine_handle::from_promise メソッドを利用して coroutine_handle を取得し、それを保持した Coroutine traits を返すように実装します。 coroutine_handle 以外に必要なものがあったら一緒に渡してもよいでしょう。

auto get_return_object()

{
return Generator(HandleType::from_promise(*this));
}


initial_suspend, final_suspend

コルーチン開始時、終了時に suspend 状態にするかどうかを定義するメソッドです。 suspend_always 型か suspend_never 型のどちらかを返すように記述します。

initial_suspend は always にすると suspend 状態で開始するので resume をするまでコルーチンが動作しません。 never にするとコルーチン関数は実行状態から開始します。 final_suspend の方は違いがわかりませんでした (すいません) 。

std::experimental::suspend_always initial_suspend() { return {}; }

std::experimental::suspend_always final_suspend() { return {}; }

await 用であれば initial_suspend は never を、そうでない場合は always がよさそうです。


yield_value, return_value

yield_value は co_yield が呼ばれた時に、 return_value は co_return, co_await が呼ばれた時に呼び出されるメソッドです。

通常は引数で受け取った値を保持する処理を実装しますが、実際のところは次のように実装することが多いようです。


  • await 用の場合は return_value のみ実装する。

  • await 用でなければ yield_value のみ実装する。

ValueType _value;

std::experimental::suspend_always yield_value(ValueType value)
{
_value = value;
return {};
}

VC++2017 の std::expermintal::generator では参照 (ポインター) で保持していました。


unhandled_exception

ハンドリングされていない例外が発生すると呼ばれますが、書かなくてもコンパイルはできます。


coroutine_handle

corotuine_handle は C++ のライブラリが定義しているクラス (ランタイムが管理しているリソースへのハンドル) で、コルーチンの状態管理を担っています。コルーチンは co_yield で処理を中断する際にその時点でのローカルスコープの内容をどこかに退避しておき、再開時に復元して処理を継続します。そういった情報を管理するためのものです。退避、復元処理はコンパイラが生成します。

promise_type は coroutine_handle によって管理されます。参照関係的には "(Coroutine traits) → coroutine_handle → promise_type" となります。


Coroutine traits の実装


コンストラクタ、デストラクタ

Coroutine traits を持つクラスはコピー不可、ムーブ可能として実装してください。また、デストラクタで coroutine_handle を破棄するコードを記述します。

~Generator()

{
if (_coroutineHandle != nullptr)
{
_coroutineHandle.destroy();
}
}


各種処理

Coroutine traits の実装は coroutine_handle, promise_type に対する制御を行う処理を実装します。

例えば C# の IEnumerator の実装のような場合は、次のようになると思います。

ValueType GetCurrentValue() const

{
return _coroutineHandle.promise()._value;
}

bool MoveNext()
{
_coroutineHandle.resume();
return !_coroutineHandle.done();
}

GetCurrentValue では coroutine_handle::promise メソッドでその coroutine_handle が保持している promise_type にアクセスできるので、 promise_type で保持している yield 時の値を取得しました。

MoveNext は suspend 状態を一旦解除 (resume) し、次の処理まで実行させます。コルーチンを抜けたかどうかは coroutine_handle::done でわかります。 done が false だった場合、まだ終わっていない (= 継続可能) ということでここでは true を返すようにしています。

ここではわかりやすくするため C# 風に記述しましたが、実際は iterator として実装した方が C++ の場合は使いやすいと思います。


コルーチン実装例

template<typename ValueType>

struct Generator
{
struct promise_type
{
public:
ValueType _value;

auto get_return_object() { return Generator(HandleType::from_promise(*this)); }
std::experimental::suspend_always initial_suspend() { return {}; }
std::experimental::suspend_always final_suspend() { return {}; }

std::experimental::suspend_always yield_value(ValueType value)
{
_value = value;
return {};
}
};

private:
using HandleType = std::experimental::coroutine_handle<promise_type>;
HandleType _coroutineHandle;

public:
explicit Generator(HandleType h) : _coroutineHandle(h)
{
}

Generator(const Generator& src) = delete;

Generator(Generator&& rhs) : _coroutineHandle(rhs._coroutineHandle)
{
rhs._coroutineHandle = nullptr;
}

~Generator()
{
if (_coroutineHandle != nullptr)
{
_coroutineHandle.destroy();
}
}

ValueType GetCurrentValue() const
{
return _coroutineHandle.promise()._value;
}

bool MoveNext()
{
_coroutineHandle.resume();
return !_coroutineHandle.done();
}
};

Generator<int> TestCoroutine()
{
co_yield 1;
co_yield 2;
}

auto f = TestCoroutine();
while (f.MoveNext())
{
printf("%d\n", f.GetCurrentValue());
}


おわりに

C++ でコルーチンを使う方法についてまとめてみました。ここまではそんなに難しくないと思います。自前でクラスをちょっと書いてデバッガでトレースしてみたらすぐ理解できると思います。

次回は今回の内容を踏まえて async/await をやっていきます。

一応、以前のドラフトへのリンクも。



  • 2018/11/3 Coroutine traits について大きく記述を修正しました