Thread di sistema, event loop, goroutine, attori e async/await a confronto su due prove misurabili: mille attese da 100 ms e quattro calcoli pesanti. Quale modello scegliere dipende da quanto aspetti e da quanto calcoli.
Concorrenza e parallelismo: due problemi distinti
Un cuoco che prepara tre piatti passa dal soffritto al forno al taglio senza mai fermarsi ad aspettare: è concorrenza, cioè più attività in corso nello stesso periodo. Tre cuochi che lavorano ciascuno a un piatto nello stesso istante sono parallelismo, cioè più attività in esecuzione simultanea su più unità di calcolo. Confondere i due concetti porta a scegliere lo strumento sbagliato, perché i modelli di questo articolo risolvono problemi diversi: alcuni rendono economica l'attesa (rete, disco, database), altri sfruttano più core per il calcolo.
La domanda da farsi prima di scegliere un modello è quindi una sola: il mio programma passa il tempo ad aspettare o a calcolare? Le due prove qui sotto la rendono concreta. Tutti i listati sono stati eseguiti (OpenJDK 21.0.13, Node.js 24.11.0, Python 3.14.4) su una macchina con 12 processori logici; i tempi cambiano da una macchina all'altra e vanno letti come ordini di grandezza.
Prova 1: mille richieste che aspettano 100 ms
Una richiesta a un servizio esterno occupa la CPU per pochi microsecondi e poi aspetta. Simulo mille richieste che aspettano 100 ms ciascuna. Se le eseguo una dopo l'altra servono 100 secondi. Il risultato dipende da quante attese riesco a sovrapporre.
Thread di sistema (Java, C++)
Nel modello più tradizionale ogni attività è un thread del sistema operativo: lo schedula il kernel e può girare su un core diverso. Un pool di N thread esegue al massimo N attese contemporanee. Il listato mette alla prova un pool da 10, uno da 200 e, per confronto, un thread virtuale per richiesta (le virtual thread di Java 21, trattate nell'articolo dedicato).
import java.util.concurrent.*;
// 1000 richieste simulate, ciascuna "aspetta" 100 ms (una chiamata di rete, un database).
public class Attese {
static long esegui(ExecutorService exec) throws Exception {
long t0 = System.nanoTime();
try (exec) {
for (int i = 0; i < 1000; i++) {
exec.submit(() -> { Thread.sleep(100); return null; });
}
}
return (System.nanoTime() - t0) / 1_000_000;
}
public static void main(String[] args) throws Exception {
System.out.println("pool di 10 thread di sistema: " + esegui(Executors.newFixedThreadPool(10)) + " ms");
System.out.println("pool di 200 thread di sistema: " + esegui(Executors.newFixedThreadPool(200)) + " ms");
System.out.println("un virtual thread per richiesta: " + esegui(Executors.newVirtualThreadPerTaskExecutor()) + " ms");
}
}
// Output:
// pool di 10 thread di sistema: 10071 ms
// pool di 200 thread di sistema: 590 ms
// un virtual thread per richiesta: 189 ms
Con 10 thread le mille attese richiedono circa 10 secondi (1000 diviso 10, per 100 ms), con 200 thread circa 0,6 secondi (5 giri da 100 ms, più il costo di creare i thread), e con un virtual thread per richiesta 189 ms, poco sopra l'attesa singola. Il vincolo è il numero di thread di sistema: ciascuno ha il suo stack e il suo costo di scheduling nel kernel, quindi il pool si dimensiona con cura. È il modello più semplice da leggere, perché il codice è sequenziale, e resta la scelta naturale per il calcolo, come mostra la prova 2.
Event loop su un solo thread (JavaScript)
In JavaScript c'è un solo thread di esecuzione e un event loop: le operazioni di rete e i timer vengono delegati al sistema operativo e le loro callback rientrano nel thread quando sono pronte (il meccanismo è spiegato in event loop e microtask). Mille attese sono mille timer in volo, non mille thread.
// Le stesse 1000 richieste, un solo thread JavaScript: le attese si sovrappongono.
const attesa = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function main() {
const t0 = performance.now();
await Promise.all(Array.from({ length: 1000 }, () => attesa(100)));
console.log(`event loop, un thread: ${Math.round(performance.now() - t0)} ms`);
}
main();
// Output:
// event loop, un thread: 107 ms
Promise.all aspetta che tutte e mille le promesse si risolvano; poiché i timer corrono in parallelo
nel kernel, il totale è 107 ms, a pochi millisecondi dall'attesa singola. Il vantaggio è la scalabilità sull'attesa
con un solo thread e senza lock: nessuna race sulle variabili del programma, perché ogni callback gira
fino in fondo prima della successiva. Il prezzo è il lato opposto della stessa medaglia: un calcolo pesante
blocca tutti, come mostra la prova 2.
Async/await con un runtime (Python, Rust)
Python e Rust usano la stessa idea con una sintassi diversa: le funzioni async cedono il controllo a ogni
await e un runtime esplicito decide chi riprende. In Python è asyncio; in Rust il linguaggio
non include alcun runtime e se ne sceglie uno, per esempio Tokio (vedi
Rust async e Tokio).
import asyncio
import time
async def richiesta() -> None:
await asyncio.sleep(0.1)
async def main():
t0 = time.monotonic()
async with asyncio.TaskGroup() as tg:
for _ in range(1000):
tg.create_task(richiesta())
print(f"asyncio, un thread: {(time.monotonic() - t0) * 1000:.0f} ms")
asyncio.run(main())
# Output:
# asyncio, un thread: 113 ms
Stesso risultato dell'event loop JavaScript: 113 ms per mille attese su un thread. La struttura TaskGroup
garantisce che le mille richieste siano concluse o cancellate all'uscita del blocco (si veda
asyncio avanzato). La conseguenza di un modello
cooperativo è che un task che non fa await per molto tempo tiene fermo il resto: il ciclo cede solo dove
glielo dici tu.
Prova 2: quattro calcoli pesanti
Ora il lavoro è calcolo puro: un ciclo da 1,5 miliardi di iterazioni, ripetuto per quattro compiti uguali. Qui non c'è nulla da sovrapporre, c'è solo da dividere fra core. Il primo listato misura un compito da solo e poi quattro compiti su quattro thread di sistema.
import java.util.concurrent.*;
// Lavoro CPU: 4 compiti uguali. Con 4 thread di sistema possono girare su 4 core insieme.
public class Calcolo {
static long lavoro() {
long s = 0;
for (long i = 0; i < 1_500_000_000L; i++) s += i % 7;
return s;
}
public static void main(String[] args) throws Exception {
long t0 = System.nanoTime();
lavoro();
long uno = (System.nanoTime() - t0) / 1_000_000;
System.out.println("1 compito, 1 thread: " + uno + " ms");
t0 = System.nanoTime();
try (ExecutorService exec = Executors.newFixedThreadPool(4)) {
for (int i = 0; i < 4; i++) exec.submit(Calcolo::lavoro);
}
System.out.println("4 compiti, 4 thread: " + (System.nanoTime() - t0) / 1_000_000 + " ms");
}
}
// Output:
// 1 compito, 1 thread: 2021 ms
// 4 compiti, 4 thread: 3282 ms
Un compito da solo richiede circa 2,0 secondi, quattro compiti su quattro thread circa 3,3 secondi: se fossero in serie sarebbero stati circa 8,1 secondi (2,0 per quattro), quindi il parallelismo ha funzionato, anche se meno che perfettamente. Non so spiegare con certezza la differenza dal caso ideale (2,0 secondi): può dipendere dalla frequenza dei core o dalla compilazione JIT, e non l'ho indagata. L'ordine di grandezza è chiaro: più thread di sistema, più core usati.
// Gli stessi 4 compiti CPU sul solo thread JavaScript: si eseguono uno dopo l'altro.
function lavoro() {
let s = 0;
for (let i = 0; i < 1_500_000_000; i++) s += i % 7;
return s;
}
let t0 = performance.now();
lavoro();
console.log(`1 compito: ${Math.round(performance.now() - t0)} ms`);
t0 = performance.now();
for (let i = 0; i < 4; i++) lavoro();
console.log(`4 compiti, un thread: ${Math.round(performance.now() - t0)} ms`);
// Output:
// 1 compito: 3250 ms
// 4 compiti, un thread: 12070 ms
Sul solo thread JavaScript, quattro compiti richiedono circa 12 secondi, quasi quattro volte un compito da solo (3,25
secondi): nessun parallelismo. Questo è il limite che fa dire «Node.js non va bene per il calcolo», ed è vero solo
per il thread principale: Node mette a disposizione i worker_threads per portare il calcolo su altri
thread. Non ho provato i worker: ho misurato solo il limite.
Con asyncio, che gira anch'esso su un thread, vale lo stesso ragionamento.
Goroutine e attori: due modelli senza esempio eseguito
Per Go ed Elixir non ho il compilatore sulla macchina, quindi non riporto listati: ciò che segue viene dalla documentazione ufficiale. In Go una goroutine è, secondo Effective Go, una funzione che gira concorrentemente alle altre nello stesso spazio di indirizzi; è leggera e costa poco più dello spazio per lo stack, che parte piccolo e cresce allocando memoria dall'heap. Il runtime le multiplexa sui thread di sistema: quando una si blocca, le altre continuano. Si comunica con i canali (modello CSP), come spiegato in goroutine e channel.
Nel modello ad attori di Erlang e Elixir ogni attore è un processo con stato privato che comunica solo con messaggi, senza memoria condivisa. La guida ufficiale di Elixir dice che i processi sono estremamente leggeri in memoria e CPU, anche rispetto ai thread, tanto che avere decine o centinaia di migliaia di processi insieme non è raro. La documentazione di Erlang precisa che un processo appena creato usa 327 parole di memoria (versione OTP 27). Il vantaggio è l'isolamento: se un processo si rompe non trascina gli altri, ed è la base della supervisione. Il costo è che i dati si copiano fra processi e che il debug di molti attori che si scambiano messaggi è più difficile.
Quale modello per quale problema
| Tipo di lavoro | Modelli adatti | Perché |
|---|---|---|
| Molte attese I/O (API, database) | Event loop, async/await, goroutine, virtual thread | Le attese si sovrappongono senza un thread per ciascuna (prova 1) |
| Calcolo pesante | Thread di sistema, goroutine, processi | Servono più core (prova 2); l'event loop da solo non basta |
| Isolamento e tolleranza ai guasti | Attori (Erlang/Elixir) | Ogni processo ha stato privato e un guasto non si propaga |
| Team abituato a codice sequenziale | Thread di sistema, virtual thread | Il codice resta bloccante e leggibile |
La tabella non è una gerarchia: ogni riga è una risposta alla domanda «che cosa fa il mio programma tutto il giorno?». I modelli si combinano: un servizio Node serve le richieste su un event loop e affida il calcolo a worker; un servizio Java usa virtual thread per le attese e un pool dimensionato sui core per il calcolo. La prima cosa da fare, prima di scegliere, è misurare dove va il tempo di una richiesta tipica.
Che cosa sai fare adesso
Sai distinguere concorrenza da parallelismo, e sai riconoscere se un carico è dominato dall'attesa o dal calcolo. Hai visto in numeri che mille attese da 100 ms costano 10 secondi con 10 thread e circa 0,1-0,2 secondi con un event loop o con virtual thread, e che quattro calcoli su un solo thread JavaScript costano quasi quattro volte uno. Sai anche che Go e Elixir offrono modelli alternativi, di cui qui trovi le fonti ma non la prova.
Il prossimo articolo apre il modello dell'event loop nei dettagli: come funziona davvero l'event loop di JavaScript. Per il guasto tipico di tutti questi modelli, vedi race condition e deadlock. Per la strada opposta, evitare del tutto i lock, vedi le strutture dati lock-free. Per la concorrenza fra macchine invece che fra thread, vedi i sistemi distribuiti.
Fonti
- Effective Go - la goroutine come funzione concorrente nello stesso spazio di indirizzi, costo di poco più dello stack che cresce dall'heap, consultata il 2026-11-12
- Processes (guida di Elixir) - processi estremamente leggeri, decine o centinaia di migliaia insieme, consultata il 2026-11-12
- Erlang Efficiency Guide: Processes - un processo appena creato usa 327 parole di memoria (OTP 27), consultata il 2026-11-12
- The Node.js Event Loop - I/O non bloccante con un solo thread JavaScript, consultata il 2026-11-12
- JEP 444: Virtual Threads - thread virtuali schedulati dalla JVM su un numero ridotto di thread di sistema, consultata il 2026-11-12
I tempi (10.071 ms, 590 ms, 189 ms, 107 ms, 113 ms, 2.021 ms, 3.282 ms, 12.070 ms) provengono da una singola esecuzione su una macchina con 12 processori logici; sono ordini di grandezza, non benchmark.

Commenti
Caricamento commenti...
Accedi per commentare