9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C++20のコルーチンを理解したい

Posted at

0. C++ Advent Calendar 2025 24日目です

どうも、tomolatoon です。私事ですが、インフルエンザに掛かってしまいまして、執筆がかなり送れてしまいました。大遅刻申し訳ありません...

さて、C++20 で導入されたコルーチンという機能があるのですが、馴染みがないと理解するのが少々難しいと思います(ソースは自分)。しかし、コルーチンは今後追加されるであろうネットワーク関連機能でも使うことが出来たり、C++/WinRT だと全面的に採用されているなど、わかっているとうれしい分野だったりもします。

ということで、今回の記事では C++20 コルーチンについて導入から始め、全体的に理解を進められるように解説していきます。なお、紹介しているコルーチンの機能自体は概ね C++20 かとは思いますが、C++23 を必要とする機能を使い倒しているのでこの記事は C++23 の記事です。

0.1. 表記上の注意

この記事では、次のような表記を用いることがあります。

  • .f()
    • (対象がわかる文脈において)メンバ関数 f
  • .f(e)
    • (対象がわかる文脈において)引数を1つ取るメンバ関数 f
  • .f() -> T
    • (対象がわかる文脈において)返り値の型が T であるようなメンバ関数 f
  • .f() -> T || U
    • .f() -> T 又は .f() -> U ということ

1. コルーチンって何?

コルーチンとは「関数本体の実行途中に中断(suspend)したり、中断したところから再開(resume)することが出来る関数」です。これによって、非同期に実行され時間がかかるもの(別スレッドの処理、デバイスの処理、ファイルI/Oなど)の処理を待つ際に中断することが可能になります。

それによって非同期処理を同期的(時系列的)に記述することや、非同期処理を待つ間に他の処理を進めることが出来るようになります。また、ジェネレータのような遅延計算による無限列の生成をシンプルに書くことも出来るようになります。(ただし、ranges とは違って巻き戻すことは出来ません。)

では、ひとまずは雰囲気を掴んでもらうために、いくつかサンプルを提示してみます。いきなり全てを読もうとするのは難しいと思うので、§2. と行ったり来たりするのが良いのではないかと思います。

1.1. 簡単なジェネレータ

simple_generator.cpp
#include <coroutine>
#include <print>
#include <random>

// ユーザーがコルーチンを定義するために使うジェネレータ型
template <class T>
struct generator
{
	// コルーチンの実行動作のカスタマイズをしたり、
	// 「コルーチン」と「コルーチンを使う側」の橋渡しをする
	// コルーチンステート上に保存されている
	struct promise_type;

	// コルーチンが動的に確保する領域に持つ状態(コルーチンステート)と
	// 紐づくハンドル(ポインタのようなもの)
	using handle_type = std::coroutine_handle<promise_type>;

	struct promise_type
	{
		// コルーチンの返り値オブジェクトを生成する
		generator get_return_object()
		{
			return generator{handle_type::from_promise(*this)};
		}

		// コルーチン開始時に中断(suspend)するかを決める
		// ここでは中断しない(すぐに実行を開始する)
		std::suspend_never initial_suspend()
		{
			return {};
		}

		// コルーチン終了時に中断(suspend)するかを決める
		// ここでは中断する(呼び出し元で明示的に破棄するまで状態を保持する)
		std::suspend_always final_suspend() noexcept
		{
			return {};
		}

		// co_return; 又は コルーチンの終端まで到達した際 に呼ばれる
		// 値を返したい場合には、return_void ではなく return_value を定義する
		void return_void() {}

		// コルーチン実行中に例外が発生すると呼ばれる
		// ここでは単純にプログラムを終了させてしまう
		void unhandled_exception()
		{
			std::terminate();
		}

		// co_yield が呼ばれた際に実行される
		// 値を保存し、コルーチンを中断させる
		std::suspend_always yield_value(T val)
		{
			value = val;
			return {};
		}

		T value;
	};

	generator(handle_type h)
		: m_handle(h)
	{}

	// コピーは禁止
	generator(const generator&) = delete;

	// ムーブは許可
	generator(generator&& other) noexcept
		: m_handle(other.m_handle)
	{
		other.m_handle = nullptr;
	}

	// RAII 的にコルーチンの破棄を行う
	~generator()
	{
		if (not m_handle) m_handle.destroy();
	}

	// コルーチンを再開して次の値を取得
	T next()
	{
		m_handle.resume();
		return m_handle.promise().value;
	}

	// 値を取得するための関数
	T operator()()
	{
		return m_handle.promise().value;
	}

private:

	// ハンドルは隠蔽するのが普通
	handle_type m_handle;
};

