Cosa fa#
Un worker pool è un insieme fisso di unità di lavoro (worker) create una sola volta all'avvio del daemon. Ogni richiesta in arrivo viene assegnata (dispatched) a un worker libero invece di creare un nuovo processo ogni volta.
TL;DR#
daemon start → crea N worker → tutti idle (in attesa)
request arriva → dispatcher assegna a worker libero → worker lavora → torna idle
se tutti occupati → request va in queue → aspetta → se queue piena → DROPIl vecchio modello: fork-per-request#
Prima dell'adozione dei worker pool, il modello dominante era fork-per-request: per ogni richiesta in arrivo il daemon creava un nuovo processo figlio con fork(), che lavorava e poi terminava.
request → fork() → nuovo processo → lavora → muore
request → fork() → nuovo processo → lavora → muore
request → fork() → nuovo processo → lavora → muoreIl problema: fork() è costoso. Creare e distruggere un processo richiede allocazione di memoria, copia del contesto, scheduling. Sotto carico elevato, il server spende più tempo a creare processi che a servire richieste.
Esempio: Apache HTTPd in modalità prefork usa ancora questo modello — ogni connessione HTTP ottiene un processo dedicato. Funziona, ma scala male oltre un certo numero di connessioni concorrenti.
Il modello moderno: worker pool#
Il daemon crea N worker una sola volta all'avvio e li mantiene vivi per tutta la durata del processo. I worker non vengono creati per ogni richiesta — vengono assegnati.
daemon (processo principale)
├── worker #1 [idle]
├── worker #2 [occupato — sta servendo la query di Franco]
├── worker #3 [idle]
└── worker #4 [occupato — sta servendo la query di Giulia]Quando arriva una nuova richiesta, il dispatcher la consegna al primo worker idle. Quando il worker finisce, torna idle e aspetta la prossima.
Cos'è un worker#
Un worker è un'unità di esecuzione del daemon. In base all'implementazione può essere:
| Tipo | Descrizione | Esempio |
|---|---|---|
| Thread OS | Unità leggera di esecuzione condivide la memoria del processo | unbound (DNS), nginx |
| Goroutine | Thread ultra-leggero gestito dal runtime Go | CoreDNS |
| Coroutine async | Unità cooperativa, non preemptiva | resolver con asyncio |
Tutti e tre condividono la stessa idea: unità di lavoro che esiste già, aspetta una richiesta, la serve, torna disponibile. Non viene creata e distrutta ad ogni ciclo.
Idle — cosa significa davvero#
Idle (inglese: ozioso, fermo, inattivo) in ambito computing significa: not processing any work, but ready to do so.
Un worker idle non consuma CPU. Tecnicamente chiama qualcosa tipo pthread_cond_wait() — dice al kernel:
"Non ho niente da fare. Toglimi dalla CPU. Svegliami quando arriva lavoro."
Il worker esiste in memoria (ha il suo stack, le sue variabili locali), ma il kernel lo schedula fuori dalla CPU. Quando il dispatcher riceve una nuova richiesta, chiama pthread_cond_signal() → il kernel rimette il worker nella coda di scheduling → il worker si sveglia e prende il lavoro.
Costo di uno switch: microsecondi. Costo di un fork: millisecondi. La differenza diventa enorme sotto carico.
Cosa succede se tutti i worker sono occupati#
worker #1 [occupato]
worker #2 [occupato]
worker #3 [occupato]
worker #4 [occupato]
nuova request → QUEUE → aspetta un worker libero
│
se queue piena → DROPLa queue ha dimensione configurabile. Se anche la queue è piena, il comportamento dipende dal protocollo:
- TCP: il daemon rifiuta la connessione (
connection refused) - UDP (come DNS): il pacchetto viene silenziosamente droppato — il client non riceve risposta e dopo un timeout fa retry
Questo è il motivo per cui un DNS resolver sotto attacco DDoS inizia a far scadere le query: la pool è satura, la queue è piena, i pacchetti UDP vengono droppati.
Cache condivisa tra worker#
I worker di uno stesso daemon condividono la memoria del processo padre, inclusa la cache. Se il worker #2 risolve google.com → 142.250.x.x e lo salva in cache (con il TTL del record DNS), il worker #4 che riceve la stessa query un secondo dopo risponde immediatamente senza query upstream.
Franco → "google.com?" → worker #2 → upstream miss → risolve → cache → risponde
Altra gatto → "google.com?" → worker #4 → cache HIT → risponde subitoÈ il motivo per cui avvelenare la cache di un Recursive Resolver è devastante: la cache è unica e condivisa — tutti i client serviti da quel daemon ricevono la risposta falsa.
Esempio DNS: il Recursive Resolver#
Un Recursive Resolver come 8.8.8.8 o unbound usa esattamente questo modello. Il daemon gira sempre, la pool di worker aspetta query in arrivo. Quando arrivi tu con nslookup google.com:
Tu → query "google.com" (UDP 53)
└─ worker #3 dalla pool (assegnato solo a te)
├─ chiede al root NS: "chi gestisce .com?" [idle mentre aspetta]
├─ chiede al .com NS: "chi gestisce google.com?" [idle mentre aspetta]
├─ chiede al google NS: "che IP è google.com?" [idle mentre aspetta]
└─ risponde a te → torna idle in poolQuasi tutto il tempo del worker è idle — aspetta risposte dalla rete. Per questo i resolver moderni gestiscono migliaia di query concorrenti con una pool relativamente piccola: i worker passano la maggior parte del tempo ad aspettare I/O, non a calcolare.
Scenario Reale#
Un SOC analyst vede latenza anomala sulle query DNS interne. La causa è un Recursive Resolver con pool satura: un volume inaspettato di query (leak da un servizio interno mal configurato) ha riempito sia la pool che la queue. Le query UDP arrivano e vengono droppate silenziosamente — i client vedono timeout DNS. Fix: aumentare la pool size nel config di unbound, e isolare il servizio che genera il volume anomalo.
Risorse#
- unbound.conf — num-threads e so-reuseport — configurazione pool size in unbound
Collegato a#
- system — processi, thread, scheduling, fork


