C++における非同期処理の歴史は、C++11でのstd::threadやstd::asyncの導入から始まり、現代のC++20/23/26において大きな転換点を迎えました。
かつてのコールバック地獄や複雑な状態管理は、C++コルーチンの標準化によって、同期処理のような直感的な記述へと劇的な進化を遂げています。
2026年現在、C++20で導入された基礎フレームワークに加え、C++23での標準ライブラリの拡充、そしてC++26で見えてきた高度な非同期実行モデルにより、コルーチンは特殊な技術ではなく、標準的な設計パターンとして定着しました。
本記事では、この現代的なC++コルーチンの仕組みから実践的な実装手法までを詳しく紐解いていきます。
C++コルーチンの基礎概念と従来の非同期処理との違い
C++のコルーチンは、一言で言えば「実行を一時中断し、後で中断した時点から再開できる関数」です。
従来の関数(サブルーチン)は、一度呼び出されると最後まで実行されるか、途中でreturnして終了するかのどちらかしかありません。
しかし、コルーチンは呼び出し元に制御を戻しつつ、その時点のローカル変数や実行状態を保持し続けることができます。
スタックレス・コルーチンの特性
C++が採用しているのはスタックレス(Stackless)なコルーチンです。
これは、コルーチン専用のスタックメモリを確保するのではなく、必要な状態をヒープ領域に「コルーチン・フレーム」として確保する方式です。
| 特徴 | スタックフル(他の言語など) | スタックレス(C++) |
|---|---|---|
| メモリ消費 | 大きい(スレッドごとに固定スタック) | 小さい(必要な状態のみ確保) |
| 切り替えコスト | 中程度(コンテキストスイッチに近い) | 非常に低い(関数呼び出しと同等) |
| 実装の柔軟性 | 言語ランタイムが制御 | プログラマがカスタマイズ可能 |
C++のコルーチンが非常に強力である理由は、この「プログラマが動作を細かくカスタマイズできる」という点にあります。
一方で、その自由度の高さゆえに、初心者にとっては「何から手をつければいいのか」が分かりにくいという側面もありました。
なぜ今、コルーチンが必要なのか
現代のアプリケーション開発において、I/O待ちやネットワーク通信などの待ち時間は避けられません。
これらをマルチスレッドで解決しようとすると、リソースの競合やデッドロックのリスクが伴います。
コルーチンを使えば、単一のスレッド内で複数のタスクを効率的に並行処理することができ、システム全体のスループットを大幅に向上させることが可能になります。
コルーチンを構成する3つのキーワード
C++でコルーチンを記述する際、関数内で以下のいずれかのキーワードを使用すると、その関数は自動的にコルーチンとして扱われます。
- co_await:他の非同期タスクの完了を待ち、その間実行を中断します。
- co_yield:値を呼び出し元に返しつつ、実行を一時中断します(ジェネレータで使用)。
- co_return:コルーチンの実行を終了し、最終的な結果を返します。
これらを使用するためには、関数の戻り値型がコルーチンのインターフェースを定義する特定の構造を持っている必要があります。
C++コルーチンの内部メカニズム:PromiseとAwaitable
C++のコルーチンは魔法ではなく、厳密に定義されたインターフェース(コンセプト)に基づいて動作します。
コルーチンを実装するには、大きく分けて2つの要素を理解する必要があります。
1. Promiseオブジェクト
Promiseオブジェクトは、コルーチンの「内部的な状態管理」を担います。
関数の戻り値型の中でpromise_typeとして定義される必要があります。
- コルーチンが開始されたとき、または終了したときに一時停止するかどうか。
- 戻り値をどのように呼び出し元に渡すか。
- 例外が発生したときにどのように処理するか。
2. AwaitableオブジェクトとAwaiter
co_awaitに渡されるオブジェクトは、Awaitableである必要があります。
これには、await_ready、await_suspend、await_resumeという3つのメソッドが実装されている必要があります。
- await_ready:今すぐ結果が得られるか確認。
falseを返すと中断。 - await_suspend:中断した際の処理(ハンドルを保存して非同期処理を開始するなど)。
- await_resume:再開したときに呼び出され、最終的な結果を返す。
C++23の革新:std::generatorによる簡略化
C++20時点では、コルーチンを動かすための基盤(インフラ)は提供されていましたが、具体的な「型」は標準ライブラリにほとんど存在しませんでした。
そのため、プログラマは毎回複雑なpromise_typeを自作する必要がありました。
これを劇的に変えたのが、C++23で導入されたstd::generatorです。
数値列を生成するジェネレータの実装例
以下に、C++23のstd::generatorを使用したシンプルな例を示します。
#include <iostream>
#include <generator> // C++23で導入
#include <ranges>
// 無限の等差数列を生成するコルーチン
std::generator<int> count_up(int start, int step) {
int current = start;
while (true) {
co_yield current; // 値を返して一時停止
current += step;
}
}
int main() {
// 10から始まり3ずつ増える数列を、最初の5つだけ取得
auto gen = count_up(10, 3);
for (int value : gen | std::views::take(5)) {
std::cout << value << " ";
}
return 0;
}
10 13 16 19 22
このコードでは、co_yieldが呼ばれるたびに制御がmain関数に戻り、ループの次のイテレーションが必要になったときに再びコルーチンが再開されます。
メモリ消費は極めて少なく、無限のシーケンスも安全に扱えます。
C++26とSender/Receiverモデルによる非同期処理
C++26では、非同期実行の新しい標準モデルとしてSender/Receiver(P2300)の統合が進んでいます。
これはコルーチンと密接に関係しており、コルーチンが「実行の記述」を担当し、Sender/Receiverが「実行のスケジューリング」を担当するという役割分担が明確になります。
これまで「コルーチンをどのスレッドで実行するか」という制御は、ライブラリ開発者の手に委ねられていました。
しかし、C++26時代の設計では、co_awaitする対象がSenderとなり、どのスレッドプールやイベントループ上で実行するかを宣言的に記述できるようになります。
非同期タスクの実装イメージ
例えば、ネットワークからのデータ取得をコルーチンで行う場合、以下のような記述が標準的になります。
#include <iostream>
#include <string>
// 注: C++26の機能を先取りした概念的な実装例
// #include <execution>
// 非同期タスクを返すコルーチン
// task型はC++26以降の標準化、あるいはライブラリ(libunifex等)による提供を想定
Task<std::string> fetch_data_async(std::string url) {
std::cout << "Fetching from: " << url << std::endl;
// 非同期I/Oの完了を待機
// この間、スレッドは解放され別のタスクを実行できる
std::string result = co_await Network::async_read(url);
co_return result;
}
Task<void> run_app() {
// 複数の非同期タスクを待機
auto data = co_await fetch_data_async("https://api.example.com");
std::cout << "Received data: " << data << std::endl;
}
実践:自作コルーチン型の実装
標準ライブラリの型を使うだけでなく、内部構造を理解するために、最小限の非同期タスク型を自作してみましょう。
これはC++20の基礎知識として非常に重要です。
最小構成のTask型
#include <iostream>
#include <coroutine>
#include <exception>
struct MyTask {
struct promise_type {
MyTask get_return_object() {
return MyTask{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() { return {}; } // 開始時に停止
std::suspend_always final_suspend() noexcept { return {}; } // 終了時に停止
void unhandled_exception() { std::terminate(); }
void return_void() {}
};
std::coroutine_handle<promise_type> handle;
MyTask(std::coroutine_handle<promise_type> h) : handle(h) {}
~MyTask() { if (handle) handle.destroy(); }
void resume() {
if (handle && !handle.done()) {
handle.resume();
}
}
};
MyTask simple_coroutine() {
std::cout << " [Coroutine] Hello," << std::endl;
co_await std::suspend_always{}; // ここで中断
std::cout << " [Coroutine] World!" << std::endl;
}
int main() {
std::cout << "[Main] Starting coroutine..." << std::endl;
auto task = simple_coroutine();
std::cout << "[Main] Coroutine suspended. Doing other work..." << std::endl;
std::cout << "[Main] Resuming coroutine..." << std::endl;
task.resume(); // 中断点から再開
std::cout << "[Main] Resuming again to finish..." << std::endl;
task.resume();
return 0;
}
[Main] Starting coroutine...
[Main] Coroutine suspended. Doing other work...
[Main] Resuming coroutine...
[Coroutine] Hello,
[Main] Resuming again to finish...
[Coroutine] World!
この例では、std::suspend_alwaysを利用して意図的に実行を中断させています。
実務では、この部分が「ファイルの読み込み完了」や「タイマーの満了」といったイベントに置き換わります。
パフォーマンスと注意点
C++コルーチンは非常に高速ですが、いくつか留意すべき点があります。
1. ヒープアロケーションの最適化 (HALO)
コルーチン・フレームは通常ヒープに確保されますが、コンパイラは一定の条件下でこれをスタックに割り当てたり、削除したりする最適化(HALO: Heap Allocation Elision Optimization)を行います。
しかし、この最適化は保証されているわけではないため、パフォーマンスが極めて重要な箇所ではプロファイリングが必要です。
2. 寿命管理の複雑さ
コルーチンが中断している間、その中で参照している変数の寿命が切れないように注意しなければなりません。
一時的なオブジェクトへの参照をコルーチン内で保持してしまうと、再開時に未定義動作を引き起こすリスクがあります。
- 値渡しを推奨:コルーチンの引数は参照ではなく値で受け取るのが安全です。
- ラムダ式でのキャプチャに注意:コルーチンとして動作するラムダ式では、キャプチャした変数の寿命に細心の注意を払ってください。
まとめ
C++20で導入されたコルーチンは、C++23でのstd::generatorの追加、そしてC++26における非同期実行モデルの洗練を経て、ついに完成の域に達しようとしています。
かつての低レイヤーなポインタ操作と同様に、現代のC++エンジニアにとって、コルーチンによる非同期制御は必須のスキルとなりました。
内部メカニズムであるPromiseやAwaiterを理解することは、単に構文を覚えることよりも遥かに重要です。
なぜなら、その抽象化の仕組みこそが、C++を他の言語よりも圧倒的に柔軟で高性能な言語たらしめている理由だからです。
まずはC++23のstd::generatorから使い始め、徐々にco_awaitを用いた独自の非同期タスクの実装へとステップアップしてみてください。
複雑な状態遷移をコードから排除し、論理の美しさに集中できる。
それこそがC++コルーチンが提供する最大の価値です。