// これがコルーチン(co_yield を含んでいるため)
generator<unsigned int> rand_3times()
{
	// 乱数生成用のエンジンを初期化
	std::mt19937 engine(std::random_device{}());

	co_yield engine();

	co_yield engine();

	co_yield engine();
}

int main()
{
	// コルーチンを開始して最初の値を取得
	// .initial_suspend() の返り値が suspend_never なので、
	// コルーチンは実行開始をすると最初の co_yield まで進む
	auto gen = rand_3times();

	// 最初の co_yield で生成された値を取得・表示
	std::println("Random Value 1: {}", gen());

	// コルーチンを再開して次の値を取得
	gen.next();

	std::println("Random Value 2: {}", gen());

	gen.next();

	std::println("Random Value 3: {}", gen());
}

実行例
Random Value 1: 2251794797
Random Value 2: 2822587817
Random Value 3: 109171059

1.2. タスクスケジューラ + n秒待つ

coroutine_with_scheduler.cpp
#include <chrono>
#include <compare>
#include <coroutine>
#include <exception>
#include <functional>
#include <print>
#include <set>
#include <thread>

struct simple_scheduler
{
	struct entry
	{
		std::coroutine_handle<>               handle;
		std::chrono::steady_clock::time_point resume_time;

		friend auto operator<=>(const entry& l, const entry& r)
		{
			return (l.resume_time <=> r.resume_time) == 0 ? l.handle <=> r.handle : l.resume_time <=> r.resume_time;
		}
	};

	// メインループで呼び続けてもらう
	void update()
	{
		if (m_entries.empty()) return;

		auto now = std::chrono::steady_clock::now();

		// 時間が来たエントリをすべて再開してゆく
		for (auto it = m_entries.begin(); it != m_entries.end() && now >= it->resume_time;)
		{
			it->handle.resume();
			it = m_entries.erase(it);
		}
	}

	// スケジューラに後で再開するコルーチンを登録
	void schedule(std::coroutine_handle<> h, std::chrono::milliseconds delay)
	{
		m_entries.insert({h, std::chrono::steady_clock::now() + delay});
	}

	bool empty() const
	{
		return m_entries.empty();
	}

private:

	std::set<entry, std::greater<>> m_entries;
} scheduler;

struct sleep_awaiter
{
	std::chrono::milliseconds m_duration;

	// 待機時間が0以下なら中断する必要がない
	bool await_ready() const noexcept
	{
		return m_duration.count() <= 0;
	}

	// グローバルなスケジューラに登録してスケジューラに再開してもらう
	void await_suspend(std::coroutine_handle<> h) const noexcept
	{
		scheduler.schedule(h, m_duration);
	}

	// co_await の結果は特に無い
	void await_resume() const noexcept {}
};

struct task
{
	struct promise_type
	{
		task get_return_object()
		{
			return {};
		}

		std::suspend_never initial_suspend()
		{
			return {};
		}

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

		void return_void() {}

		void unhandled_exception()
		{
			std::terminate();
		}

		// 2000ms などを co_await 出来るようにする
		auto await_transform(std::chrono::milliseconds ms)
		{
			return sleep_awaiter{ms};
		}
	};
};

task register_task()
{
	using namespace std::chrono_literals;

	std::println("[coroutine] Start");

	co_await 2000ms; // 2000ms待機

	std::println("[coroutine] Middle");

	co_await 2000ms; // 2000ms待機

	std::println("[coroutine] End");
}

int main()
{
	register_task();

	while (not scheduler.empty())
	{
		// ちゃんとメインループも回っているのがわかる
		std::print(".");

		scheduler.update();

		// 回りすぎても困るので待機しつつ
		std::this_thread::sleep_for(std::chrono::milliseconds(100));
	}
}

実行例
[coroutine] Start
....................[coroutine] Middle
...................[coroutine] End

2. C++20 コルーチンの仕様を理解する

C++20 コルーチンの仕様について、大まかに理解していきましょう。

2.1. C++20 コルーチンの定義と性質

C++20 的には、コルーチンは「C++20 で追加された新キーワードである co_awaitco_yieldco_return のいずれか(複数含んでもよい)を含む関数」です。したがって std::generator<T> などがコルーチンというわけではなく、

std::generater<int> f()
{
    co_yield 0;
}

のようなものがコルーチンになるということになります。

概念的には、コルーチンは「関数本体の実行途中に中断(suspend)したり、中断したところから再開(resume)することが出来る関数」です。これによっていろいろとうれしいことが出来ます。

関数とコルーチンの差
20分くらいでわかった気分になれるC++20コルーチン | ドクセル(p. 13) より

