C++20コルーチンの探究(1)——コルーチンの基本概念

コルーチンとは?

コルーチンとは、一時停止(サスペンド)と再開(リジューム)が可能なプログラムの断片であり、一般的にはサスペンドとリジュームをサポートする関数です。

通常、関数は一度開始されると中断することはできません。もし関数が中断可能であれば、冒頭で言及したコルーチンと見なすことができます。したがって、サスペンド(一時停止)は中断を意味し、リジューム(再開)は中断した箇所から実行を続けることを意味します。

RetVal CoroutineFunction() {
  std::cout << 1 << std::endl;
  co_await std::suspend_always{};
  std::cout << 2 << std::endl;
  co_await std::suspend_always{};
  std::cout << 3 << std::endl;
};

RetValはコルーチンの規則に従って定義された型であり、後で詳しく説明します。C++20では、関数の戻り値の型がコルーチンの規則に適合している場合、その関数はコルーチンとなります。

co_await std::suspend_always{}; のうち、co_awaitはキーワードであり、現在の関数(コルーチン)の実行を一時停止させます。

コンソールに1が出力された後、しばらくしてから2が出力される場合、この「しばらく」は通常、現在実行中のスレッドがブロックされているためではなく、現在の関数(コルーチン)の実行位置が保存され、将来のある時点で読み出されて再実行されるためです。

コルーチンの状態

スレッドに対応して、コルーチンにも実行中、一時停止、再開、例外、戻り値の状態があります。

C++コルーチンは、実行を開始する最初の段階でoperator newを使用して情報を保存するためのメモリ領域を確保します。このメモリ領域、またはこのオブジェクトはコルーチンの状態(coroutine state)と呼ばれます。

コルーチンの状態は、一時停止時の位置(以降サスペンドポイントと呼ぶ)を保存するために使用されるだけでなく、コルーチンが開始される際にコルーチン本体のパラメータ値も保存されます。例えば:

RetVal CoroutineFunction(int initial_value) {
  std::cout << initial_value << std::endl;
  co_await std::suspend_always{};
  std::cout << initial_value + 1 << std::endl;
};

ここでのinitial_valueはコルーチンの状態に保存されます。

注意すべき点は、パラメータが値型の場合、その値は型自身のコピーコンストラクタとムーブコンストラクタの定義に応じてコピーまたはムーブされてコルーチンの状態に保存されることです。参照やポインタ型の場合、コルーチンの状態に保存される値は参照やポインタそのものであり、指しているオブジェクト自体ではありません。この場合、開発者はコルーチンが一時停止後再開される際に、パラメータの参照やポインタが指すオブジェクトが依然として存在していることを保証する必要があります。

作成に対応して、コルーチンの実行が完了したり、外部から明示的に破棄されたりした後、コルーチンの状態も破棄・解放されます(コンパイラが処理し、明示的な呼び出しは不要です)。

コルーチンの一時停止

C++はco_await式を使用してコルーチンの一時停止を処理し、式の操作対象は**待機体(awaiter)**です。

待機体は3つの関数を実装する必要があり、これらの関数は一時停止と再開時にそれぞれ呼び出されます。

await_ready

標準ライブラリには2つの非常にシンプルで直接的な待機体が提供されています。struct suspend_alwaysは常に一時停止し、struct suspend_neverは常に一時停止しません。これらの機能は主にawait_ready関数の戻り値に依存します:

struct suspend_never {
    constexpr bool await_ready() const noexcept {
        return true;  // trueを返す、常に一時停止しない
    }
    //待機体のその他の内容...
};

struct suspend_always {
    constexpr bool await_ready() const noexcept {
        return false; // falseを返す、常に一時停止する
    }
     //待機体のその他の内容...
};

await_suspend

await_readyがfalseを返すと、コルーチンは一時停止します。この時点で、コルーチンのローカル変数とサスペンドポイントはコルーチンの状態に保存され、await_suspendが呼び出されます。

戻り値 await_suspend(std::coroutine_handle<> coroutine_handle);

パラメータのcoroutine_handleは現在のコルーチンを表し、後適切なタイミングでresumeを呼び出すことで現在のコルーチンを再開実行できます:

coroutine_handle.resume();

