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

Metrik OS-Thread (Linux NPTL) Coroutine / Async-Task
Kontextwechsel 1,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.

Implementierung Speicher pro Nebenläufigkeitseinheit
Linux-Thread (Standard-Stack) 8 MiB virtuell, ~10 KiB RSS Minimum
Go-Goroutine 2–4 KiB (dynamischer Stack, wächst bei Bedarf)
Kotlin-Coroutine wenige 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):


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) nginx Apache
Anfragen pro Sekunde (statisch) 2.500–3.000 800–1.200
HTTP/2-Durchsatz >6.000 req/s ~826 req/s
Stabilität unter Last Stabil Degradierung 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:

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

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

Referenzen

Messungen und Benchmarks

Akademische Arbeiten

Industrieerfahrung

Siehe auch