また、C++20 コルーチンでは、コルーチンは元々実行されていたスレッドとは別のスレッドから再開することも可能で、スレッドを乗り換えながら実行することが出来ます。

関数とコルーチンの差
20分くらいでわかった気分になれるC++20コルーチン | ドクセル(p. 14) より

2.2. コルーチンステートと coroutine_handle

コルーチンの中断・再開を可能にしているのがコルーチンステートです。コルーチンステートはコルーチンが初めて実行されるタイミングに動的確保1される領域で、

  • promise object(後述)
  • コルーチンの実引数のコピー
  • 中断位置の情報
  • ローカル変数や一時オブジェクト

などを保持しています。コルーチンステートにこれらの情報が保存されているため、コルーチンでは中断と再開を経た後も、中断前の状態から継続してコルーチンの実行を進めることが出来るようになっています。

std::coroutine_handle<Promise> はこのコルーチンステートへのポインタのようなもので、実際、.address() でコルーチンステートへのアドレスを取得することが出来ます。他にも、

  • .primise()
    • promise object を取得する
  • .resume()
    • コルーチンの実行を再開する
  • .destory()
    • コルーチンステートを破棄する
  • .done()
    • コルーチンの実行が終端まで進んでいるかを確認する

などのメンバ関数が存在しています。これらのメンバ関数群を用いてコルーチン外部からコルーチンの状態を確認したり、コルーチンを再開したり出来るということになります。

それぞれのメンバ関数についての詳しい情報は cpprefjp を確認してください。

2.3. コルーチン実行と promise object

コルーチンの実行と深く関わっているのが promise object(今回は generator<T>::promise_type のこと)です。

promise object はコルーチンが初めて実行されるときから存在するオブジェクトで、コルーチン側からそのメンバ関数が呼ばれることによってコルーチンの実行の制御に携わると共に、コルーチン外部との通信も担当します2

2.3.1. promise object によるコルーチン返り値の生成

promise object は .get_return_object() というメンバ関数を持っている必要があり、これを用いてコルーチンから返される返り値を生成します。

ユーザーはこのメンバ関数によって与えられるオブジェクトを受け取り、そのオブジェクトのインターフェースを経由して promise object に、更に promise object を経由してコルーチンと通信していくことになります。

2.3.2. promise object による initial/final suspend の制御と co_await

promise object は、

  • .initial_suspend()
    • コルーチンの本体を実行し始めるタイミングで中断(suspend)するかどうかを制御する
  • .final_suspend()
    • コルーチンの本体の実行を終了するタイミングで中断(suspend)するかどうかを制御する

といったメンバ関数を持っている必要があります。これらは、コンパイラによってコルーチン本体の始めと終わり(詳しくは §3.1. を参照してください)において、それぞれ次の形式(promise はコルーチンステートに存在している promise object)で呼び出されます。

  • co_await promise.initial_suspend()
  • co_await promise.final_suspend()

co_await については追々後述しますが、ここではひとまず、

  • 中断させたい場合:
    • std::suspend_always
  • 中断させないで実行を続ける場合:
    • std::suspend_never

を返せば良いということを認識しておくと良いと思います。

.final_suspend() で中断させない場合には、即座にコルーチンステートが破棄されてから制御が戻るので、コルーチンの最後の状態(例えば、co_return で返された値)を取得できなくなることに注意しましょう。

2.3.3. promise object による co_yield の制御

co_yield は主にコルーチンを中断して値を生成(yield)する際に使います。co_yield を使用する場合、promise object は、

  • .yield_value(e)

というメンバ関数を実装する必要があります。このメンバ関数は、

  • co_yield e
  • co_await promise.yield_value(e)

のように co_yieldco_await へ置換される際に呼ばれます。このメンバ関数は co_await と用いられるので、前節と同様にして std::suspend_alwaysstd::suspend_never など(正確には Awaitable)を返すことになります。

生成(yield)した値をコルーチン外から使いたい場合は std::suspend_always を、そうではない場合(例えば、コールバック関数を登録しておいて呼び出す、など)には std::suspend_never を返すのだと思われます。

2.3.4. promise object による co_return で返す値の制御

promise object は、

  • .return_void() -> void
  • .return_value(e) -> void

の上記2つのメンバ関数のうちどちらかを持つ必要があります。このことからすぐにわかりますが、引数なし co_return と引数あり co_return は同じコルーチン内で併用することは出来ません。

.return_void() は、

  • co_return;(引数なし co_return)呼び出しがあった場合
  • コルーチン本体の終端に達した場合