await_suspend関数の戻り値の型は明示されていません。なぜなら、以下のいくつかのオプションがあるためです:

  • void型またはtrueを返す場合、現在のコルーチンが一時停止した後、実行権は当初現在のコルーチンを呼び出したまたは再開した関数に戻ります。
  • falseを返すと、現在のコルーチンの実行が再開されます。await_readyがtrueを返す場合とは異なり、ここではコルーチンはすでに一時停止しており、await_suspendがfalseを返すことは一時停止直後の再開を意味します。
  • その他のコルーチンのcoroutine_handleオブジェクトを返す場合、返されたcoroutine_handleに対応するコルーチンが再開実行されます。
  • 例外をスローする場合、現在のコルーチンは再開実行され、現在のコルーチン内で例外がスローされます。

await_resume

同様に、await_resumeの戻り値の型も限定されず、戻り値はco_await式の戻り値となります。

待機体の例

struct MyAwaiter {
  int data;

  bool await_ready() {
    // コルーチンを一時停止
    return false;
  }

  void await_suspend(std::coroutine_handle<> coroutine_handle) {
    // スレッドを切り替え
    std::async([=](){
      using namespace std::chrono_literals;
      // 1秒スリープ
      std::this_thread::sleep_for(1s); 
      // コルーチンを再開
      coroutine_handle.resume();
    });
  }

  int await_resume() {
    // dataがco_await式の値として返される
    return data;
  }
};

await_ready:このメソッドはコルーチンが一時停止する必要があるかどうかをチェックします。falseを返す場合、コルーチンは一時停止し、待機状態に入ります。

await_suspend:このメソッドはawait_readyがfalseを返した場合に呼び出され、コルーチンを一時停止します。

この例の具体的な操作は以下の通りです:

  • std::asyncを使用して非同期タスクを作成し、これは独立したスレッド上で実行されます。
  • このスレッドでは、まずスレッドを1秒間停止させます(std::this_thread::sleep_for(1s))。
  • 停止後、coroutine_handle.resume()を呼び出して一時停止されたコルーチンを再開します。

この方法により、await_suspendメソッドはコルーチンを一定時間停止させる効果を実現し、コルーチンが存在するメインスレッドをブロックしません。

await_resume:このメソッドはコルーチンが再開実行された後に呼び出され、コルーチンの結果を渡すために使用されます。この例では、構造体のdataメンバ変数を返し、これはco_await式の結果となります。

コルーチンの戻り値の型

関数がコルーチンであるかどうかは、その戻り値の型によって判断されます。戻り値の型がコルーチンの規則を満たしている場合、その関数はコンパイル時にコルーチンになります。

規則とは、戻り値の型が以下のテンプレート型_Coroutine_traitsをインスタンス化できることです。

#includeファイル内での定義は以下の通りです:

template <class _Ret, class = void>
struct _Coroutine_traits {};

template <class _Ret>
struct _Coroutine_traits<_Ret, void_t<typename _Ret::promise_type>> {
    using promise_type = typename _Ret::promise_type;
};

template <class _Ret, class...>
struct coroutine_traits : _Coroutine_traits<_Ret> {};

簡単に言えば、戻り値の型_Retに_Ret::promise_typeという型が対応していることです。このpromise_typeは_Ret内で直接定義されている型でも、usingで既存の外部型を指している型でも構いません。

これにより、Resultの部分的な実装を示すことができます:

struct CoroutineResult {
  struct promise_type {
    //内部実装は下記参照
  };
};

コルーチン戻り値オブジェクトの構築

ここでC++においてコルーチンを区別する方法が理解できました。しかし、新しい問題が生じます。戻り値はどこから来るのでしょうか?コルーチン本体にはResultオブジェクトを作成するコードがありません。

実際には、Resultオブジェクトの作成はpromise_typeが担当し、get_return_object関数を定義してResultオブジェクトの作成を処理する必要があります:

struct CoroutineResult {
  struct promise_type {

    CoroutineResult get_return_object() {
      // Resultオブジェクトを作成
      return {};
    }
    //その他の関数
  };
};

通常の関数とは異なり、コルーチンの戻り値はreturnの直前に作成されるのではなく、コルーチンの状態が作成された直後に作成されます。つまり、コルーチンの状態が作成された後、すぐにpromise_typeオブジェクトが構築され、その後get_return_objectが呼び出されて戻り値オブジェクトが作成されます。

promise_type型のコンストラクタの引数リストがコルーチンの引数リストと一致する場合、promise_typeを構築する際にそのコンストラクタが呼び出されます。それ以外の場合は、デフォルトの引数なしコンストラクタを使用してpromise_typeが構築されます。

コルーチン本体の実行

initial_suspend

コルーチン本体の実行の最初のステップはco_await promise.initial_suspend()を呼び出すことです。initial_suspendの戻り値は待機オブジェクト(awaiter)であり、戻り値が一時停止の条件を満たす場合、コルーチン本体は最初の段階で即座に一時停止します。この点は非常に重要であり、initial_suspendが返す待機体を制御することでコルーチンの実行スケジューリングを実現できます。

