Limitare la concorrenza in Go: errgroup, pool di worker e backpressure
Limita il lavoro in volo, non limitarti ad annullarlo.
Il fan-out illimitato è la modalità di fallimento predefinita nella concorrenza di Go: ogni istruzione go è un ulteriore task in esecuzione, senza limiti e senza un proprietario. Questo articolo si occupa di fornire entrambi gli elementi.
La cancellazione e la limitazione sono controlli separati. La cancellazione decide quando il lavoro si ferma; la limitazione decide quanto lavoro esiste contemporaneamente. Un servizio può gestire la cancellazione in modo perfetto e comunque crollare perché diecimila goroutine hanno deciso, indipendentemente, che ora fosse un buon momento per chiamare il database.

Le pagine attorno a questa trattano l’altra metà del problema. Go context.Context fatto bene tratta il context come il piano di controllo per fermare il lavoro; qui l’argomento è il piano di capacità — errgroup.SetLimit, canali semaforo, pool di worker e code limitate — oltre alla forma di leak che sopravvive a errgroup.Wait anche quando tutti i percorsi di cancellazione sembrano cablati.
Cinque primitive, una tabella di decisioni
| Primitiva | Risultati parziali | Fail-fast | Backpressure | Lavoro dinamico | Ideale per |
|---|---|---|---|---|---|
sync.WaitGroup + errors.Join |
sì | no | no | no | lotti wait-all in cui ogni risultato conta |
errgroup.WithContext |
no | sì | no | no | fan-out in cui il primo errore termina la richiesta |
errgroup.SetLimit(n) |
no | sì | sì (blocca Go) |
no | fan-out fail-fast limitato |
| Canale semaforo | sì | manuale | sì (blocca acquire) | sì | controllo di ammissione personalizzato |
| Pool di worker | sì | manuale | sì (la coda si riempie) | sì | flussi stabili, worker longevi |
Due domande permettono di scegliere la riga. Prima: quando un task fallisce, vuoi ancora i risultati degli altri? Se sì, la semantica di gruppo fail-fast scarta il lavoro di cui avevi bisogno. Seconda: il lavoro arriva continuamente o è un lotto fisso che puoi contare prima di lanciarlo? I gruppi gestiscono i lotti; i pool e le code gestiscono i flussi.
errors.Join è ciò che rende praticabile la riga con il semplice WaitGroup — raccoglie l’errore di ogni worker invece di conservare solo il primo, il che si abbina alle regole di traduzione dei confini in Architettura di Gestione Errori in Go.
Il leak che sopravvive a errgroup.Wait
La modalità di fallimento più nota di errgroup non ha nulla a che fare con la limitazione. È un buco di cancellazione, e si nasconde dietro codice che sembra corretto:
g, gctx := errgroup.WithContext(ctx)
results := make(chan int) // non bufferizzato
go func() { // produttore — nessuno possiede questa goroutine
for i := 0; i < 100; i++ {
results <- i
}
close(results)
}()
g.Go(func() error { // consumatore — membro del gruppo
for r := range results {
if r == 5 {
return fmt.Errorf("salvataggio fallito")
}
}
return nil
})
err := g.Wait() // ritorna sull'errore del consumatore
Quando il consumatore torna, gctx viene cancellato e Wait ritorna. Il produttore è parcheggiato su results <- i — e la cancellazione del context non sblocca un’invio su un canale. Nessuno possiede quella goroutine, quindi resta parcheggiata per tutta la vita del processo. Esegui questa forma in un loop e il leak è esattamente lineare:
| Variante | Goroutine attive dopo 500 iterazioni |
|---|---|
Invio senza un ramo ctx.Done |
501 (+500 leak, una per iterazione) |
Invio avvolto in select con <-gctx.Done() |
502 (+1 baseline) |
// la correzione: ogni operazione su un canale all'interno di un task cancellabile
// ha un ramo Done
select {
case results <- i:
case <-gctx.Done():
return
}
Lo stesso buco esiste sul lato di ricezione. La regola è meccanica: ogni operazione su un canale all’interno di codice che può essere cancellato ha un ramo <-ctx.Done(), e ogni goroutine ha un proprietario che aspetta su di essa o la cancella. goleak in CI cattura i casi che la revisione del codice lascia sfuggire — si inserisce nello stesso gate di qualità automatizzato degli strumenti in Go Linters: Strumenti Essenziali per la Qualità del Codice.
Valgono la pena di essere nominate due forme correlate. Se il produttore è un membro del gruppo, lo stesso codice produce non un leak ma un deadlock — Wait blocca per sempre aspettando l’invio parcheggiato, il che almeno fallisce in modo evidente. E un produttore che invia con select ma riceve su un canale il cui consumatore si è fermato leaka allo stesso modo; i rami Done appartengono a entrambi i lati.
errgroup.SetLimit: fan-out fail-fast limitato
SetLimit trasforma un errgroup in un gruppo con controllo di ammissione. Ogni chiamata Go oltre il limite blocca fino a quando un slot non si libera:
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, item := range items {
g.Go(func() error {
return process(ctx, item)
})
}
err := g.Wait()
Tre semantica sono importanti in produzione. Go blocca, quindi il loop sopra applica backpressure al produttore — di solito è ciò che si vuole, ma significa che la goroutine del chiamante può bloccarsi, quindi un handler di richiesta che alimenta un lotto grande ha bisogno del proprio budget di timeout attorno a tutto il loop. SetLimit(0) blocca ogni chiamata Go per sempre. E cambiare il limite mentre le goroutine sono attive provoca un panic — il limite è fisso per tutta la vita del gruppo.
SetLimit eredita la semantica di gruppo: il primo errore cancella il context e Wait restituisce il primo errore, scartando i risultati del lavoro ancora in volo. È la primitiva giusta quando fallire l’intera operazione al primo fallimento è corretto — recuperare una pagina di dati, chiamare un set di health check indipendenti, fanare una richiesta verso shard.
Canali semaforo: backpressure come funzionalità
Un canale bufferizzato di capacità n è un semaforo senza dipendenze:
sem := make(chan struct{}, 8)
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
sem <- struct{}{} // blocca quando 8 task sono in volo
go func() {
defer wg.Done()
defer func() { <-sem }()
_ = process(ctx, item)
}()
}
wg.Wait()
L’acquisizione avviene prima dell’istruzione go, quindi le goroutine vengono create solo quando esiste uno slot. La dimensione del buffer è il contratto: è simultaneamente il soffitto della concorrenza e la coda di ammissioni pendenti.
La primitiva guadagna il suo posto quando serve un controllo di ammissione che i gruppi non esprimono — acquisizione consapevole del context, pool per singolo downstream, o risultati parziali in caso di fallimento:
select {
case sem <- struct{}{}: // slot acquisito
case <-ctx.Done(): // il chiamante ha rinunciato prima di entrare
return ctx.Err()
}
Misurati sullo stesso workload, il canale semaforo e SetLimit(8) finiscono entro il 1,5% l’uno dall’altro — la scelta tra loro è di semantica, non di performance.
Pool di worker: per flussi, non per lotti
Un pool fisso di worker longevi separa l’ammissione (la coda) dall’esecuzione (i worker):
func pool(ctx context.Context, workers int) chan<- func() {
jobs := make(chan func())
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case f, ok := <-jobs:
if !ok {
return
}
f()
case <-ctx.Done():
return
}
}
}()
}
return jobs
}
I pool si adattano a lavoro che arriva continuamente — consumatori di coda, loop di polling, worker di richiesta — dove un gruppo per lotto verrebbe creato e smontato all’infinito. Da Go 1.25, sync.WaitGroup.Go rimuove la boilerplate di appaiamento Add/Done per la forma comune. Il contratto di spegnimento è la parte da gestire correttamente: chiudere jobs termina i worker dopo che la coda si è svuotata; cancellare ctx abbandona il lavoro in coda. Entrambi sono legittimi, ma solo uno può essere il comportamento documentato. Go 1.27 continua nella stessa direzione — metodi generici, nuovi modernizer go fix, e un httptest.NewTestServer in memoria — tutto coperto nella guida all’aggiornamento a Go 1.27.
Code limitate: blocco o scarto
Un canale bufferizzato è anche una coda con un soffitto. Una volta che il buffer si riempie, il produttore blocca — e quel momento è la decisione di design. Il blocco propaga la pressione a monte a chi alimenta la coda; lo scarto allevia il carico a costo di lavoro perduto. Un pipeline di monitoraggio può scartare; un pipeline di ordini deve bloccarsi o riversare su storage durevole.
select {
case queue <- event: // ammesso
default: // coda piena: la politica di scarto vive qui
dropped.Add(1)
}
Qualsiasi sia la politica, rendila esplicita e misurata. Un ramo default silenzioso è il posto dove gli eventi vanno a scomparire.
Cosa costa la limitazione — misurato
Lo stesso workload, 10.000 task di 5 ms di I/O simulato ciascuno, Go 1.27.1:
| Strategia | Goroutine di picco | Tempo reale |
|---|---|---|
go illimitato per task |
~10.000 lanciate | 18 ms |
errgroup.SetLimit(8) |
8 | 6,9 s |
| Canale semaforo (cap 8) | 8 | 6,6 s |
L’esecuzione illimitata vince sul tempo reale perché nulla nell’esperimento fa pressione a ritroso — diecimila goroutine timer semplicemente dormono in parallelo. È esattamente la trappola. Il costo del fan-out illimitato non è mai la CPU nel tuo stesso processo; è diecimila connessioni simultanee, un downstream che inizia a timeout sotto il burst, e un grafico della memoria che segue la profondità della coda di chi stai chiamando. Su una dipendenza reale, l’esecuzione illimitata non finisce in 18 ms — va in timeout. Le esecuzioni limitate pagano 6,6 secondi perché 10.000 task ÷ 8 slot × 5 ms sono 6,25 s di serializzazione inevitabile, e il soffitto è il punto: trasforma un burst incontrollato in uno scarico prevedibile di 6,25 secondi.
Una raffina ha importanza quando il soffitto è più alto di un pugno di slot: mantieni il soffitto a o sotto ciò che il downstream può sostenere in concorrenza, e derivalo da quel limite (dimensione del connection pool, rate limit, capacità dei worker) piuttosto che da una sensazione sul parallelismo “ragionevole”.
Misurazione e test del codice limitato
Tre segnali coprono la maggior parte. Le tendenze del conteggio delle goroutine (/sched/goroutines:goroutines esportato via il collector Prometheus Go) catturano i leak come una pendenza. La latenza del scheduler (/sched/latencies:seconds) cattura la saturazione — le goroutine eseguibili in attesa di tempo CPU è pressione reale indipendentemente dal conteggio. Un delta pprof di due dump delle goroutine punta il luogo dove vivono le goroutine parcheggiate.
Per i test, i worker limitati sono il caso per cui Testare Codice Go Concorrente con synctest è stato costruito: il tempo finto fa eseguire uno scarico completo di 10.000 task in millisecondi, synctest.Wait sostituisce il dormi-e-spera per la quiescenza, e una bolla fallisce rapidamente quando un worker resta bloccato durabilmente — la riproduzione del leak sopra muore in una bolla di test invece che in produzione.
A livello di servizio, la stessa domanda di limitazione ricompare tra i servizi, dove il semaforo diventa un rate limit e il circuit breaker protegge il downstream — Pattern Circuit Breaker in Go copre quel livello, e Go Microservices per Orchestrazione AI/ML mostra le forme basate su coda che lo tier di orchestrazione assume.
Scelta
al primo fallimento?] D -- sì --> E[WaitGroup + errors.Join
o canale semaforo] D -- no --> F[errgroup.WithContext] F --> G[Serve un soffitto di concorrenza?] G -- sì --> H[errgroup.SetLimit] G -- no --> I[gruppo senza limite
solo quando il lotto è provabilmente piccolo] C --> J[Coda piena: bloccare o scartare?] J -- bloccare --> K[Backpressure a monte] J -- scartare --> L[Allevia il carico, conta gli scarti]
La tabella e il flowchart si comprimono in un’abitudine: prima di scrivere go, nomina il soffitto e il proprietario. Il soffitto è SetLimit, un semaforo, o una coda limitata; il proprietario è un Wait, un ramo ctx.Done su ogni operazione di canale, o entrambi. Ogni punto di creazione di goroutine che può rispondere a quei due nomi a prima vista è uno che non chiamerà nessuno alle 3 di notte.