ForschungWarum Coroutinen funktionieren

Empirische Evidenz: Warum Single-Threaded-Coroutinen funktionieren ​

Die Behauptung, dass kooperative Nebenläufigkeit in einem einzelnen Thread für I/O-lastige Workloads effektiv ist, wird durch Messungen, akademische Forschung und Betriebserfahrung mit großen Systemen gestützt.


1. Wechselkosten: Coroutine vs. OS-Thread ​

Der Hauptvorteil von Coroutinen besteht darin, dass der kooperative Wechsel im Benutzerraum stattfindet, ohne den OS-Kernel aufzurufen.

Messungen unter Linux ​

MetrikOS-Thread (Linux NPTL)Coroutine / Async-Task
Kontextwechsel1,2–1,5 µs (gepinnt), ~2,2 µs (ungepinnt)~170 ns (Go), ~200 ns (Rust async)
Task-Erstellung~17 µs~0,3 µs
Speicher pro Task~9,5 KiB (min), 8 MiB (Standard-Stack)~0,4 KiB (Rust), 2–4 KiB (Go)
Skalierbarkeit~80.000 Threads (Test)250.000+ Async-Tasks (Test)

Quellen:

Was das in der Praxis bedeutet ​

Der Wechsel einer Coroutine kostet ~200 Nanosekunden — eine Größenordnung günstiger als der Wechsel eines OS-Threads (~1,5 µs). Aber noch wichtiger ist, dass der Coroutine-Wechsel keine indirekten Kosten verursacht: TLB-Cache-Flush, Branch-Predictor-Invalidierung, Migration zwischen Kernen — all das ist typisch für Threads, aber nicht für Coroutinen innerhalb eines einzelnen Threads.

Für eine Event-Loop, die 80 Coroutinen pro Kern verarbeitet, beträgt der gesamte Wechsel-Overhead:

80 × 200 ns = 16 µs für einen vollständigen Zyklus durch alle Coroutinen

Das ist vernachlässigbar im Vergleich zu 80 ms I/O-Wartezeit.


2. Speicher: Größenordnung der Unterschiede ​

OS-Threads allokieren einen Stack fester Größe (standardmäßig 8 MiB unter Linux). Coroutinen speichern nur ihren Zustand — lokale Variablen und den Wiederaufnahmepunkt.

ImplementierungSpeicher pro Nebenläufigkeitseinheit
Linux-Thread (Standard-Stack)8 MiB virtuell, ~10 KiB RSS Minimum
Go-Goroutine2–4 KiB (dynamischer Stack, wächst bei Bedarf)
Kotlin-Coroutinewenige Bytes auf dem Heap; Thread:Coroutine-Verhältnis ≈ 6:1
Rust-Async-Task~0,4 KiB
C++-Coroutine-Frame (Pigweed)88–408 Bytes
Python-asyncio-Coroutine~2 KiB (vs. ~5 KiB + 32 KiB Stack für einen Thread)

Quellen:

Auswirkungen auf Webserver ​

Für 640 gleichzeitige Tasks (8 Kerne × 80 Coroutinen):

  • OS-Threads: 640 × 8 MiB = 5 GiB virtueller Speicher (tatsächlich weniger durch Lazy Allocation, aber der Druck auf den OS-Scheduler ist erheblich)
  • Coroutinen: 640 × 4 KiB = 2,5 MiB (ein Unterschied von drei Größenordnungen)

3. Das C10K-Problem und reale Server ​

Das Problem ​

1999 formulierte Dan Kegel das C10K-Problem: Server mit dem Modell „ein Thread pro Verbindung" konnten 10.000 gleichzeitige Verbindungen nicht bedienen. Die Ursache waren nicht Hardware-Beschränkungen, sondern der Overhead von OS-Threads.

Die Lösung ​

Das Problem wurde durch den Übergang zu einer ereignisgesteuerten Architektur gelöst: Statt für jede Verbindung einen Thread zu erstellen, bedient eine einzelne Event-Loop Tausende von Verbindungen in einem Thread.

Genau dieser Ansatz wird von nginx, Node.js, libuv und — im PHP-Kontext — True Async umgesetzt.

Benchmarks: nginx (ereignisgesteuert) vs. Apache (Thread-pro-Anfrage) ​

Metrik (1000 gleichzeitige Verbindungen)nginxApache
Anfragen pro Sekunde (statisch)2.500–3.000800–1.200
HTTP/2-Durchsatz>6.000 req/s~826 req/s
Stabilität unter LastStabilDegradierung bei >150 Verbindungen

nginx bedient 2–4x mehr Anfragen als Apache und verbraucht dabei deutlich weniger Speicher. Apache mit Thread-pro-Anfrage-Architektur akzeptiert nicht mehr als 150 gleichzeitige Verbindungen (standardmäßig), danach warten neue Clients in einer Warteschlange.