スケジューリングに関する詳細は後の連載で説明します。

コルーチン本体の戻り値(値,void,例外)

このコルーチンの戻り値は、関数宣言の前の型を返すのではなく、co_returnを使用します。この宣言されたCoroutineResultは、コルーチンと呼び出し者間のコミュニケーションのブリッジのようなものです。

次にコルーチン本体を実行します。コルーチン本体にはco_await、co_yield、co_returnという3つのコルーチン特有の呼び出しが存在します。そのうち:

  • co_awaitは前に説明した通り、コルーチンを一時停止するために使用されます。
  • co_yieldはco_awaitの別の実装であり、コルーチンの呼び出者や再開者、あるいは再開される側に値を渡すために使用されます。
  • co_returnは値を返すか、コルーチン本体から脱出するために使用されます。

値を返す場合、promise_type内に以下の関数を定義する必要があります:

void return_value();//voidを返す必要がある

そしてco_returnを呼び出すと:

co_return 1000;

1000が引数として渡され、つまりreturn_value関数のパラメータvalueの値が1000になります。この値はpromise_typeオブジェクトに保存でき、外部の呼び出し者は取得できます。

戻り値の場合のほかに、C++コルーチンはvoidを返すこともサポートしています。ただし、promise_typeで定義する関数はreturn_valueではなくreturn_voidになります:

struct CoroutineResult {
  struct promise_type {
    
    void return_void() {
      ...
    }

    ...
  };
};

この場合、コルーチン内部ではco_returnを使用してコルーチン本体から脱出できます:

コルーチン本体は正常な戻り値のほかに、例外をスローすることもできます。例外は結果の型の一種であるため、処理方法も戻り値の場合と同様です。promise_type内に以下の関数を定義するだけで、例外がスローされた際にこの関数が呼び出されます:

struct CoroutineResult {
  struct promise_type {
    
    void unhandled_exception() {
      exception_ = std::current_exception(); // 現在の例外を取得
    }

    ...
  };
};

final_suspend

コルーチンが実行を完了したり例外をスローしたりした後、まずローカル変数がクリーンアップされ、その後にfinal_suspendが呼び出されて開発者がその他のリソースの破棄ロジックを独自に処理できるようにします。final_suspendも待機体を返して現在のコルーチンを一時停止させることができますが、その後現在のコルーチンはcoroutine_handleのdestroy関数を使用して直接破棄するべきであり、resumeすべきではありません。

一般的にfinal_suspendはコルーチンを一時停止させ、その破棄とresult(コルーチンの戻り値を受け取ったもの)のライフサイクルが一致することを望み、早すぎる破棄による予期せぬ事態を避けるべきです。

int main() {
    auto result=MyCoroutine(); 
    return 0;
} 

テストサンプル

#define __cpp_lib_coroutine

#include <iostream>
#include <coroutine>
#include <future>
#include <chrono>

using namespace std::chrono_literals;

void HelperFunction() {
    std::cout << "A" << std::endl;
    std::cout << "B" << std::endl;
    std::cout << "C" << std::endl;
    std::cout << "D" << std::endl;
}

struct CoroutineResult {
    struct promise_type {
        int result_value;
        std::suspend_never initial_suspend() {
            return {};
        }

        std::suspend_never final_suspend() noexcept {
            return {};
        }

        CoroutineResult get_return_object() {
            return {};
        }

        void return_value(int _value) {
            result_value = _value;
            std::cout << "コルーチンが " << result_value << " を返しました\n";
        }

        void unhandled_exception() {

        }
    };
};

struct MyAwaiter {
    int data;

    bool await_ready() {
        return false;
    }

    void await_suspend(std::coroutine_handle<> coroutine_handle) {
        std::async([=]() {
            std::this_thread::sleep_for(1s);
            coroutine_handle.resume();
            });
    }

    int await_resume() {
        return data;
    }
};


CoroutineResult MyCoroutine() {
    std::cout << "コルーチン開始 " << std::endl;
    HelperFunction();
    std::cout << co_await MyAwaiter{.data = 2000} << std::endl;
    co_return 10;
};

int main() {
    auto result = MyCoroutine();
    std::this_thread::sleep_for(1s);
    // コルーチンの戻り値を確認するために少し待つ
    return 0;
}

出力結果

コルーチン開始
A
B
C
D
2000
コルーチンが 10 を返しました

タグ: C++20 コルーチン 非同期処理

7月19日 23:09 投稿