Worker pool in Go vs goroutine-per-task per i lavori in background
Worker pool in Go vs goroutine-per-task: scopri come ogni modello influisce su throughput, uso di memoria e backpressure per l'elaborazione in background e workflow a lunga durata.

Che problema stiamo risolvendo?
La maggior parte dei servizi Go fa più che rispondere a richieste HTTP. Eseguono anche lavoro in background: inviare email, ridimensionare immagini, generare fatture, sincronizzare dati, processare eventi o ricostruire un indice di ricerca. Alcuni job sono veloci e indipendenti. Altri formano workflow lunghi in cui ogni passo dipende dal precedente (addebita una carta, aspetta conferma, poi notifica il cliente e aggiorna i report).
Quando si confrontano "Go worker pools vs goroutine-per-task", di solito si cerca di risolvere un problema di produzione: come eseguire molto lavoro in background senza rendere il servizio lento, costoso o instabile.
Lo senti in vari punti:
- Latenza: il lavoro in background ruba CPU, memoria, connessioni al database e banda di rete alle richieste rivolte all'utente.
- Costo: concorrenza incontrollata ti spinge verso macchine più grandi, più capacità DB o bollette più alte per code e API.
- Stabilità: raffiche (import, invii marketing, tempeste di retry) possono causare timeout, crash OOM o guasti a catena.
Il vero compromesso è semplicità vs controllo. Far partire una goroutine per ogni task è facile da scrivere e spesso accettabile quando il volume è basso o naturalmente limitato. Un worker pool aggiunge struttura: concorrenza fissa, limiti più chiari e un posto naturale dove mettere timeout, retry e metriche. Il costo è codice in più e una decisione su cosa succede quando il sistema è occupato (i task aspettano, vengono rifiutati o messi da parte?).
Questo riguarda l'elaborazione quotidiana in background: throughput, memoria e backpressure (come prevenire il sovraccarico). Non mira a coprire tutte le tecnologie di coda, motori di workflow distribuiti o semantiche exactly-once.
Se costruisci app complete con logica in background usando una piattaforma come AppMaster, le stesse domande emergono rapidamente. I tuoi processi di business e le integrazioni richiedono ancora limiti attorno a database, API esterne e provider di email/SMS così che un workflow impegnato non rallenti tutto il resto.
Due pattern comuni, in termini semplici
Goroutine-per-task
Questo è l'approccio più semplice: quando arriva un job, avvia una goroutine per gestirlo. La “coda” è spesso ciò che innesca il lavoro, come il ricevitore di un channel o una chiamata diretta da un handler HTTP.
Una forma tipica è: ricevi un job, poi go handle(job). A volte c'è comunque un channel, ma solo come punto di passaggio, non come limitatore.
Funziona bene quando i job passano la maggior parte del tempo in attesa I/O (chiamate HTTP, query DB, upload), il volume è modesto e le raffiche sono piccole o prevedibili.
Lo svantaggio è che la concorrenza può crescere senza un limite chiaro. Questo può causare picchi di memoria, aprire troppe connessioni o sovraccaricare un servizio downstream.
Worker pool
Un worker pool avvia un numero fisso di goroutine worker e fornisce loro job da una coda, di solito un channel bufferizzato in memoria. Ogni worker cicla: prendi un job, processalo, ripeti.
La differenza chiave è il controllo. Il numero di worker è un limite di concorrenza fisso. Se i job arrivano più velocemente di quanto i worker possano completarli, i job aspettano nella coda (o vengono rifiutati se la coda è piena).
I worker pool sono adatti quando il lavoro è intensivo sulla CPU (elaborazione immagini, generazione di report), quando hai bisogno di un uso prevedibile delle risorse o quando devi proteggere un database o un'API di terze parti dalle raffiche.
Dove risiede la coda
Entrambi i pattern possono usare un channel in memoria, che è veloce ma scompare al riavvio. Per job “da non perdere” o workflow lunghi, la coda spesso si sposta fuori dal processo (una tabella DB, Redis o un broker di messaggi). In quel setup, scegli ancora tra goroutine-per-task e worker pool, ma ora agiscono come consumer della coda esterna.
Come esempio semplice: se il sistema deve improvvisamente inviare 10.000 email, la goroutine-per-task può provare a spararle tutte subito. Un pool può inviarne 50 alla volta e tenere il resto in attesa in modo controllato.
Throughput: cosa cambia e cosa no
È comune aspettarsi una grande differenza di throughput tra worker pool e goroutine-per-task. Molto spesso, la throughput grezza è limitata da qualcos'altro, non dal modo in cui avvii le goroutine.
La throughput di solito raggiunge un tetto nella risorsa condivisa più lenta: limiti del database o API esterne, disco o banda di rete, lavoro CPU-heavy (JSON/PDF/ridimensionamento immagini), lock e stato condiviso, o servizi downstream che rallentano sotto carico.
Se una risorsa condivisa è il collo di bottiglia, lanciare più goroutine non finisce il lavoro più velocemente. Crea per lo più più attesa nello stesso punto critico.
La goroutine-per-task può vincere quando i task sono brevi, prevalentemente I/O bound e non contendono limiti condivisi. L'avvio di goroutine è economico e Go gestisce bene grandi numeri. In un ciclo “fetch, parse, scrivi una riga”, questo può tenere occupata la CPU e nascondere la latenza di rete.
I worker pool vincono quando devi limitare risorse costose. Se ogni job mantiene una connessione DB, apre file, alloca buffer grandi o consuma quota API, una concorrenza fissa mantiene il servizio stabile pur raggiungendo la massima throughput sicura.
La latenza (soprattutto p99) è dove la differenza spesso si nota. La goroutine-per-task può sembrare ottima a basso carico, poi crollare quando troppi task si accumulano. I pool introducono ritardo in coda (i job aspettano un worker libero), ma il comportamento è più regolare perché eviti che una mandria si scagli contro lo stesso limite.
Un modello mentale semplice:
- Se il lavoro è economico e indipendente, più concorrenza può aumentare la throughput.
- Se il lavoro è bloccato da un limite condiviso, più concorrenza aumenta soprattutto l'attesa.
- Se ti interessa il p99, misura il tempo in coda separatamente dal tempo di elaborazione.
Memoria e uso delle risorse
Gran parte del dibattito worker-pool vs goroutine-per-task è in realtà sulla memoria. La CPU si scala spesso su o giù. I fallimenti di memoria sono più improvvisi e possono portare giù tutto il servizio.
Una goroutine è economica, ma non gratis. Ognuna parte con uno stack piccolo che cresce se chiama funzioni più profonde o tiene variabili locali grandi. Ci sono anche costi di bookkeeping del scheduler e del runtime. Diecimila goroutine possono andare bene. Centomila possono sorprendere se ciascuna mantiene riferimenti a grandi dati di job.
Il costo nascosto più grande spesso non è la goroutine stessa, ma ciò che tiene viva. Se i task arrivano più velocemente di quanto finiscono, la goroutine-per-task crea un backlog non limitato. La “coda” può essere implicita (goroutine in attesa su lock o I/O) o esplicita (un channel bufferizzato, una slice, un batch in memoria). In ogni caso, la memoria cresce con il backlog.
I worker pool aiutano perché impongono un tetto. Con worker fissi e una coda limitata ottieni un vero limite di memoria e una modalità di fallimento chiara: una volta piena la coda, blocchi, scarichi carico o fai push upstream.
Una verifica rapida per ordine di grandezza:
- Goroutine di picco = worker + job in corso + job in attesa che hai creato
- Memoria per job = payload (byte) + metadata + tutto ciò che viene referenziato (request, JSON decodificato, righe DB)
- Memoria di backlog approssimativa ~= job in attesa * memoria per job
Esempio: se ogni job mantiene un payload da 200 KB (o riferisce un object graph da 200 KB) e permetti a 5.000 job di accumularsi, sono circa 1 GB solo per i payload. Anche se le goroutine fossero magicamente gratis, il backlog no.
Backpressure: evitare che il sistema si sciolga
Il backpressure è semplice: quando il lavoro arriva più velocemente di quanto tu possa finirlo, il sistema respinge in modo controllato invece di accumulare silenziosamente. Senza di esso non ottieni solo rallentamenti: ottieni timeout, crescita di memoria e guasti difficili da riprodurre.
Di solito noti l'assenza di backpressure quando una raffica (import, email, export) causa pattern come memoria che sale e non scende, tempo in coda che cresce mentre la CPU resta occupata, spike di latenza per richieste non correlate, retry che si accumulano, o errori come “too many open files” ed esaurimento del pool di connessioni.
Uno strumento pratico è un channel limitato: poni un tetto a quanti job possono aspettare. I producer bloccano quando il channel è pieno, rallentando la creazione di job alla sorgente.
Bloccare non è sempre la scelta giusta. Per lavori opzionali, scegli una policy esplicita così l'overload è prevedibile:
- Scartare task a basso valore (per esempio notifiche duplicate)
- Batchare molti piccoli task in una sola scrittura o in una sola chiamata API
- Ritardare il lavoro con jitter per evitare picchi di retry
- Deferire verso una coda persistente e tornare rapidamente
- Shed load con un errore chiaro quando sei già sovraccarico
Rate limiting e timeout sono anche strumenti di backpressure. Il rate limiting limita la velocità con cui colpisci una dipendenza (provider email, database, API di terze parti). I timeout limitano quanto a lungo un worker può restare bloccato. Insieme, evitano che una dipendenza lenta diventi un outage completo.
Esempio: generazione di estratti di conto a fine mese. Se 10.000 richieste arrivano contemporaneamente, goroutine illimitate possono innescare 10.000 rendering PDF e upload. Con una coda limitata e worker fissi, rendi e ritenti a un ritmo sicuro.
Come costruire un worker pool passo dopo passo
Un worker pool limita la concorrenza eseguendo un numero fisso di worker e fornendo loro job da una coda.
1) Scegli un limite di concorrenza sicuro
Parti da cosa fanno i tuoi job.
- Per lavoro CPU-heavy, tieni i worker vicino al numero di core CPU.
- Per lavoro I/O-heavy (DB, HTTP, storage), puoi andare più alto, ma fermati quando le dipendenze iniziano a timeoutare o throttling.
- Per lavoro misto, misura e regola. Un range iniziale ragionevole è spesso 2x–10x i core CPU, poi fai tuning.
- Rispetta limiti condivisi. Se il pool DB è 20 connessioni, 200 worker lotteranno solo per quelle 20.
2) Scegli la coda e imposta la sua dimensione
Un channel bufferizzato è comune perché è builtin e facile da ragionare. Il buffer è il tuo ammortizzatore per le raffiche.
Buffer piccoli espongono l'overload presto (i sender bloccano prima). Buffer grandi smorzano gli spike ma possono nascondere problemi e aumentare memoria e latenza. Dimensiona il buffer con uno scopo e decidi cosa succede quando si riempie.
3) Rendi ogni task cancellabile
Passa un context.Context in ogni job e assicurati che il codice del job lo utilizzi (DB, HTTP). Questo è il modo per fermarsi pulitamente su deploy, shutdown e timeout.
func StartPool(ctx context.Context, workers, queueSize int, handle func(context.Context, Job) error) chan<- Job {
jobs := make(chan Job, queueSize)
for i := 0; i < workers; i++ {
go func() {
for {
select {
case <-ctx.Done():
return
case j := <-jobs:
_ = handle(ctx, j)
}
}
}()
}
return jobs
}
4) Aggiungi le metriche che userai davvero
Se tracci solo pochi numeri, fallo con questi:
- Profondità della coda (quanto sei indietro)
- Tempo di occupazione dei worker (quanto è saturato il pool)
- Durata dei task (p50, p95, p99)
- Tasso di errori (e conteggio dei retry se fai retry)
Questo è sufficiente per regolare worker e dimensione della coda basandoti su evidenza, non su ipotesi.
Errori comuni e trappole
La maggior parte dei team non si danneggia scegliendo il pattern “sbagliato”. Si danneggiano per default piccoli che diventano outage quando il traffico spike.
Quando le goroutine si moltiplicano
La trappola classica è spawnare una goroutine per job sotto una raffica. Qualche centinaio va bene. Centinaia di migliaia possono intasare scheduler, heap, log e socket di rete. Anche se ogni goroutine è piccola, il costo totale si somma, e il recupero richiede tempo perché il lavoro è già in corso.
Un altro errore è trattare un channel molto bufferizzato come “backpressure”. Un buffer grande è solo una coda nascosta. Può comprare tempo, ma nasconde i problemi finché non colpisci un muro di memoria. Se ti serve una coda, dimensionala deliberatamente e decidi cosa succede quando è piena (bloccare, scartare, ritentare più tardi o persistere su storage).
Colli di bottiglia nascosti
Molti job in background non sono CPU-bound. Sono limitati da qualcosa a valle. Se ignori quei limiti, un producer veloce sopraffà un consumer lento.
Trappole comuni:
- Nessuna cancellazione o timeout, così i worker possono bloccarsi per sempre su una richiesta API o query DB
- Conteggi di worker scelti senza verificare limiti reali come connessioni DB, I/O su disco o cap di terze parti
- Retry che amplificano il carico (retry immediati su 1.000 job falliti)
- Un lock condiviso o una singola transazione che serializza tutto, quindi “più worker” aggiunge solo overhead
- Mancanza di visibilità: nessuna metrica per profondità della coda, età dei job, conteggio retry e utilizzo dei worker
Esempio: un export notturno genera 20.000 task “invia notifica”. Se ogni task colpisce il DB e un provider email, è facile superare pool di connessioni o quote. Un pool di 50 worker con timeout per job e una coda piccola rende il limite ovvio. Una goroutine per task più un buffer gigante fa sembrare il sistema sano finché non lo è più.
Esempio: export e notifiche a raffica
Immagina un team di supporto che ha bisogno di dati per un audit. Una persona clicca "Export", poi alcuni colleghi fanno lo stesso e improvvisamente hai 5.000 export creati in un minuto. Ogni export legge il DB, formatta un CSV, salva un file e invia una notifica (email o Telegram) quando è pronto.
Con un approccio goroutine-per-task il sistema sembra veloce per un attimo. Tutti i 5.000 job partono quasi istantaneamente e sembra che la coda si stia svuotando rapidamente. Poi compaiono i costi: migliaia di query concorrenti al DB competono per connessioni, la memoria sale mentre i job tengono buffer contemporaneamente e i timeout diventano comuni. Job che avrebbero potuto finire rapidamente restano bloccati dietro retry e query lente.
Con un worker pool l'avvio è più lento ma l'esecuzione è più calma. Con 50 worker solo 50 export fanno lavoro pesante alla volta. L'uso del database resta in un intervallo prevedibile, i buffer vengono riutilizzati più spesso e la latenza è più stabile. Anche il tempo totale di completamento è più facile da stimare: approssimativamente (job / worker) * durata media del job, più un po' di overhead.
La differenza chiave non è che i pool sono magicamente più veloci. È che evitano che il sistema si danneggi durante le raffiche. Un'esecuzione controllata 50-alla-volta spesso finisce prima di 5.000 job che si intralciano a vicenda.
Dove applichi il backpressure dipende da cosa vuoi proteggere:
- A livello API, rifiuta o ritarda nuove richieste di export quando il sistema è occupato.
- Alla coda, accetta richieste ma metti i job in coda e consumali a ritmo sicuro.
- Nel worker pool, limita la concorrenza per le parti costose (letture DB, generazione file, invio notifiche).
- Per risorsa, separa i limiti (per esempio 40 worker per export ma solo 10 per le notifiche).
- Nelle chiamate esterne, applica rate limit a email/SMS/Telegram così non vieni bloccato.
Checklist rapida prima del deploy
Prima di mettere in produzione i job in background, passa su limiti, visibilità e gestione dei guasti. La maggior parte degli incidenti non è causata da “codice lento”. Deriva da mancanza di guardrail quando il carico spike o una dipendenza diventa instabile.
- Imposta massimo di concorrenza per dipendenza. Non scegliere un numero globale sperando che vada bene per tutto. Limita scritture DB, chiamate HTTP in uscita e lavoro CPU-intensive separatamente.
- Rendi la coda limitata e osservabile. Metti un vero limite sui job pending ed esporta poche metriche: profondità della coda, età del job più vecchio e velocità di elaborazione.
- Aggiungi retry con jitter e una dead-letter path. Riprova selettivamente, spargi i retry e dopo N fallimenti sposta il job in una dead-letter queue o in una tabella “failed” con dettagli per riesame e replay.
- Verifica comportamento di shutdown: drain, cancel, resume in sicurezza. Decidi cosa succede su deploy o crash. Rendi i job idempotenti così il reprocessing è sicuro e salva progresso per workflow lunghi.
- Proteggi il sistema con timeout e circuit breaker. Ogni chiamata esterna ha bisogno di un timeout. Se una dipendenza è down, fail fast (o pausa l'intake) invece di accumulare lavoro.
Passi pratici successivi
Scegli il pattern che corrisponde a come è il tuo sistema in una giornata normale, non in una giornata perfetta. Se il lavoro arriva a raffiche (upload, export, invii email), un worker pool fisso con coda limitata è di solito il default più sicuro. Se il lavoro è stabile e ogni task è piccolo, la goroutine-per-task può andare bene, purché tu imponga limiti da qualche parte.
La scelta vincente è spesso quella che rende il fallimento noioso. I pool rendono i limiti ovvi. La goroutine-per-task rende facile dimenticare i limiti fino al primo picco reale.
Parti semplici, poi aggiungi limiti e visibilità
Inizia con qualcosa di lineare, ma aggiungi presto due controlli: un limite sulla concorrenza e un modo per vedere code e fallimenti.
Un piano di rollout pratico:
- Definisci la forma del carico: a raffiche, costante o mista (e cosa significa “picco”).
- Metti un tetto sul lavoro in corso (dimensione del pool, semaforo o channel limitato).
- Decidi cosa succede quando il tetto è raggiunto: blocchi, scarti o ritorni un errore chiaro.
- Aggiungi metriche base: profondità della coda, tempo in coda, tempo di elaborazione, retry e dead letters.
- Testa con un burst 5x del picco atteso e osserva memoria e latenza.
Quando un pool non basta
Se i workflow possono durare minuti o giorni, un pool semplice fatica perché il lavoro non è solo “fai e basta”. Ti serve stato, retry e possibilità di riprendere. Questo in genere significa persistere il progresso, usare passi idempotenti e applicare backoff. Può anche significare spezzare un job grande in passi più piccoli così puoi riprendere in sicurezza dopo un crash.
Se vuoi spedire un backend completo con workflow più velocemente, AppMaster (appmaster.io) può essere un'opzione pratica: modelli dati e logica business visualmente e genera codice Go reale per il backend così mantieni la stessa disciplina su limiti di concorrenza, queueing e backpressure senza collegare tutto a mano.
FAQ
Default a un worker pool quando i job possono arrivare a raffica o toccare limiti condivisi come connessioni DB, CPU o quote di API esterne. Usa una goroutine-per-task quando il volume è contenuto, i task sono brevi e hai comunque un limite chiaro da qualche parte (per esempio un semaforo o un rate limiter).
Spawnare una goroutine per task è veloce da scrivere e può avere ottima throughput a basso carico, ma sotto picchi può creare backlog non limitati. Un worker pool aggiunge un tetto di concorrenza e un posto chiaro dove applicare timeout, retry e metriche, rendendo il comportamento in produzione più prevedibile.
Di solito non molto. Nella maggior parte dei sistemi la throughput è limitata da un collo di bottiglia condiviso come il database, un'API esterna, I/O su disco o passi CPU-intensive. Più goroutine non superano quel limite; aumentano soprattutto l'attesa e la contesa.
La goroutine-per-task spesso ha latenza migliore a basso carico, ma peggiora sensibilmente ad alto carico perché tutto compete contemporaneamente. Un pool può aggiungere delay in coda, ma tende a mantenere il p99 più stabile prevenendo che troppe richieste si accaniscono sulle stesse dipendenze.
Il costo maggiore non è quasi mai la goroutine in sé, ma il backlog. Se i task si accumulano e ciascuno trattiene payload o oggetti grandi, la memoria può salire rapidamente. Un worker pool con coda limitata trasforma questo in un tetto di memoria definito e in un comportamento di overload prevedibile.
Backpressure significa rallentare o smettere di accettare nuovo lavoro quando il sistema è già occupato, invece di lasciare che il lavoro si accumuli silenziosamente. Una coda limitata è una forma semplice: quando è piena i producer bloccano o ricevono un errore, prevenendo esaurimenti di memoria e connessioni.
Parti dal limite reale. Per lavori CPU-intensive comincia vicino al numero di core CPU. Per lavori I/O-heavy puoi salire, ma fermati quando il database, la rete o le API iniziano a timeoutare o throttling, e assicurati di rispettare le dimensioni dei pool di connessioni.
Scegli una dimensione che assorba i picchi normali ma non nasconda problemi per minuti. Buffer piccoli espongono il sovraccarico presto; buffer grandi possono aumentare memoria e far aspettare gli utenti prima che si manifestino i guasti. Decidi in anticipo cosa succede quando la coda è piena: bloccare, rifiutare, scartare o persistere altrove.
Usa context.Context per ogni job e assicurati che chiamate a DB e HTTP lo rispettino. Imposta timeout sulle chiamate esterne e rendi esplicito il comportamento di shutdown così i worker possano fermarsi pulitamente senza lasciare goroutine appese o lavoro a metà.
Monitora profondità della coda, tempo in coda, durata dei task (p50/p95/p99) e conteggio di errori/retry. Queste metriche ti dicono se serve più concorrenza, una coda più piccola, timeout più stringenti o rate limiting verso una dipendenza.


