Go vs Rust/C++: Eine Vergleich von Goroutinen und Coroutines
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Detaillierte Analyse von Coroutinen in Golang, Rust und C++
Heutzutage sind Coroutinen ein wichtiger Bestandteil moderner Programmiersprachen und werden häufig in Szenarien wie asynchroner Programmierung und gleichzeitiger Steuerung eingesetzt. Viele gängige Programmiersprachen, wie z. B. Goroutinen in Golang und Async/Await in JavaScript, bieten Unterstützung für Coroutinen. Obwohl sich die Namen und Implementierungsmethoden von Coroutinen in den verschiedenen Sprachen unterscheiden, lassen sich Coroutinen im Wesentlichen in zwei Kategorien einteilen: Stackful Coroutinen und Stackless Coroutinen. Erstere werden durch Goroutinen repräsentiert, letztere durch Async/Await.
1. Die Unterschiede zwischen Stackful und Stackless Coroutinen
Die hier genannten Begriffe "Stackful" und "Stackless" bedeuten nicht, dass ein Stack-Speicherplatz erforderlich ist, wenn die Coroutine ausgeführt wird. Tatsächlich beinhalten Funktionsaufrufe in den meisten Programmiersprachen unweigerlich den Callstack. Der Hauptunterschied besteht darin, ob eine Coroutine in einer beliebigen verschachtelten Funktion unterbrochen werden kann (z. B. einer Unterfunktion, einer anonymen Funktion usw.). Stackful Coroutinen haben diese Fähigkeit, Stackless Coroutinen hingegen nicht. Um diesen Unterschied besser zu verstehen, müssen wir mit dem Funktionsmechanismus des Function Callstacks beginnen.
1.1 Der Funktionsmechanismus des Function Callstacks
Die Diskussion in diesem Artikel basiert auf der x86-Plattform und nimmt ein 32-Bit-System als Objekt. Auf der x86-Plattform wächst die Adresse des Callstacks von einer hohen zu einer niedrigen Adresse. Der Callstack ist ein zusammenhängender Adressraum, und sowohl der Aufrufer als auch der Aufgerufene befinden sich darin. Der Adressraum, der von jeder Funktion im Callstack belegt wird, wird als "Stack Frame" bezeichnet, und der gesamte Callstack besteht aus mehreren Stack Frames. Das Folgende ist ein typisches Callstack-Modell, das von Wikipedia stammt:
Über den Compiler Explorer ist es bequem, C-Code in Assembly-Code zu konvertieren, um den zugrunde liegenden Ausführungsprozess zu verstehen. Das Folgende ist der AT&T-Syntax-Assembly-Code, der von x86_64 gcc 9.3 mit dem Kompilierungsparameter -m32
generiert wurde:
int callee() { int x = 0; return x; } int caller() { callee(); return 0; }
Der entsprechende Assembly-Code lautet:
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
Wenn caller
callee
aufruft, sind die Ausführungsschritte wie folgt:
- Den in
eip
gespeicherten Befehl Adresse (d. h. die Rücksprungadresse voncaller
, die Adresse des Befehlsmovl $0, %eax
incaller
) zur Aufbewahrung auf den Stack schieben. - Zu
callee
springen. - Die untere Adresse des Stack Frame von
caller
zur Aufbewahrung auf den Stack schieben. - Die aktuelle obere Adresse des Callstacks als untere Adresse des Stack Frame von
callee
verwenden. - Die Oberseite des Callstacks um 16 Byte als Stack Frame-Speicherplatz von
callee
erweitern. Da die Callstack-Adresse auf der x86-Plattform von einer hohen zu einer niedrigen Adresse wächst, wird der Befehlsubl
verwendet.
Wenn callee
zu caller
zurückkehrt, sind die Ausführungsschritte wie folgt:
- Richte die Spitze des Callstacks mit dem Boden des Stack Frame von
callee
aus und gib den Stack Frame-Speicherplatz voncallee
frei. - Rufe die zuvor gespeicherte untere Adresse des Stack Frame von
caller
vom Stack ab und weise sieebp
zu. - Rufe die zuvor gespeicherte Rücksprungadresse von
caller
vom Stack ab und weise sieeip
zu, d. h. die Adresse des Befehlsmovl $0, %eax
voncaller
. caller
kehrt voncallee
zurück und fährt mit der Ausführung der nachfolgenden Befehle fort.
Der tatsächliche Betriebsprozess des Callstacks ist komplexer. Um die Diskussion in diesem Artikel zu vereinfachen, werden Details wie die Übergabe von Funktionsparametern ignoriert.
2. Die Implementierung und das Prinzip von Stackful Coroutinen (Goroutine)
Der Schlüssel zur Implementierung von Coroutinen liegt im Speichern, Wiederherstellen und Umschalten des Kontexts. Da Funktionen auf dem Callstack ausgeführt werden, liegt es nahe, dass das Speichern des Kontexts das Speichern der Werte in den zusammenhängenden Stack Frames der Funktion und ihrer verschachtelten Funktionen sowie der Werte der aktuellen Register bedeutet; die Wiederherstellung des Kontexts bedeutet, dass diese Werte wieder in die entsprechenden Stack Frames und Register geschrieben werden; das Umschalten des Kontexts bedeutet das Speichern des Kontexts der aktuell ausgeführten Funktion und das Wiederherstellen des Kontexts der nächsten auszuführenden Funktion. Stackful Coroutinen werden genau nach dieser Idee implementiert.
2.1 Die Implementierung von Stackful Coroutinen
Um Stackful Coroutinen zu implementieren, muss zunächst ein Speicherbereich zugewiesen werden, um den Kontext zu speichern. Man kann wählen, ob man den Kontext in diesen Speicherbereich kopiert oder diesen Speicherbereich direkt als Stack Frame-Speicherbereich verwendet, wenn die Coroutine läuft, um den durch das Kopieren verursachten Leistungsverlust zu vermeiden. Es ist jedoch zu beachten, dass die Größe des Speicherbereichs sinnvoll zugewiesen werden muss. Wenn er zu klein ist, kann er bei der Ausführung der Coroutine einen Stacküberlauf verursachen, und wenn er zu groß ist, wird Speicher verschwendet.
Gleichzeitig müssen auch die Werte der Register gespeichert werden. Im Function Callstack werden Register wie eax
, ecx
und edx
gemäß der Konvention vom caller
gespeichert, während Register wie ebx
, edi
und esi
vom callee
gespeichert werden. Für die aufgerufene Coroutine ist es notwendig, die Registerwerte zu speichern, die sich auf den callee
beziehen, die Werte von ebp
und esp
, die sich auf den Callstack beziehen, und die in eip
gespeicherte Rücksprungadresse.
// *(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 // Note that the stack growth direction of x86 is from high address to low address, so the addressing is an offset downward 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; }
Um die Werte der Register zu speichern und wiederherzustellen, muss Assembly-Code geschrieben werden. Angenommen, die Speicheradresse, die den Kontext speichert, wurde eax
zugewiesen, dann lautet die Speicherlogik wie folgt:
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)
Die Wiederherstellungslogik lautet wie folgt:
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)
Basierend auf dem obigen Assembly-Code kann die Funktion void swap_ctx(char **current, char **next)
erstellt werden. Durch Übergabe des von char **init_ctx(char *func)
erstellten Kontexts kann der Kontextwechsel realisiert werden. Zur Vereinfachung der Verwendung kann die Funktion swap_ctx()
auch in die Funktion yield()
gekapselt werden, um die Funktion Scheduling-Logik zu implementieren. Das Folgende ist ein vollständiges Beispiel:
#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 func, 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; }
Kompilieren Sie es mit gcc -m32 stackful.c stackful.s
und führen Sie ./a.out
aus. Die Ausführungsergebnisse zeigen, dass die Funktion nest()
tatsächlich in jeder ihrer verschachtelten Funktionen unterbrochen werden kann und dass dieselbe Funktion bei mehrfachem Aufruf in verschiedenen Stack Frame-Bereichen ausgeführt wird.
3. Die Implementierung und das Prinzip von Stackless Coroutinen
Im Gegensatz zu Stackful Coroutinen, die Stack Frames direkt umschalten, implementieren Stackless Coroutinen das Umschalten von Kontexten auf eine ähnliche Weise wie ein Generator, ohne den Function Callstack zu ändern.
Da Stackless Coroutinen den Function Callstack nicht ändern, ist es fast unmöglich, die Coroutine in einer beliebigen verschachtelten Funktion zu unterbrechen. Gerade weil es nicht notwendig ist, Stack Frames umzuschalten, haben Stackless Coroutinen in der Regel eine höhere Leistung als Stackful Coroutinen. Wie aus coroutine.h
im obigen Artikel hervorgeht, kapselt der Autor außerdem alle Variablen der Coroutine über C-Sprachmakros in eine Struktur und weist Speicherplatz für diese Struktur zu, wodurch Speicherverschwendung vermieden wird, was für Stackful Coroutinen nur schwer zu erreichen ist.
4. Coroutinen in Rust und C++ (Stackless Coroutinen)
4.1 Coroutinen in Rust
Rust unterstützt asynchrone Programmierung durch die Schlüsselwörter async
und await
, die im Wesentlichen Stackless Coroutinen sind. Die asynchrone Laufzeitumgebung von Rust (z. B. Tokio) ist für die Planung und Verwaltung dieser Coroutinen verantwortlich. Zum Beispiel:
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 }
In Rust gibt eine async
-Funktion ein Objekt zurück, das das Future
-Trait implementiert, und das Schlüsselwort await
wird verwendet, um die aktuelle Coroutine zu unterbrechen und auf den Abschluss des Future
zu warten.
4.2 Coroutinen in C++
C++20 führte die Unterstützung für Coroutinen ein und implementierte Coroutine-Funktionen über Schlüsselwörter wie co_await
, co_yield
und co_return
. Das Coroutine-Modell von C++ ist flexibler und kann bei Bedarf Stackful- oder Stackless-Coroutinen implementieren. Zum Beispiel:
#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. Schlussfolgerung
Durch eine detaillierte Analyse von Stackful- und Stackless-Coroutinen haben wir ein klareres Verständnis ihrer zugrunde liegenden Implementierungen erhalten. Obwohl Stackful- und Stackless-Coroutinen nach dem Mechanismus der Kontextspeicherung benannt sind, liegt ihr wesentlicher Unterschied darin, ob sie in einer beliebigen verschachtelten Funktion unterbrochen werden können. Dieser Unterschied bestimmt, dass Stackful-Coroutinen eine höhere Freiheit beim Unterbrechen haben und bequemer in Bezug auf die Kompatibilität mit bestehendem synchronem Code sind; während Stackless-Coroutinen, obwohl sie in der Unterbrechungsfreiheit eingeschränkt sind, eine höhere Leistung und bessere Speichermanagementfunktionen aufweisen. In der Praxis sollte der geeignete Coroutine-Typ entsprechend den spezifischen Anforderungen ausgewählt werden.
Leapcell: Das Beste vom Serverlosen Webhosting
Abschließend möchte ich eine Plattform Leapcell empfehlen, die sich am besten für die Bereitstellung von Go/Rust-Diensten eignet.
🚀 Mit Ihrer Lieblingssprache entwickeln
Mühelose Entwicklung in JavaScript, Python, Go oder Rust.
🌍 Unbegrenzte Projekte kostenlos bereitstellen
Zahlen Sie nur für das, was Sie nutzen—keine Anfragen, keine Gebühren.
⚡ Pay-as-You-Go, keine versteckten Kosten
Keine Leerlaufgebühren, nur nahtlose Skalierbarkeit.
📖 Entdecken Sie unsere Dokumentation
🔹 Folgen Sie uns auf Twitter: @LeapcellHQ