Go vs Rust/C++: Goroutineとコルーチンの比較
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Golang、Rust、C++におけるコルーチンの詳細な分析
今日、コルーチンは現代のプログラミング言語の重要な一部となり、非同期プログラミングや並行制御などのシナリオで広く使用されています。GolangのgoroutineやJavaScriptのasync/awaitなど、多くの主流プログラミング言語がコルーチンのサポートを提供しています。コルーチンの名前と実装方法は言語によって異なりますが、本質的に、コルーチンは主にスタックフルコルーチンとスタックレスコルーチンという2つのカテゴリに分類されます。前者はgoroutineで代表され、後者はasync/awaitで典型的に示されます。
1. スタックフルコルーチンとスタックレスコルーチンの違い
ここで言及されている「スタックフル」と「スタックレス」という用語は、コルーチンの実行時にスタック領域が必要かどうかを意味するものではありません。実際、ほとんどのプログラミング言語では、関数呼び出しは必然的にコールスタックを伴います。主な違いは、コルーチンが任意のネストされた関数(サブ関数、匿名関数など)で中断できるかどうかです。スタックフルコルーチンはこの能力を持ち、スタックレスコルーチンは持ちません。この違いを深く理解するためには、関数呼び出しスタックの動作メカニズムから始める必要があります。
1.1 関数呼び出しスタックの動作メカニズム
この記事の議論はx86プラットフォームに基づいており、32ビットシステムを対象としています。x86プラットフォームでは、コールスタックのアドレスは高いアドレスから低いアドレスに増加します。コールスタックは連続したアドレス空間であり、呼び出し元と呼び出し先の両方がその中に位置しています。コールスタック内の各関数が占めるアドレス空間は「スタックフレーム」と呼ばれ、コールスタック全体は複数のスタックフレームで構成されています。以下は、Wikipediaから引用した典型的なコールスタックモデルです。
Compiler Explorerを使用すると、Cコードをアセンブリコードに変換して、基盤となる実行プロセスを理解するのに便利です。以下は、コンパイルパラメータ-m32
を使用したx86_64 gcc 9.3によって生成されたAT&T構文のアセンブリコードです。
int callee() { int x = 0; return x; } int caller() { callee(); return 0; }
対応するアセンブリコードは次のとおりです。
callee: pushl %ebp movl %esp, %ebp subl $16, %esp movl $0, -4(%ebp) movl -4(%ebp), %eax leave ret caller: pushl %ebp movl %esp, %ebp call callee movl $0, %eax popl %ebp ret
caller
がcallee
を呼び出すとき、実行ステップは次のとおりです。
eip
に格納されている命令アドレス(つまり、caller
の戻りアドレス、caller
のmovl $0, %eax
命令のアドレス)をスタックにプッシュして保持します。callee
にジャンプします。caller
のスタックフレームの下位アドレスをスタックにプッシュして保持します。- この時点でのコールスタックの先頭アドレスを
callee
のスタックフレームの下位アドレスとして使用します。 - コールスタックの先頭を16バイト拡張して、
callee
のスタックフレームスペースとします。x86プラットフォームのコールスタックアドレスは高いアドレスから低いアドレスに増加するため、subl
命令が使用されます。
callee
がcaller
に戻るとき、実行ステップは次のとおりです。
- コールスタックの先頭を
callee
スタックフレームの下位と揃え、callee
スタックフレームスペースを解放します。 - 以前に保存した
caller
のスタックフレームの下位アドレスをスタックからポップし、ebp
に割り当てます。 - 以前に保存した
caller
の戻りアドレスをスタックからポップし、eip
に割り当てます。つまり、caller
のmovl $0, %eax
命令のアドレスです。 caller
はcallee
から戻り、後続の命令の実行を継続します。
コールスタックの実際の操作プロセスはより複雑です。この記事での議論を簡略化するために、関数パラメータの受け渡しなどの詳細は無視されています。
2. スタックフルコルーチンの実装と原理(Goroutine)
コルーチンの実装の鍵は、コンテキストの保存、復元、および切り替えにあります。関数はコールスタック上で実行されるため、コンテキストを保存することは、関数とそのネストされた関数の連続したスタックフレームの値と、現在のレジスタの値を保存することを意味すると考えるのは自然です。コンテキストを復元することは、これらの値を対応するスタックフレームとレジスタに書き戻すことを意味します。コンテキストを切り替えることは、現在実行中の関数のコンテキストを保存し、実行される次の関数のコンテキストを復元することを意味します。スタックフルコルーチンは、まさにこのアイデアに基づいて実装されています。
2.1 スタックフルコルーチンの実装
スタックフルコルーチンを実装するには、まず、コンテキストを格納するためにメモリ空間を割り当てる必要があります。コンテキストをこのメモリにコピーするか、コルーチンの実行時にこのメモリ空間をスタックフレームスペースとして直接使用して、コピーによるパフォーマンスの低下を回避することができます。ただし、メモリ空間のサイズを適切に割り当てる必要があることに注意してください。小さすぎると、コルーチンの実行時にスタックオーバーフローが発生する可能性があり、大きすぎると、メモリが無駄になります。
同時に、レジスタの値も保存する必要があります。関数呼び出しスタックでは、慣例により、eax
、ecx
、edx
などのレジスタはcaller
によって保存され、ebx
、edi
、esi
などのレジスタはcallee
によって保存されます。呼び出されたコルーチンでは、callee
に関連するレジスタ値、コールスタックに関連するebp
とesp
の値、およびeip
に格納されている戻りアドレスを保存する必要があります。
// *(ctx + CTX_SIZE - 1) は戻りアドレスを格納します // *(ctx + CTX_SIZE - 2) は ebx を格納します // *(ctx + CTX_SIZE - 3) は edi を格納します // *(ctx + CTX_SIZE - 4) は esi を格納します // *(ctx + CTX_SIZE - 5) は ebp を格納します // *(ctx + CTX_SIZE - 6) は esp を格納します // x86 のスタックの増加方向は高いアドレスから低いアドレスであるため、アドレス指定は下方向へのオフセットであることに注意してください char **init_ctx(char *func) { size_t size = sizeof(char *) * CTX_SIZE; char **ctx = malloc(size); memset(ctx, 0, size); *(ctx + CTX_SIZE - 1) = (char *) func; *(ctx + CTX_SIZE - 6) = (char *) (ctx + CTX_SIZE - 7); return ctx + CTX_SIZE; }
レジスタの値を保存および復元するために、アセンブリコードを記述する必要があります。コンテキストを格納するメモリアドレスがeax
に割り当てられていると仮定すると、保存ロジックは次のとおりです。
movl %ebx, -8(%eax) movl %edi, -12(%eax) movl %esi, -16(%eax) movl %ebp, -20(%eax) movl %esp, -24(%eax) movl (%esp), %ecx movl %ecx, -4(%eax)
復元ロジックは次のとおりです。
movl -8(%eax), %ebx movl -12(%eax), %edi movl -16(%eax), %esi movl -20(%eax), %ebp movl -24(%eax), %esp movl -4(%eax), %ecx movl %ecx, (%esp)
上記のアセンブリコードに基づいて、void swap_ctx(char **current, char **next)
関数を構築できます。char **init_ctx(char *func)
で構築されたコンテキストを渡すことにより、コンテキストスイッチを実現できます。使いやすさのために、swap_ctx()
関数をyield()
関数にカプセル化して、関数スケジューリングロジックを実装することもできます。以下は完全な例です。
#include <stdio.h> #include <stdlib.h> #include <string.h> // コンパイル // gcc -m32 stackful.c stackful.s const int CTX_SIZE = 1024; // *(ctx + CTX_SIZE - 1) は戻りアドレスを格納します // *(ctx + CTX_SIZE - 2) は ebx を格納します // *(ctx + CTX_SIZE - 3) は edi を格納します // *(ctx + CTX_SIZE - 4) は esi を格納します // *(ctx + CTX_SIZE - 5) は ebp を格納します // *(ctx + CTX_SIZE - 6) は esp を格納します char **MAIN_CTX; char **NEST_CTX; char **FUNC_CTX_1; char **FUNC_CTX_2; // コルーチンコンテキストの切り替えをシミュレートするために使用されます int YIELD_COUNT; // コンテキストを切り替えます。詳細については、stackful.sのコメントを参照してください extern void swap_ctx(char **current, char **next); // x86 のスタックは高いメモリアドレスから低いメモリアドレスに増加することに注意してください。 // したがって、アドレス指定は下方向に移動します char **init_ctx(char *func) { // コルーチンコンテキストを格納するために、動的にCTX_SIZEメモリを割り当てます size_t size = sizeof(char *) * CTX_SIZE; char **ctx = malloc(size); memset(ctx, 0, size); // 関数のアドレスをスタックフレームの初期戻りアドレスとして設定します // これにより、関数が最初にスケジュールされたときに、そのエントリポイントから実行が開始されます *(ctx + CTX_SIZE - 1) = (char *) func; // https://github.com/mthli/blog/pull/12 // 6つのレジスタ値を格納するためのスペースを予約する必要があります // 残りのメモリ空間は、関数のスタックフレームとして使用できます *(ctx + CTX_SIZE - 6) = (char *) (ctx + CTX_SIZE - 7); return ctx + CTX_SIZE; } // 4つのコルーチン(そのうちの1つはメインコルーチン)しかないため、 // スイッチステートメントを使用して、コンテキスト切り替え用のスケジューラーをシミュレートします void yield() { switch ((YIELD_COUNT++) % 4) { case 0: swap_ctx(MAIN_CTX, NEST_CTX); break; case 1: swap_ctx(NEST_CTX, FUNC_CTX_1); break; case 2: swap_ctx(FUNC_CTX_1, FUNC_CTX_2); break; case 3: swap_ctx(FUNC_CTX_2, MAIN_CTX); break; default: // 何もしません break; } } void nest_yield() { yield(); } void nest() { // タグとして乱数を生成します int tag = rand() % 100; for (int i = 0; i < 3; i++) { printf("nest func, tag: %d, index: %d\n", tag, i); nest_yield(); } } void func() { // タグとして乱数を生成します int tag = rand() % 100; for (int i = 0; i < 3; i++) { printf("func, tag: %d, index: %d\n", tag, i); yield(); } } int main() { MAIN_CTX = init_ctx((char *) main); // nest()がネストされた関数内で中断できることを示します NEST_CTX = init_ctx((char *) nest); // 同じ関数が異なるスタックフレームで実行できることを示します FUNC_CTX_1 = init_ctx((char *) func); FUNC_CTX_2 = init_ctx((char *) func); int tag = rand() % 100; for (int i = 0; i < 3; i++) { printf("main, tag: %d, index: %d\n", tag, i); yield(); } free(MAIN_CTX - CTX_SIZE); free(NEST_CTX - CTX_SIZE); free(FUNC_CTX_1 - CTX_SIZE); free(FUNC_CTX_2 - CTX_SIZE); return 0; }
gcc -m32 stackful.c stackful.s
を使用してコンパイルし、./a.out
を実行します。実行結果は、nest()
関数が実際にはネストされた関数内で中断でき、同じ関数が複数回呼び出されたときに異なるスタックフレームスペースで実行されることを示しています。
3. スタックレスコルーチンの実装と原理
スタックフレームを直接切り替えるスタックフルコルーチンとは異なり、スタックレスコルーチンは、関数呼び出しスタックを変更せずに、ジェネレーターと同様の方法でコンテキストスイッチを実装します。
スタックレスコルーチンは関数呼び出しスタックを変更しないため、任意のネストされた関数でコルーチンを中断することはほぼ不可能です。ただし、スタックフレームを切り替える必要がないため、スタックレスコルーチンは通常、スタックフルコルーチンよりも高いパフォーマンスを発揮します。さらに、上記の記事のcoroutine.h
からわかるように、作成者はC言語のマクロを通じてコルーチンのすべての変数を構造体にカプセル化し、この構造体のメモリ空間を割り当てることで、スタックフルコルーチンでは達成するのが難しいメモリの無駄を回避しています。
4. RustおよびC++のコルーチン(スタックレスコルーチン)
4.1 Rustのコルーチン
Rustは、async
およびawait
キーワードを通じて非同期プログラミングをサポートしており、これらは本質的にスタックレスコルーチンです。Rustの非同期ランタイム(Tokioなど)は、これらのコルーチンのスケジュールと管理を担当します。例:
async fn fetch_data() -> Result<String, reqwest::Error> { let client = reqwest::Client::new(); let response = client.get("https://example.com").send().await?; response.text().await }
Rustでは、async
関数はFuture
トレイトを実装するオブジェクトを返し、await
キーワードは現在のコルーチンを一時停止してFuture
が完了するのを待つために使用されます。
4.2 C++のコルーチン
C++20ではコルーチンのサポートが導入され、co_await
、co_yield
、co_return
などのキーワードを通じてコルーチン関数が実装されました。C++のコルーチンモデルはより柔軟性があり、必要に応じてスタックフルまたはスタックレスコルーチンを実装できます。例:
#include <iostream> #include <experimental/coroutine> struct task { struct promise_type { task get_return_object() { return task{this}; } auto initial_suspend() { return std::experimental::suspend_always{}; } auto final_suspend() noexcept { return std::experimental::suspend_always{}; } void return_void() {} void unhandled_exception() {} }; task(promise_type* p) : coro(std::experimental::coroutine_handle<promise_type>::from_promise(*p)) {} ~task() { coro.destroy(); } void resume() { coro.resume(); } bool done() { return coro.done(); } private: std::experimental::coroutine_handle<promise_type> coro; }; task async_function() { std::cout << "Start" << std::endl; co_await std::experimental::suspend_always{}; std::cout << "Resume" << std::endl; }
5. 結論
スタックフルコルーチンとスタックレスコルーチンの詳細な分析を通じて、それらの基盤となる実装についてより明確に理解できました。スタックフルコルーチンとスタックレスコルーチンは、コンテキストストレージメカニズムに従って命名されていますが、本質的な違いは、任意のネストされた関数で中断できるかどうかです。この違いにより、スタックフルコルーチンは中断時に自由度が高く、既存の同期コードとの互換性の点でより便利です。一方、スタックレスコルーチンは、中断の自由度は制限されていますが、パフォーマンスが高く、メモリ管理機能が優れています。実際のアプリケーションでは、特定のニーズに応じて適切なタイプのコルーチンを選択する必要があります。
【Leapcell:最高のサーバーレスWebホスティング】(https://leapcell.io/)
最後に、Go/Rustサービスをデプロイするのに最適なプラットフォーム**【Leapcell】(https://leapcell.io/)**をお勧めします。
🚀 お気に入りの言語で構築
JavaScript、Python、Go、またはRustで楽に開発。
🌍 無制限のプロジェクトを無料でデプロイ
使用量に応じてお支払いください。リクエストも料金もありません。
⚡ 従量課金制、隠れたコストなし
アイドル料金はなく、シームレスなスケーラビリティのみ。
🔹 Twitterでフォローしてください: @LeapcellHQ