Evidenza empirica: perche’ le coroutine single-threaded funzionano

L’affermazione che la concorrenza cooperativa single-threaded sia efficace per i carichi di lavoro IO-bound e’ supportata da misurazioni, ricerca accademica ed esperienza operativa con sistemi su larga scala.


1. Costo del cambio di contesto: coroutine vs thread del sistema operativo

Il principale vantaggio delle coroutine e’ che il cambio cooperativo avviene nello spazio utente, senza invocare il kernel del sistema operativo.

Misurazioni su Linux

Metrica Thread del SO (Linux NPTL) Coroutine / task asincrono
Context switch 1,2–1,5 µs (pinned), ~2,2 µs (unpinned) ~170 ns (Go), ~200 ns (Rust async)
Creazione del task ~17 µs ~0,3 µs
Memoria per task ~9,5 KiB (min), 8 MiB (stack predefinito) ~0,4 KiB (Rust), 2–4 KiB (Go)
Scalabilita’ ~80.000 thread (test) 250.000+ task asincroni (test)

Fonti:

Cosa significa in pratica

Il cambio di una coroutine costa ~200 nanosecondi — un ordine di grandezza piu’ economico del cambio di un thread del sistema operativo (~1,5 µs). Ma ancora piu’ importante, il cambio di coroutine non comporta costi indiretti: flush della cache TLB, invalidazione del branch predictor, migrazione tra core — tutti questi sono caratteristici dei thread, ma non delle coroutine all’interno di un singolo thread.

Per un event loop che gestisce 80 coroutine per core, l’overhead totale del cambio e’:

80 × 200 ns = 16 µs per un ciclo completo attraverso tutte le coroutine

Questo e’ trascurabile rispetto a 80 ms di tempo di attesa I/O.


2. Memoria: ordine di grandezza delle differenze

I thread del sistema operativo allocano uno stack di dimensione fissa (8 MiB per impostazione predefinita su Linux). Le coroutine memorizzano solo il loro stato — variabili locali e il punto di ripresa.

Implementazione Memoria per unita’ di concorrenza
Thread Linux (stack predefinito) 8 MiB virtuali, ~10 KiB RSS minimo
Goroutine Go 2–4 KiB (stack dinamico, cresce secondo necessita’)
Coroutine Kotlin decine di byte sull’heap; rapporto thread:coroutine ≈ 6:1
Task asincrono Rust ~0,4 KiB
Frame coroutine C++ (Pigweed) 88–408 byte
Coroutine Python asyncio ~2 KiB (vs ~5 KiB + 32 KiB stack per un thread)

Fonti:

Implicazioni per i web server

Per 640 task concorrenti (8 core × 80 coroutine):


3. Il problema C10K e i server reali

Il problema

Nel 1999, Dan Kegel formulo’ il problema C10K: i server che utilizzavano il modello “un thread per connessione” non erano in grado di servire 10.000 connessioni simultanee. La causa non erano le limitazioni hardware, ma l’overhead dei thread del sistema operativo.

La soluzione

Il problema fu risolto con la transizione a un’architettura event-driven: invece di creare un thread per ogni connessione, un singolo event loop serve migliaia di connessioni in un thread.

Questo e’ esattamente l’approccio implementato da nginx, Node.js, libuv e — nel contesto PHP — True Async.

Benchmark: nginx (event-driven) vs Apache (thread-per-request)

Metrica (1000 connessioni concorrenti) nginx Apache
Richieste al secondo (statiche) 2.500–3.000 800–1.200
Throughput HTTP/2 >6.000 req/s ~826 req/s
Stabilita’ sotto carico Stabile Degradazione a >150 connessioni

nginx serve 2–4x piu’ richieste di Apache, consumando significativamente meno memoria. Apache con architettura thread-per-request accetta non piu’ di 150 connessioni simultanee (per impostazione predefinita), dopo di che i nuovi client attendono in coda.

Fonti:


4. Ricerca accademica

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

Matt Welsh, David Culler ed Eric Brewer dell’UC Berkeley proposero SEDA — un’architettura server basata su eventi e code tra le fasi di elaborazione.

Risultato chiave: Il server SEDA in Java supero’ Apache (C, thread-per-connection) in throughput con 10.000+ connessioni simultanee. Apache non riusciva ad accettare piu’ di 150 connessioni simultanee.

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

Confronto di architetture di web server (Pariag et al., 2007)

Il confronto piu’ approfondito delle architetture fu condotto da Pariag et al. dell’Universita’ di Waterloo. Confrontarono tre server sulla stessa base di codice:

Risultato chiave: Il µserver event-driven e il WatPipe basato su pipeline hanno fornito un throughput superiore del ~18% rispetto al Knot basato su thread. WatPipe necessitava di 25 thread writer per raggiungere le stesse prestazioni del µserver con 10 processi.

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

AEStream: elaborazione accelerata di eventi con coroutine (2022)

Uno studio pubblicato su arXiv ha condotto un confronto diretto tra coroutine e thread per l’elaborazione di dati in streaming (elaborazione basata su eventi).

Risultato chiave: Le coroutine hanno fornito almeno il doppio del throughput rispetto ai thread convenzionali per l’elaborazione di stream di eventi.

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


5. Scalabilita’: 100.000 task

Kotlin: 100.000 coroutine in 100 ms

Nel benchmark di TechYourChance, la creazione e il lancio di 100.000 coroutine ha richiesto ~100 ms di overhead. Un numero equivalente di thread richiederebbe ~1,7 secondi solo per la creazione (100.000 × 17 µs) e ~950 MiB di memoria per gli stack.

Rust: 250.000 task asincroni

Nel benchmark context-switch, 250.000 task asincroni sono stati lanciati in un singolo processo, mentre i thread del sistema operativo hanno raggiunto il loro limite a ~80.000.

Go: milioni di goroutine

Go lancia abitualmente centinaia di migliaia e milioni di goroutine nei sistemi di produzione. Questo e’ cio’ che permette a server come Caddy, Traefik e CockroachDB di gestire decine di migliaia di connessioni simultanee.


6. Riepilogo delle evidenze

Affermazione Conferma
Il cambio di coroutine e’ piu’ economico dei thread ~200 ns vs ~1500 ns — 7–8x (Bendersky 2018, Blandy)
Le coroutine consumano meno memoria 0,4–4 KiB vs 9,5 KiB–8 MiB — 24x+ (Blandy, Go FAQ)
Il server event-driven scala meglio nginx 2–4x throughput vs Apache (benchmark)
Event-driven > thread-per-connection (accademicamente) +18% throughput (Pariag 2007), C10K risolto (Kegel 1999)
Coroutine > thread per elaborazione eventi 2x throughput (AEStream 2022)
Centinaia di migliaia di coroutine in un processo 250K task asincroni (Rust), 100K coroutine in 100ms (Kotlin)
La formula N ≈ 1 + T_io/T_cpu e’ corretta Goetz 2006, Zalando, Legge di Little

Riferimenti

Misurazioni e benchmark

Articoli accademici

Esperienza industriale

Vedi anche