Avviare una goroutine
Metti go davanti a una chiamata di funzione e quella chiamata viene eseguita in modo concorrente. L'istruzione ritorna subito; chi chiama non aspetta.
Quattro goroutine eseguono shout nello stesso momento. L'ordine in cui finiscono non è definito, ma l'output è sempre nell'ordine originale, perché ogni goroutine scrive nel proprio indice e main legge la slice solo dopo che wg.Wait() è tornato.
Quell'esempio contiene già le tre cose di cui ha bisogno quasi ogni programma con goroutine: un modo per avviare il lavoro (go), un modo per aspettarlo (sync.WaitGroup) e un modo per riavere i risultati senza che due goroutine tocchino la stessa memoria (un elemento della slice ciascuna).
Main non aspetta
Quando main ritorna, il programma termina. Le goroutine ancora in esecuzione vengono fermate ovunque si trovino. Niente le aspetta.
Di solito stampa solo from main. A volte la goroutine viene schedulata in tempo e vedi entrambe le righe. Quel "di solito" è il problema: codice che funziona sul tuo computer e fallisce su un server carico.
Un time.Sleep alla fine di main fa stampare entrambe le righe alla demo, ed è la correzione sbagliata. Tira a indovinare quanto dura il lavoro. Aspetta il lavoro stesso, con un WaitGroup (la pagina su WaitGroup lo tratta nel dettaglio) o con un channel.
Ottenere i risultati
Un'istruzione go butta via i valori di ritorno della funzione. x := go f() non compila. Ci sono due modi standard per restituire dati.
Uno slot per goroutine, come nel primo esempio. Dimensiona prima una slice, dai a ogni goroutine il suo indice, leggi dopo Wait. L'ordine è preservato e non serve nessun lock, perché nessuna coppia di goroutine scrive lo stesso elemento.
Un channel. Ogni goroutine invia il suo risultato; chi riceve li raccoglie. I risultati arrivano in ordine di completamento, non di avvio.
Ricevere esattamente len(nums) valori fa anche da attesa: main non può superare il ciclo finché ogni goroutine non ha inviato. L'ordine di arrivo cambia da un'esecuzione all'altra, quindi il programma ordina prima di stampare qualsiasi cosa dipenda dall'ordine. La pagina sui channel tratta i channel bufferizzati, la chiusura e range su un channel.
Variabili del ciclo e closure (novità di Go 1.22)
Da Go 1.22, ogni iterazione di un ciclo for riceve una copia nuova delle variabili del ciclo. Una closure avviata in una goroutine cattura il valore di quella iterazione, quindi questo è corretto:
for i, w := range words {
go func() {
results[i] = shout(w) // Go 1.22+: i and w belong to this iteration
}()
}
Prima di Go 1.22, tutte le iterazioni condividevano una sola i e una sola w, e ogni goroutine tendeva a vedere l'ultimo valore. Il codice vecchio lo aggira passando i valori come argomenti, go func(i int, w string) { ... }(i, w), oppure con lo shadowing, i := i. Entrambi sono innocui da Go 1.22 in poi, e li vedrai ancora nel codice esistente. Il nuovo comportamento si applica quando il go.mod del modulo dice go 1.22 o superiore.
Le goroutine costano poco
Una goroutine parte con uno stack piccolo (pochi kilobyte) che il runtime fa crescere e ridurre quando serve. Lo scheduler di Go esegue le goroutine su un pool di thread del sistema operativo, al massimo GOMAXPROCS dei quali eseguono codice Go nello stesso momento, e per default GOMAXPROCS è uguale al numero di CPU. Bloccarsi su un channel, un mutex, uno sleep o su I/O di rete parcheggia la goroutine e libera il thread per un'altra.
Quindi avviare una goroutine per ogni compito va bene anche con numeri grandi:
Centomila goroutine finiscono in una frazione di secondo. La somma è sempre 4999950000, perché atomic.Int64 rende ogni addizione indivisibile. Costare poco però non vuol dire essere gratis: ogni goroutine ancora bloccata tiene in vita il suo stack e tutto ciò a cui fa riferimento.
| Thread del sistema operativo | Goroutine | |
|---|---|---|
| Creato da | il kernel | il runtime di Go |
| Stack iniziale | fisso, spesso 1 MB o più | pochi KB, cresce su richiesta |
| Cambio di contesto | context switch del kernel | scheduler di Go, in user space |
| Identità | ha un ID di thread | nessun ID leggibile, per scelta |
| Numero tipico | centinaia | da migliaia a milioni |
Data race
Due goroutine che accedono alla stessa variabile nello stesso momento, con almeno una delle due che scrive, sono una data race. Il risultato è imprevedibile, non solo "un po' sbagliato": gli aggiornamenti vanno persi, e una race su una stringa, una slice, una map o un valore interface può far crashare il programma o corrompere la memoria.
Su una macchina multi-core, nella maggior parte delle esecuzioni stampa un numero diverso e minore di 10000, perché due goroutine leggono lo stesso vecchio valore e scrivono entrambe quel valore più uno. Su un solo core può stampare 10000, che è peggio: il bug passa il tuo test e si presenta in produzione.
Go include un race detector. Esegui il programma o i test con -race:
go run -race main.go
go test -race ./...
==================
WARNING: DATA RACE
Read at 0x00c000090038 by goroutine 8:
main.main.func1()
/tmp/race/main.go:16 +0x94
Previous write at 0x00c000090038 by goroutine 6:
main.main.func1()
/tmp/race/main.go:16 +0xa4
...
Found 2 data race(s)
exit status 66
Indica la riga esatta (counter++) ed entrambe le goroutine. Segnala solo le race che avvengono davvero durante l'esecuzione, quindi eseguilo su test che attraversano i percorsi concorrenti. Rallenta il programma di diverse volte, quindi è per i test e lo staging, non per la produzione.
Le soluzioni, dalla più semplice alla più generale:
- Non condividere. Dai a ogni goroutine i suoi dati e combinali alla fine (lo schema a uno slot per goroutine).
- Usa
sync/atomicper un singolo contatore o flag:var n atomic.Int64; n.Add(1). - Usa un
sync.Mutexattorno a qualsiasi cosa più grande, come una map o una struct con diversi campi. La pagina sul mutex tratta ancheRWMutexesync.Once. - Invia i dati su un channel così che una sola goroutine alla volta ne sia proprietaria.
Un panic in una goroutine uccide il programma
Se una goroutine va in panic e niente la recupera dentro quella stessa goroutine, crasha l'intero programma, compresi main e ogni altra goroutine. Un recover in main non aiuta, perché recover cattura solo i panic della propria goroutine.
Recuperare così ha senso ai margini di un server che gira a lungo, dove una richiesta sbagliata non deve buttare giù tutto il resto. Nel codice normale, un panic di solito indica un bug, e crashare in modo evidente è l'esito giusto.
Goroutine leak
Una goroutine che resta bloccata per sempre non termina mai e non libera mai la sua memoria. La causa classica è un invio che nessuno riceverà mai:
func firstResult(urls []string) string {
ch := make(chan string) // unbuffered
for _, u := range urls {
go func() { ch <- fetch(u) }()
}
return <-ch // takes the first result; the other senders block forever
}
Ogni chiamata lascia appese len(urls) - 1 goroutine. In un server che gestisce questa richiesta migliaia di volte, la memoria sale finché il processo muore. Due soluzioni: rendi il channel abbastanza grande perché ogni mittente possa finire (make(chan string, len(urls))), oppure dai alle goroutine un modo per rinunciare, di solito un context.Context più un select su ctx.Done(). Puoi tenere d'occhio i leak con runtime.NumGoroutine() nei test.
Limitare quante ne girano insieme
"Una goroutine per elemento" va bene per 10.000 calcoli leggeri. Non va bene per 10.000 richieste HTTP allo stesso server o 10.000 file aperti. Metti un tetto alla concorrenza con un channel bufferizzato usato come semaforo:
Il channel bufferizzato contiene al massimo 3 gettoni, quindi in ogni momento al massimo 3 goroutine hanno superato la riga sem <-. Il picco non può mai superare 3, e con dodici compiti che dormono ciascuno in pratica arriva a 3. Un pool fisso di goroutine worker che leggono da un channel di job è l'altra forma comune; la pagina su WaitGroup ne costruisce uno.
Fuori dalla libreria standard, golang.org/x/sync/errgroup combina in un solo tipo un WaitGroup, il primo errore, la cancellazione tramite context e un limite di concorrenza (g.SetLimit(n)). È la scelta abituale nel codice di produzione che ha bisogno di tutti e quattro.
Errori comuni
- Dimenticare di aspettare.
mainritorna e il lavoro, senza avvisi, non avviene mai. Ogni istruzionegoha bisogno di un modo corrispondente per sapere che ha finito. - Chiamare
wg.Adddentro la goroutine.Waitpotrebbe essere eseguito prima diAdd, vedere il contatore a zero e tornare in anticipo. ChiamaAddprima dell'istruzionego. - Condividere una variabile senza sincronizzazione. Le map sono il caso tipico: le scritture concorrenti su una map di solito vengono rilevate dal runtime e fanno crashare il programma con
fatal error: concurrent map writes, cherecovernon può catturare. - Dare per scontato un ordine. Le goroutine girano nell'ordine che sceglie lo scheduler. Se l'output deve essere ordinato, raccogli e ordina, oppure scrivi in slot indicizzati.
- Usare
time.Sleepper sincronizzare. Rende i test lenti e comunque instabili. Aspetta l'evento, non una stima. - Avviare una goroutine senza un modo per fermarla. Tutto ciò che cicla o aspetta I/O dovrebbe ricevere un
context.Contextcosì che chi chiama possa cancellarlo.
Domande frequenti
Cos'è una goroutine in Go?
Una goroutine è una chiamata di funzione che viene eseguita in modo concorrente con il resto del programma. Ne avvii una mettendo go davanti alla chiamata: go work(). Le goroutine sono gestite dal runtime di Go, non dal sistema operativo, e il runtime ne distribuisce molte su un piccolo numero di thread del sistema operativo, quindi avviarne migliaia è normale.
Come aspetto che le goroutine finiscano in Go?
Usa un sync.WaitGroup: chiama wg.Add(1) prima di ogni istruzione go, defer wg.Done() all'inizio della goroutine e wg.Wait() dove ti serve che abbiano finito tutte. Se le goroutine producono valori, anche ricevere un valore per goroutine da un channel funziona come attesa.
Qual è la differenza tra una goroutine e un thread?
Un thread del sistema operativo ha uno stack fisso (spesso 1 MB o più) ed è schedulato dal kernel. Una goroutine parte con uno stack di pochi kilobyte che cresce quando serve, e lo scheduler di Go passa da una goroutine all'altra in user space. Il runtime esegue le goroutine su al massimo GOMAXPROCS thread contemporaneamente (per default, il numero di CPU).
Come ottengo un valore di ritorno da una goroutine?
Un'istruzione go scarta i valori di ritorno della funzione. Invia il risultato su un channel (results <- compute(x)) oppure scrivilo nel tuo slot di una slice già dimensionata (out[i] = compute(x)) e leggilo dopo wg.Wait().
Perché il mio programma Go termina prima che la goroutine stampi qualcosa?
Quando main ritorna, il programma finisce e ogni altra goroutine viene fermata senza eseguire il codice rimanente. Niente aspetta le goroutine in automatico. Blocca main finché il lavoro non è finito con un WaitGroup o una ricezione da un channel. Aggiungere time.Sleep nasconde solo il problema.