Leapcell: The Best of Serverless Web Hosting
Golang、Rust、C++におけるコルーチンの詳細分析
現在、コルーチンは現代のプログラミング言語の重要な部分となり、非同期プログラミングや並行制御などのシナリオで広く利用されています。多くの主流のプログラミング言語、例えばGolangのgoroutineやJavaScriptのasync/awaitは、コルーチンに対するサポートを提供しています。コルーチンの名前や実装方法は言語によって異なりますが、本質的には、コルーチンは主に2つのカテゴリに分けられます:スタックフルコルーチンとスタックレスコルーチン。前者はgoroutineを代表とし、後者はasync/awaitが典型的な例です。
1. スタックフルコルーチンとスタックレスコルーチンの違い
ここで言及する「スタックフル」と「スタックレス」という用語は、コルーチンが実行される際にスタック空間が必要かどうかを意味するものではありません。実際、ほとんどのプログラミング言語では、関数呼び出しには不可避的に呼び出しスタックが関係します。その核心的な違いは、コルーチンが任意のネスト関数(例えばサブ関数、匿名関数など)で一時停止できるかどうかにあります。スタックフルコルーチンはこの能力を持っていますが、スタックレスコルーチンは持っていません。この違いを深く理解するためには、関数呼び出しスタックの動作メカニズムから始める必要があります。
1.1 関数呼び出しスタックの動作メカニズム
この記事の議論はx86プラットフォームを基に、32ビットシステムを対象としています。x86プラットフォームでは、呼び出しスタックのアドレスは高アドレスから低アドレスに向かって増加します。呼び出しスタックは連続したアドレス空間で、呼び出し元と呼び出し先の両方がその中に位置します。呼び出しスタック内の各関数が占めるアドレス空間を「スタックフレーム」と呼び、全体の呼び出しスタックは複数のスタックフレームで構成されています。以下は典型的な呼び出しスタックモデルで、ソースはWikipediaです:
Compiler Explorerを通じて、Cコードをアセンブリコードに変換し、下層の実行プロセスを理解することが便利です。以下はx86_64 gcc 9.3によって生成されたAT&T構文のアセンブリコードで、コンパイルパラメータは-m32
です:
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>
// Compilation
// gcc -m32 stackful.c stackful.s
const int CTX_SIZE = 1024;
// *(ctx + CTX_SIZE - 1) stores the return address
// *(ctx + CTX_SIZE - 2) stores ebx
// *(ctx + CTX_SIZE - 3) stores edi
// *(ctx + CTX_SIZE - 4) stores esi
// *(ctx + CTX_SIZE - 5) stores ebp
// *(ctx + CTX_SIZE - 6) stores esp
char **MAIN_CTX;
char **NEST_CTX;
char **FUNC_CTX_1;
char **FUNC_CTX_2;
// Used to simulate switching coroutine contexts
int YIELD_COUNT;
// Switch context, refer to the comments in stackful.s for details
extern void swap_ctx(char **current, char **next);
// Note that the stack in x86 grows from high memory addresses to low memory addresses,
// so addressing moves downward
char **init_ctx(char *func) {
// Dynamically allocate CTX_SIZE memory to store the coroutine context
size_t size = sizeof(char *) * CTX_SIZE;
char **ctx = malloc(size);
memset(ctx, 0, size);
// Set the function's address as the initial return address of its stack frame,
// so that when the function is first scheduled, it will start executing from its entry point
*(ctx + CTX_SIZE - 1) = (char *) func;
// https://github.com/mthli/blog/pull/12
// Need to reserve space for storing 6 register values,
// The remaining memory space can be used as the function's stack frame
*(ctx + CTX_SIZE - 6) = (char *) (ctx + CTX_SIZE - 7);
return ctx + CTX_SIZE;
}
// Since we only have 4 coroutines (one of which is the main coroutine),
// we simply use a switch statement to simulate the scheduler for context switching
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:
// DO NOTHING
break;
}
}
void nest_yield() {
yield();
}
void nest() {
// Generate a random integer as a tag
int tag = rand() % 100;
for (int i = 0; i < 3; i++) {
printf("nest, tag: %d, index: %d\n", tag, i);
nest_yield();
}
}
void func() {
// Generate a random integer as a tag
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);
// Demonstrates that nest() can be suspended inside its nested function
NEST_CTX = init_ctx((char *) nest);
// Demonstrates that the same function can run on different stack frames
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言語のマクロを使ってコルーチンのすべての変数を1つの構造体にカプセル化し、その構造体のためにメモリ空間を割り当てています。これにより、メモリの無駄遣いを回避できており、これはスタックフルコルーチンが難しく実現することです。
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: The Best of Serverless Web Hosting
最後に、Go/Rustサービスのデプロイに最適なプラットフォーム**Leapcell** をおすすめします。
🚀 好きな言語で構築しよう
JavaScript、Python、Go、またはRustで簡単に開発できます。
🌍 無制限のプロジェクトを無料でデプロイ
使用した分だけ支払います—リクエストがなければ、請求もありません。
⚡ 使った分だけ支払い、隠された費用はありません
アイドル料金はなく、シームレスなスケーラビリティがあります。
🔹 Twitterでフォローしてください:@LeapcellHQ