Quellen:


4. Akademische Forschung ​

SEDA: Staged Event-Driven Architecture (Welsh et al., 2001) ​

Matt Welsh, David Culler und Eric Brewer von der UC Berkeley schlugen SEDA vor — eine Serverarchitektur basierend auf Ereignissen und Warteschlangen zwischen Verarbeitungsstufen.

Kernergebnis: Der SEDA-Server in Java übertraf Apache (C, Thread-pro-Verbindung) im Durchsatz bei 10.000+ gleichzeitigen Verbindungen. Apache konnte nicht mehr als 150 gleichzeitige Verbindungen akzeptieren.

Welsh M., Culler D., Brewer E. SEDA: An Architecture for Well-Conditioned, Scalable Internet Services. SOSP '01 (2001). PDF

Vergleich von Webserver-Architekturen (Pariag et al., 2007) ​

Der gründlichste Vergleich von Architekturen wurde von Pariag et al. an der University of Waterloo durchgeführt. Sie verglichen drei Server auf derselben Codebasis:

  • µserver — ereignisgesteuert (SYMPED, einzelner Prozess)
  • Knot — Thread-pro-Verbindung (Capriccio-Bibliothek)
  • WatPipe — hybrid (Pipeline, ähnlich wie SEDA)

Kernergebnis: Der ereignisgesteuerte µserver und der Pipeline-basierte WatPipe lieferten ~18% höheren Durchsatz als der Thread-basierte Knot. WatPipe benötigte 25 Writer-Threads, um die gleiche Leistung wie µserver mit 10 Prozessen zu erreichen.

Pariag D. et al. Comparing the Performance of Web Server Architectures. EuroSys '07 (2007). PDF

AEStream: Beschleunigte Ereignisverarbeitung mit Coroutinen (2022) ​

Eine auf arXiv veröffentlichte Studie führte einen direkten Vergleich von Coroutinen und Threads für die Verarbeitung von Streamdaten (ereignisbasierte Verarbeitung) durch.

Kernergebnis: Coroutinen lieferten mindestens 2x Durchsatz im Vergleich zu herkömmlichen Threads für die Verarbeitung von Ereignisströmen.

Pedersen J.E. et al. AEStream: Accelerated Event-Based Processing with Coroutines. (2022). arXiv:2212.10719


5. Skalierbarkeit: 100.000 Tasks ​

Kotlin: 100.000 Coroutinen in 100 ms ​

Im TechYourChance-Benchmark dauerte das Erstellen und Starten von 100.000 Coroutinen ~100 ms Overhead. Eine äquivalente Anzahl von Threads würde ~1,7 Sekunden allein für die Erstellung (100.000 × 17 µs) und ~950 MiB Speicher für Stacks benötigen.

Rust: 250.000 Async-Tasks ​

Im context-switch-Benchmark wurden 250.000 Async-Tasks in einem einzelnen Prozess gestartet, während OS-Threads ihr Limit bei ~80.000 erreichten.

Go: Millionen von Goroutinen ​

Go startet routinemäßig Hunderttausende und Millionen von Goroutinen in Produktionssystemen. Dies ermöglicht es Servern wie Caddy, Traefik und CockroachDB, Zehntausende gleichzeitige Verbindungen zu verarbeiten.


6. Zusammenfassung der Evidenz ​

BehauptungBestätigung
Coroutine-Wechsel ist günstiger als Threads~200 ns vs. ~1500 ns — 7–8x (Bendersky 2018, Blandy)
Coroutinen verbrauchen weniger Speicher0,4–4 KiB vs. 9,5 KiB–8 MiB — 24x+ (Blandy, Go FAQ)
Ereignisgesteuerter Server skaliert bessernginx 2–4x Durchsatz vs. Apache (Benchmarks)
Ereignisgesteuert > Thread-pro-Verbindung (akademisch)+18% Durchsatz (Pariag 2007), C10K gelöst (Kegel 1999)
Coroutinen > Threads für Ereignisverarbeitung2x Durchsatz (AEStream 2022)
Hunderttausende Coroutinen in einem Prozess250K Async-Tasks (Rust), 100K Coroutinen in 100ms (Kotlin)
Formel N ≈ 1 + T_io/T_cpu ist korrektGoetz 2006, Zalando, Little's Law

Referenzen ​

Messungen und Benchmarks ​

Akademische Arbeiten ​

  • Welsh M. et al. SEDA: An Architecture for Well-Conditioned, Scalable Internet Services. SOSP '01. PDF
  • Pariag D. et al. Comparing the Performance of Web Server Architectures. EuroSys '07. PDF
  • Pedersen J.E. et al. AEStream: Accelerated Event-Based Processing with Coroutines. arXiv:2212.10719

Industrieerfahrung ​

Siehe auch ​