3
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?

GoとRust/C++の並行処理: ゴルーチン vs コルーチンの実装

Last updated at Posted at 2025-03-27

Group170.png

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です:

call-stack.png

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

callercalleeを呼び出すとき、実行ステップは以下の通りです:

  1. eipに保存されている命令アドレス(すなわちcallerの戻り先アドレス、caller内のmovl $0, %eax命令のアドレス)をスタックにプッシュして保存します。
  2. calleeにジャンプします。
  3. callerのスタックフレームの底部アドレスをスタックにプッシュして保存します。
  4. このときの呼び出しスタックのトップアドレスをcalleeのスタックフレームの底部アドレスとして使用します。
  5. 呼び出しスタックのトップを16バイト拡張してcalleeのスタックフレーム空間とします。x86プラットフォームでの呼び出しスタックアドレスは高アドレスから低アドレスに向かって増加するため、subl命令を使用します。

calleecallerに戻るとき、実行ステップは以下の通りです:

  1. 呼び出しスタックのトップをcalleeのスタックフレームの底部に合わせ、calleeのスタックフレーム空間を解放します。
  2. 以前に保存されたcallerのスタックフレームの底部アドレスをスタックからポップし、ebpに割り当てます。
  3. 以前に保存されたcallerの戻り先アドレスをスタックからポップし、eipに割り当てます。つまり、callermovl $0, %eax命令のアドレスです。
  4. callercalleeから戻り、続けて次の命令を実行します。

呼び出しスタックの実際の動作プロセスはもっと複雑です。この記事での議論を簡略化するために、関数パラメータの渡し方などの詳細は無視しています。

2. スタックフルコルーチン(Goroutine)の実装と原理

コルーチンを実装するキーは、コンテキストの保存、復元、切り替えにあります。関数は呼び出しスタック上で実行されるため、コンテキストを保存するということは、関数とそのネスト関数の連続したスタックフレーム内の値と、現在のレジスタの値を保存することを意味します。コンテキストを復元するとは、これらの値を対応するスタックフレームとレジスタに書き戻すことを意味します。コンテキストを切り替えるとは、現在実行中の関数のコンテキストを保存し、次に実行する関数のコンテキストを復元することを意味します。スタックフルコルーチンはまさにこのアイデアに基づいて実装されています。

2.1 スタックフルコルーチンの実装

スタックフルコルーチンを実装するには、まず、コンテキストを保存するためのメモリ空間を割り当てる必要があります。コンテキストをこのメモリにコピーするか、コルーチンが実行されるときに直接このメモリをスタックフレーム空間として使用することができ、コピーによるパフォーマンス損失を回避するためです。ただし、メモリ空間のサイズは適切に割り当てる必要があります。もしそれが小さすぎると、コルーチンが実行されるときにスタックオーバーフローが発生する可能性があり、大きすぎるとメモリが無駄になります。

同時に、レジスタの値も保存する必要があります。関数呼び出しスタックでは、慣例により、eaxecxedxなどのレジスタはcallerによって保存され、ebxediesiなどのレジスタはcalleeによって保存されます。呼び出されるコルーチンにとって、calleeに関連するレジスタの値、呼び出しスタックに関連するebpespの値、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はasyncawaitのキーワードを通じて非同期プログラミングをサポートしており、これらは本質的にはスタックレスコルーチンです。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_awaitco_yieldco_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** をおすすめします。

brandpic7.png

🚀 好きな言語で構築しよう

JavaScript、Python、Go、またはRustで簡単に開発できます。

🌍 無制限のプロジェクトを無料でデプロイ

使用した分だけ支払います—リクエストがなければ、請求もありません。

⚡ 使った分だけ支払い、隠された費用はありません

アイドル料金はなく、シームレスなスケーラビリティがあります。

Frame3-withpadding2x.png

📖 ドキュメントを調べる

🔹 Twitterでフォローしてください:@LeapcellHQ

3
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
3
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?