Skip to main content
  1. Concetti/

Worker Pool — come i daemon gestiscono richieste in parallelo

·5 mins
Alessio Barnini
Author
Alessio Barnini
Table of Contents

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 → DROP

Il 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 → muore

Il 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:

TipoDescrizioneEsempio
Thread OSUnità leggera di esecuzione condivide la memoria del processounbound (DNS), nginx
GoroutineThread ultra-leggero gestito dal runtime GoCoreDNS
Coroutine asyncUnità cooperativa, non preemptivaresolver 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 → DROP

La 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 pool

Quasi 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
#

Collegato a
#

  • system — processi, thread, scheduling, fork

Related