promise.return_void(); として呼ばれます。一方、.return_value(e) は、

  • co_return e;(引数あり co_return)呼び出しがあった場合

promise.return_value(e); として形で呼ばれます。

.return_value(e) を定義することを選択した場合は、コルーチン本体の終端に達しないように、どの実行過程を経ても引数あり co_return で終了するようにしましょう。(コルーチン本体の終端に達すると未定義動作。)

co_return の後で中断をするかどうかは、前述の通り promise object の .final_suspend() によって制御されます。そのため、これらのメンバ関数が返す値は無く、返り値の型は void となります。

2.3.5. promise object によるコルーチン本体実行中の例外制御

promise object は、

  • .unhandled_exception()

というメンバ関数を持っている必要もあります。このメンバ関数は、コルーチン本体の実行中に例外が投げられ、コルーチン本体で捕捉されなかった場合に呼ばれます。このメンバ関数は、

  • std::terminate() でプログラム自体を強制終了させる
  • エラーコードを生成・保存して呼び出し/再開元に戻った後の報告に備える
  • std::current_exception() で例外を取得・保存し、戻った後に std::rethrow_exception(exc_ptr) を用いて例外を再送出する

などの用途が考えられます。

なお .unhandled_exception() が呼ばれた場合にはコルーチンの実行は終了した扱い(実際、例外を処理できていないので実行を継続できない)となるので、co_return の実行後やコルーチン本体の終端に達した場合と同様に、.final_suspend() の呼び出し(§2.3.2. を参照)に入ります。

2.4. co_await と Awaitable と Awaiter

co_await は主にオペランド(被演算子)である式の評価を待ち、コルーチンの実行を中断するために用いられます。co_await 式の評価過程は込み入っているのでゆっくり見ていきましょう。

2.4.1. Awaitable の決定

Awaitable はざっくりと言えば、「コルーチンを記述するユーザーが指定する『待機可能』なオブジェクト」のことです。より正確には「演算子 co_await を適用して Awaiter(後述)を得られるオブジェクトか、Awaiter(後述)そのもの」のことです。

Awaitable は co_await c と書かれた場合の c から導出されます。その過程は、正確さを気にしないで単純化すると次のようになります。

  • co_yield や first/final_suspend 由来の co_await の場合、c そのもの
  • promise object に .await_transform(c) が定義されていれば、promise.await_transform(c)
  • いずれにも該当しない場合は c そのもの

このことからわかるように、promise object のメンバ関数 .await_transform(c) を適切に定義することで、あらゆる値を co_await で使うことが出来るようになります。外部ライブラリのオブジェクトやプリミティブ型の値で co_await をしたい場合などに役に立つと思われます。

2.4.2. Awaiter の決定

前節でも出てきた Awaiter ですが、これは「実際の待機動作を制御するオブジェクト」です。前節でも触れたように、Awaitable から導出されます。こちらも単純化した過程を提示すると次のようになります。

  • Awaitable に適用できる operator co_await がオーバーロード解決で一意に定まった場合、解決した operator co_awaitを適用した結果
    • 注: オーバーロード解決で一意に定まらない場合はコンパイルエラー
  • 適用できる operator co_await が無い場合は Awaitable そのもの

また、Awaiter は次の3つのメンバ関数を持つクラスでなければなりません。

  • .await_ready() -> bool
    • コルーチンの中断を「しないかどうか(スキップするか)」を決める
  • .await_suspend(h) -> void || bool || std::coroutine_handle<Q>
    • 中断したコルーチンの再開についてのスケジューングを行う
    • hco_await を含むコルーチンと紐づいている std::coroutine_handle<P> のオブジェクト
    • Q は任意の promise object の型
  • .await_resume()
    • co_await 式全体の評価結果となる値を返す
    • 注: 返り値の型は任意

今までに出てきた、std::suspend_neverstd::suspend_always は Awaitable でも Awaiter でもあり、これら3つのメンバ関数を持っています。

2.4.3. co_await 式の評価と .await_ready()

では Awaiter がどのように co_await 式の実行に関わってくるかを見ていきましょう。

co_await 式の実行では、まず始めに .await_ready() が呼ばれます。.await_ready() は「コルーチンの中断を『しないかどうか(スキップするか)』を決める」メンバ関数なので、その返り値に応じて次のような挙動を示します。

  • true
    • 中断をしない。§2.4.4. をスキップして §2.4.5. に続く
  • false
    • 中断する。§2.4.4. に続く

.await_ready() は結果がキャッシュされているなどの理由によって、わざわざコルーチンを中断しなくとも短時間で co_await 式の結果を返せる場合に true を返すように実装するべきです。そうすることで、コルーチンを中断することに係るコストを回避することが出来ます。

