Salta al contenuto principale

01 - Modelli di Concorrenza a Confronto: Thread OS, Event Loop, Goroutine, Attori e Async/Await

Mille attese da 100 ms costano 10 secondi con 10 thread e 0,1 con un event loop; quattro calcoli su un thread JS ne costano 12. Scegli il modello misurando.

Cosa ne pensi di questo articolo?

01 - Modelli di Concorrenza a Confronto: Thread OS, Event Loop, Goroutine, Attori e Async/Await
ConcurrencyFoto:Ruben BoekelooPexels

In Breve

Mille attese da 100 ms costano 10 secondi con 10 thread e 0,1 con un event loop; quattro calcoli su un thread JS ne costano 12. Scegli il modello misurando.

Concurrency 10 min

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 modelli thread vs goroutine event loop vs thread actor model async await

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 lavoroModelli adattiPerché
Molte attese I/O (API, database)Event loop, async/await, goroutine, virtual threadLe attese si sovrappongono senza un thread per ciascuna (prova 1)
Calcolo pesanteThread di sistema, goroutine, processiServono più core (prova 2); l'event loop da solo non basta
Isolamento e tolleranza ai guastiAttori (Erlang/Elixir)Ogni processo ha stato privato e un guasto non si propaga
Team abituato a codice sequenzialeThread di sistema, virtual threadIl 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.

Prossimo nella serie Event Loop JavaScript →

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti