Metti go davanti a una chiamata di funzione e quella funzione parte in concorrenza col resto del programma, gestita dal runtime di Go invece che dal sistema operativo. Avviarla è la parte facile. La parte difficile è far passare un risultato tra due goroutine senza che si pestino i piedi a vicenda sullo stato condiviso, e quel lavoro lo fanno i channel.

Cos’è una goroutine?

Una goroutine è una funzione che corre in modo indipendente da quella che l’ha avviata, gestita dal runtime di Go e non dal sistema operativo. Un thread del sistema operativo riserva un megabyte o più di stack e costa al kernel tempo reale per essere pianificato. Una goroutine parte con uno stack di circa 2KB che cresce secondo necessità, e il runtime moltiplexa molte goroutine su una manciata di thread del sistema operativo. Avviarne a migliaia è normale.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package main

import (
	"fmt"
	"time"
)

func sayHi(name string) {
	fmt.Println("hi,", name)
}

func main() {
	go sayHi("alice")
	go sayHi("bob")
	time.Sleep(10 * time.Millisecond)
	fmt.Println("done")
}

Esegui questo codice un po’ di volte e l’ordine di “hi, alice” e “hi, bob” cambia. Decide lo scheduler. time.Sleep serve solo a tenere in vita main finché le goroutine non hanno stampato: toglilo e il programma può terminare prima che una delle due venga eseguita, perché quando main ritorna il processo finisce comunque, qualsiasi altra cosa sia ancora in corso. Lo sleep è un trucco, non una sincronizzazione. Un channel è quello che lo sostituisce.

Come collegano le goroutine i channel?

Un channel è una pipe tipizzata: una goroutine ci manda dentro un valore, un’altra lo riceve, e il runtime gestisce il passaggio di mano. Immagina un nastro trasportatore a una sola corsia tra due operai. Chi invia mette un oggetto sul nastro e non può metterne un secondo finché il primo non è stato preso. Questo è un channel non bufferizzato, e quel passaggio di mano bloccante è l’intero meccanismo di sincronizzazione.

1
2
3
4
5
6
7
8
ch := make(chan string)

go func() {
	ch <- "payload ready"
}()

msg := <-ch
fmt.Println(msg)

make(chan string) crea un channel di stringhe non bufferizzato. L’invio (ch <- "payload ready") si blocca finché qualcosa non riceve, e la ricezione (<-ch) si blocca finché qualcosa non invia. Nessuno dei due lati si muove finché entrambi non sono pronti, ed è proprio il passaggio di mano che il time.Sleep di prima stava solo simulando.

Dai al channel una capacità e il nastro regge più di un oggetto prima di incepparsi:

1
2
3
4
5
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
// a 4th send here would block until something receives

L’invio di un channel bufferizzato si blocca solo quando il buffer è pieno; la ricezione si blocca solo quando il buffer è vuoto. Questo allenta l’accoppiamento tra chi invia e chi riceve, il che aiuta ad assorbire picchi di lavoro. Non elimina la necessità di coordinarsi. Sposta solo il punto in cui inizia il blocco.

Come si chiude un channel e si fa range su di esso?

Chi invia chiude un channel per dire “non arriva più niente”:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func producer(ch chan<- int) {
	for i := 1; i <= 3; i++ {
		ch <- i
	}
	close(ch)
}

ch := make(chan int)
go producer(ch)
for v := range ch {
	fmt.Println(v) // 1, 2, 3, then the loop exits
}

for v := range ch continua a ricevere finché il channel non è chiuso e svuotato, poi esce da solo. Nessun valore sentinella, nessun conteggio manuale. Per distinguere un channel chiuso e vuoto da uno che ha consegnato un valore zero, usa la forma a due valori: v, ok := <-ch imposta ok a false solo quando il channel è chiuso e non ha più nulla da dare.

close ha tre modi per andare in panic, e il compilatore non ne intercetta nessuno:

  • Inviare su un channel chiuso va in panic.
  • Chiudere un channel già chiuso va in panic.
  • Chiudere un channel nil va in panic.

Quindi a chiudere è sempre chi invia, mai chi riceve. Chi riceve non ha modo di sapere se un’altra goroutine ha ancora intenzione di inviare.

Come si aspetta che più goroutine finiscano?

range funziona quando un channel segnala la fine del lavoro. Quando hai avviato diverse goroutine e ti serve solo sapere che hanno finito tutte, usa sync.WaitGroup:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
	"fmt"
	"sync"
)

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Printf("worker %d done\n", id)
}

func main() {
	var wg sync.WaitGroup
	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go worker(i, &wg)
	}
	wg.Wait()
	fmt.Println("all workers finished")
}

Add(1) incrementa il contatore prima che ogni goroutine parta, Done() lo decrementa (con defer, quindi viene eseguito anche se la funzione va in panic), e Wait() si blocca finché il contatore non arriva a zero. Sposta Add dentro la goroutine e hai scritto una race: main può arrivare a Wait(), trovare il contatore a zero, e ritornare prima che un solo worker l’abbia incrementato.

Come funziona select con più channel?

select aspetta su più operazioni di channel contemporaneamente ed esegue quella che è pronta per prima. Timeout e cancellazione in Go si basano su questo.

1
2
3
4
5
6
7
8
select {
case res := <-resultCh:
	fmt.Println("got result:", res)
case err := <-errCh:
	fmt.Println("failed:", err)
case <-time.After(2 * time.Second):
	fmt.Println("timed out waiting for a result")
}

Se più di un case è pronto, select ne sceglie uno a caso, non nell’ordine in cui è scritto, quindi l’ordine dei case non dà alcuna priorità. Senza default e senza case pronti, select si blocca; un select{} vuoto si blocca per sempre. Aggiungi un case default e l’intera istruzione diventa non bloccante: se in questo momento non c’è niente di pronto, viene eseguito default.

Un worker pool mette insieme quasi tutto questo. Un numero fisso di goroutine preleva job da un channel e scrive risultati su un altro, così il lavoro concorrente resta limitato indipendentemente da quanti job arrivano.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func worker(id int, jobs <-chan int, results chan<- int) {
	for j := range jobs {
		results <- j * j
	}
}

func main() {
	jobs := make(chan int, 100)
	results := make(chan int, 100)

	for w := 1; w <= 3; w++ {
		go worker(w, jobs, results)
	}

	for j := 1; j <= 9; j++ {
		jobs <- j
	}
	close(jobs)

	for i := 0; i < 9; i++ {
		fmt.Println(<-results)
	}
}

Tre worker, nove job. close(jobs) dopo l’ultimo invio permette al loop range di ogni worker di uscire una volta che la coda si svuota. Le firme usano jobs <-chan int (solo ricezione) e results chan<- int (solo invio). La freccia indica la direzione e il compilatore la fa rispettare, quindi un worker che per errore invia su jobs non compila invece di comportarsi male a runtime.

Perché le goroutine vanno in leak o in deadlock?

Leak di goroutine. Una goroutine bloccata per sempre su un invio o una ricezione che non accadrà mai non va in crash. Resta lì, trattenendo il suo stack e tutto quello che ha catturato per closure, per tutta la vita del programma. La causa più comune è una goroutine che manda un risultato su un channel che nessuno legge più, perché chi doveva riceverlo ha già rinunciato dopo un timeout. Dai alla goroutine una via d’uscita, di solito un context.Context su cui fa anche select, così può abbandonare l’invio.

Deadlock. fatal error: all goroutines are asleep - deadlock! significa che ogni goroutine, main incluso, è bloccata su un channel o un lock senza più niente che possa sbloccarla. L’esempio più piccolo è un invio non bufferizzato in main senza nessun’altra goroutine in esecuzione pronta a riceverlo.

Race che i channel non prevengono. Un channel sincronizza il passaggio di mano di un valore. Non protegge una variabile che continui a toccare fuori dal channel. Due goroutine che incrementano lo stesso contatore catturato vanno comunque in race, channel o no, esattamente come nel caso dei thread spiegato in cos’è una race condition e come risolverla. Go include un detector per questo: compila o testa con -race e il runtime segnala gli accessi concorrenti alla stessa memoria dove almeno uno è una scrittura.

1
2
go test -race ./...
go build -race -o worker-pool .

Intercetta solo le race che accadono davvero durante l’esecuzione, quindi ne trova di più sotto un carico concorrente realistico che in un test unitario tranquillo. Quel carico è anche dove i test pieni di goroutine diventano flaky, lo stesso tipo di inaffidabilità descritto in test unitari vs test di integrazione vs test end-to-end. Aspettati di usare diverse volte più memoria e CPU con -race attivo. Eseguilo in CI, non nel binario che distribuisci.

Channel o mutex: quale usare?

Entrambi rendono sicuro l’accesso concorrente, ma da estremi opposti. Un sync.Mutex protegge lo stato condiviso sul posto: blocca, leggi o scrivi, sblocca, e il dato non si sposta mai. Un channel sposta la proprietà di un valore da una goroutine a un’altra, così solo una goroutine lo toccherà alla volta per costruzione. L’indicazione di Go stessa: usa i channel per coordinare o passare lavoro tra goroutine, e un mutex per proteggere un singolo pezzo di dato condiviso che più goroutine aggiornano sul posto, come una cache o un contatore. Se una funzione ha bisogno di entrambi, probabilmente sta facendo due lavori che si leggerebbero meglio separati.

Se vieni da JavaScript, il modello è diverso. Un solo thread esegue il tuo codice e await cede il controllo solo nei punti che vedi nel codice sorgente; async/await e event loop in JavaScript spiegati mostra dove si trovano quei punti di sospensione. Go ti dà un parallelismo reale, quindi la scelta del primitivo spetta a te.

Per il tuo prossimo batch job (ridimensionare una cartella di immagini, o chiamare un’API una volta per riga di un CSV), parti dal worker pool qui sopra. Scegli il numero di worker, chiudi il channel dei job quando hai finito di inviare, e aggiungi un context.Context al select la prima volta che ti serve cancellare.

Articoli correlati