2.4.4. co_await 式の評価と .await_suspend(h)

§2.4.3. で .await_ready()false を返した場合は即座にコルーチンは中断されます。コルーチンが中断されると .await_suspend(h) の出番がやってきます。

.await_suspend(h)は、中断したコルーチンと紐づく std::coroutine_handle<P> である h を用いて、.await_suspend(h) の形で呼び出され、その返り値に応じて制御が移動します。.await_suspend(h)コルーチンが中断されている間に、コルーチンの外部からの視点で操作を行えることに注意しましょう。

2.4.4.1. .await_suspend(h) の役割

.await_suspend(h) は「中断したコルーチンの再開についてのスケジューングを行う」と紹介しました。これは、引数で取っている std::coroutine_handle<P> を用い、再開してもよい状況となった際にコルーチンが確実に再開されるようにすることを指します。

これは具体的に言えば「イベントループとタスクスケジューラを持っているプログラムならばそのタスクスケジューラに登録する」、「OSのタスクスケジューラに登録する」、「他スレッドを起動してそこからh.resume()を呼ぶ」など、様々考えられます。

ただし、std::generator<T> などのジェネレータの場合は、生成する値を利用する側が必要に応じてコルーチンを再開してくれるので、.await_suspend(h) では何もする必要が無いですね。

2.4.4.2. .await_suspend(h) と制御の行方

.await_suspend(h) の返り値ごとに制御の行き先が変わります。具体的には、

  • void, true
    • コルーチンの呼び出し元/再開元に制御が戻る
  • false
    • 先程中断したコルーチンを再開し、再開したコルーチンに制御が移る
  • std::coroutine_handle<Q>
    • 返り値の std::coroutine_handle<Q> によって、そのハンドルと紐づいているコルーチンを再開し、そのコルーチンに制御が移る

となります。なお .await_suspend(h) から例外が送出された場合は、中断したコルーチンが再開され、再開されたコルーチンから例外が再送出されます。

2.4.5. co_await 式の評価と .await_resume()

§2.4.3. で中断されなかった場合や、§2.4.4. の後何らかの手段によってコルーチンが再開されると、.await_resume() が呼び出されます。この時には .await_resume() によって「co_await 式全体の評価結果となる値を返す」ことが出来る状態である(ように実装されているはず)ので、即座に評価が終わり、co_await 式の評価が終了することになります。

長かったですが、これで co_await 式の説明は終わりです!ここまでで概ねの C++20 コルーチンの仕様の説明が終わりました!サンプルの読めなかった部分が読めるようになっていると思います... :eyes:

4. あとがき

早速懺悔なのですが、§1. と §2. における例がまだ少ないです...また、§3. には §2. で触れていない細かい部分について書こうとしているのですが、年越し寸前だったので一旦省略させてもらいました。きっと §2. を読んで理解した方なら多分 cpprefjp や cppreference.com を直接読んでも読めると思います...(←ほんと?)

体調管理はちゃんとしないとですね!12/24の記事なのに本当に年が変わるギリギリ投稿で本当にすみませんでした m(_ _)m

5. 参考文献

  1. [dcl.fct.def.coroutine]
    • C++23 相当の Working Draft の coroutine 定義部分
  2. [expr.await]
    • C++23 相当の Working Draft の co_await 定義部分
  3. [expr.yield]
    • C++23 相当の Working Draft の co_yield 定義部分
  4. [stmt.return.coroutine]
    • C++23 相当の Working Draft の co_return 定義部分
  5. Coroutines (C++20) - cppreference.com
    • 規格書だと足りていない行間がある程度補われて説明されています
  6. コルーチン [P0912R5] - cpprefjp C++日本語リファレンス
    • cpprefjp による日本語のコルーチン解説記事
  7. C++ MIX #5に参加しました - yohhoyの日記(別館)
    • コルーチンに関連する提案文書の列挙が記載されています
  8. 20分くらいでわかった気分になれるC++20コルーチン | ドクセル
    • yohhoy さんによる C++ MIX での公演における C++20 coroutine の説明スライド
  9. C++26 std::execution | ドクセル
    • yohhoy さんによる std::execution の説明スライド。終盤でコルーチンが出てきます
  1. 最適化によって動的メモリ確保が回避されることもあるようです

  2. ただし、実際にはコルーチンの返り値の型に coroutine_handle が隠蔽されることがほとんどなので、promise object はコルーチンの返り値の型からしか直接アクセスされることは無いと考えてよいです。

9
5
0

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
9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?