Salta al contenuto principale

03 - Goroutine e Channel in Go: Concorrenza Pratica con Communicating Sequential Processes

In Go lanci un compito con go e lo fai parlare con gli altri tramite channel: goroutine, select, context e race detector spiegati con i loro limiti.

Cosa ne pensi di questo articolo?

03 - Goroutine e Channel in Go: Concorrenza Pratica con Communicating Sequential Processes
ConcurrencyFoto:Ruben BoekelooPexels

In Breve

In Go lanci un compito con go e lo fai parlare con gli altri tramite channel: goroutine, select, context e race detector spiegati con i loro limiti.

Concurrency 17 min

Go implementa Communicating Sequential Processes: goroutine ultra-leggere, channel come pipe tipizzate per comunicazione sicura, select per multiplexing, WaitGroup per sincronizzazione e context.Context per cancellazione propagata - tutto senza mutex espliciti nella maggior parte dei casi.

goroutine channel go go concurrency CSP pattern select go

Goroutine e channel: la cucina di un ristorante

In Go lanci un compito concorrente anteponendo go a una chiamata di funzione, e fai parlare i compiti fra loro con i channel. Il modo più semplice per ricordarlo è una cucina: le goroutine sono i cuochi, ciascuno al suo banco, e il channel è il passavivande dove un cuoco appoggia il piatto e un altro lo prende. I cuochi non si passano gli ingredienti di mano in mano mentre li usano entrambi: il piatto passa da uno a uno, e finché è sul passavivande è di nessuno.

Questo è il modello CSP (Communicating Sequential Processes), proposto da Tony Hoare in un articolo del 1978 su Communications of the ACM. Il motto che Go ne ha ricavato, in Effective Go, è: «Do not communicate by sharing memory; instead, share memory by communicating». Funziona se tratti il valore inviato su un channel come ceduto: dopo l'invio non lo tocchi più.

Il compilatore non lo verifica: se mandi un puntatore e continui a usarlo, hai di nuovo memoria condivisa, e le race condition tornano. La stessa Effective Go avverte che l'approccio «può essere portato troppo in là»: per un contatore condiviso un mutex è spesso più semplice di un channel.

I listati qui sotto sono programmi completi. Attenzione: sulla macchina su cui ho scritto questo articolo il compilatore Go non è installato, quindi non li ho eseguiti: le uscite che indico sono dedotte dalla semantica del linguaggio (e lo dico ogni volta), non osservate. Compilali e lanciali prima di fidarti; per il confronto con gli altri modelli di concorrenza vedi il primo articolo della serie.

Goroutine: avviare un compito e aspettarlo

Il problema più piccolo: cinque lavori indipendenti da far partire insieme, e un programma che deve sapere quando sono finiti. Un channel vuoto di tipo struct{} è il segnale di completamento più economico, perché non trasporta dati.

package main

import (
	"fmt"
	"time"
)

func worker(id int, done chan<- struct{}) {
	fmt.Printf("worker %d avviato\n", id)
	time.Sleep(100 * time.Millisecond) // simula il lavoro
	fmt.Printf("worker %d finito\n", id)
	done <- struct{}{} // segnala: "ho finito"
}

func main() {
	done := make(chan struct{}, 5) // capacità 5: nessun worker resta bloccato sull'invio

	for i := 0; i < 5; i++ {
		go worker(i, done) // "go" avvia la funzione in una nuova goroutine
	}

	for i := 0; i < 5; i++ {
		<-done // main aspetta cinque segnali; senza questo ciclo uscirebbe subito
	}
	fmt.Println("tutti i worker hanno finito")
}

La riga che fa partire tutto è go worker(i, done): la funzione gira in una nuova goroutine e main prosegue senza aspettarla. Il secondo ciclo, con cinque <-done, è ciò che tiene vivo il programma: la specifica del linguaggio dice che quando main ritorna il programma termina e non attende le altre goroutine, quindi senza quel ciclo i worker rischierebbero di non stampare nulla. Il buffer da 5 serve perché ogni worker possa inviare il proprio segnale anche se main non sta ancora ricevendo.

Che cosa osservare lanciandolo (uscita non verificata da me): le righe «avviato» e «finito» appaiono in un ordine che cambia da una esecuzione all'altra, perché lo scheduler decide chi parte per primo; l'unica riga garantita è l'ultima, dopo tutti i cinque segnali. Se ti ritrovi a contare segnali a mano come qui, di solito stai reimplementando un sync.WaitGroup, che vedi più sotto.

Sul costo di una goroutine: Effective Go dice che costano poco più dello spazio per lo stack, e che gli stack partono piccoli e crescono allocando memoria dall'heap quando serve. Non riporto una cifra perché non l'ho misurata e cambia con la versione di Go; se ti serve un numero per la tua macchina, misuralo con runtime.MemStats avviando diecimila goroutine ferme.

Channel: con buffer, senza buffer, e con una direzione

Un channel senza buffer è un passaggio di mano: chi invia aspetta che qualcuno riceva, e viceversa, quindi i due si incontrano. Con un buffer di capacità N l'invio non aspetta finché la coda ha posto. Le frecce nelle firme (chan<- e <-chan) dichiarano che dentro la funzione il channel si può solo scrivere o solo leggere, e il compilatore lo fa rispettare.

package main

import "fmt"

// chan<- int: dentro questa funzione il canale si può solo scrivere
func producer(out chan<- int) {
	out <- 42
}

// <-chan int: dentro questa funzione il canale si può solo leggere
func consumer(in <-chan int) {
	fmt.Println("ricevuto", <-in)
}

func main() {
	// Senza buffer: l'invio aspetta che qualcuno riceva
	ch := make(chan int)
	go producer(ch)
	consumer(ch)

	// Con buffer: due invii passano senza ricevitore
	buffered := make(chan int, 2)
	buffered <- 1
	buffered <- 2
	fmt.Println(len(buffered), cap(buffered)) // elementi in coda, capacità
	fmt.Println(<-buffered, <-buffered)       // l'ordine è FIFO
}

In main il primo channel non ha buffer: producer parte in una goroutine e resta fermo sull'invio finché consumer, chiamato da main, non legge. Il secondo ha capacità 2, quindi i due invii consecutivi non si bloccano nonostante non ci sia ancora nessun ricevitore; len dice quanti elementi sono in coda e cap quanti ne entrano.

Uscita attesa, dedotta e non osservata: ricevuto 42, poi 2 2, poi 1 2 (la specifica garantisce che le due ricezioni nella stessa chiamata avvengano da sinistra a destra, e il buffer è FIFO).

Se togliessi un ricevitore e provassi a inviare un terzo valore sul channel con buffer pieno, nessuna goroutine potrebbe più avanzare: il runtime di Go ha un controllo che termina il programma con l'errore «all goroutines are asleep - deadlock!» (il messaggio è nel sorgente di runtime/proc.go). Come scegliere la capacità:

  • Senza buffer: quando vuoi che mittente e destinatario procedano allo stesso ritmo, e che l'invio sia anche una sincronizzazione.
  • Buffer piccolo e motivato: quando sai quanti invii avverranno (come i 5 segnali di prima, o il buffer da 1 del timeout più sotto).
  • Buffer grande «per sicurezza»: quasi sempre nasconde un produttore più veloce del consumatore, e sposta il problema in memoria invece di risolverlo.

L'ordine non conta, ma il criterio sì: la capacità di un channel è una decisione di progetto e va motivata con un numero che conosci. Un buffer sovradimensionato fa sembrare sano un sistema che sta accumulando lavoro; il tema, cioè cosa fare quando chi consuma è più lento di chi produce, è l'oggetto di backpressure e flow control.

Pipeline e fan-in: catene di goroutine

Quando un lavoro si scompone in fasi (leggi, trasforma, scrivi), ogni fase può essere una goroutine collegata alla successiva da un channel. L'esempio è volutamente minimo: una fase emette numeri, l'altra li eleva al quadrato.

package main

import "fmt"

// generate emette i numeri ricevuti e poi chiude il canale.
func generate(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out) // chi invia chiude, sempre
		for _, n := range nums {
			out <- n
		}
	}()
	return out
}

// square legge finché il canale in ingresso non viene chiuso.
func square(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			out <- n * n
		}
	}()
	return out
}

func main() {
	for v := range square(generate(2, 3, 4, 5)) {
		fmt.Println(v)
	}
}

Due convenzioni reggono tutto. La prima: chi invia chiude, con defer close(out). La specifica dice che inviare su un channel chiuso, o chiuderlo due volte, provoca un panic, quindi la chiusura spetta all'unico che sa che non arriverà altro. La seconda: chi riceve usa for n := range in, che termina da solo quando il channel è chiuso e svuotato.

Se togli i close, il range in main aspetta per sempre. Qui l'uscita è deterministica, perché c'è un unico percorso dall'inizio alla fine, e deducendola dalla semantica è 4, 9, 16, 25 su righe separate.

Il trade-off è nel caso in cui il consumatore smette di leggere prima della fine: le fasi a monte restano bloccate sull'invio e non terminano mai. Il blog ufficiale di Go dedica a questo caso («Stopping short») una sezione della sua guida alle pipeline, e la soluzione passa da un segnale di cancellazione, cioè dal context più sotto.

Fan-in: riunire più ingressi in uno

Se due fasi producono in parallelo e un'altra deve consumare tutto, serve una funzione che riunisca i channel. È il fan-in.

// Da aggiungere al file della pipeline (stesso package main, usa generate).
// Aggiungi "sync" agli import.
func merge(inputs ...<-chan int) <-chan int {
	merged := make(chan int)
	var wg sync.WaitGroup

	wg.Add(len(inputs)) // prima di avviare le goroutine, non dentro
	for _, in := range inputs {
		go func(c <-chan int) {
			defer wg.Done()
			for v := range c {
				merged <- v
			}
		}(in)
	}

	go func() {
		wg.Wait()     // quando tutti gli ingressi sono esauriti...
		close(merged) // ...e solo allora, chiudi l'uscita
	}()
	return merged
}

// in main:
//   sum := 0
//   for v := range merge(generate(1, 2, 3), generate(10, 20, 30)) { sum += v }
//   fmt.Println(sum)

Per ogni ingresso parte una goroutine che copia i valori su merged. Il punto delicato è la chiusura: merged può essere chiuso solo quando tutte le copiatrici hanno finito, per questo c'è un WaitGroup e una goroutine dedicata che attende e chiude. wg.Add deve precedere l'avvio delle goroutine; la guida alle pipeline lo sottolinea per lo stesso motivo. Con gli ingressi dell'esempio ci si aspetta una somma di 66 (1+2+3+10+20+30), uscita dedotta e non osservata, mentre l'ordine dei singoli valori in merged non è garantito.

select: aspettare più cose e scegliere la prima che arriva

select è un switch sui channel: attende contemporaneamente più operazioni e ne esegue una che può procedere. La specifica dice che, se più di una è pronta, ne viene scelta una con selezione pseudo-casuale uniforme: non puoi contare su un ordine di priorità. Il caso d'uso quotidiano è il timeout su una chiamata lenta.

package main

import (
	"errors"
	"fmt"
	"time"
)

var ErrTimeout = errors.New("timeout")

// slowFetch finge una chiamata di rete che impiega `delay`.
func slowFetch(delay time.Duration) (string, error) {
	time.Sleep(delay)
	return "risposta", nil
}

func fetchWithTimeout(delay, timeout time.Duration) (string, error) {
	resultCh := make(chan string, 1) // capacità 1: la goroutine può inviare e terminare
	errCh := make(chan error, 1)     // anche se nessuno ascolta più

	go func() {
		result, err := slowFetch(delay)
		if err != nil {
			errCh <- err
			return
		}
		resultCh <- result
	}()

	select {
	case result := <-resultCh:
		return result, nil
	case err := <-errCh:
		return "", err
	case <-time.After(timeout):
		return "", ErrTimeout
	}
}

func main() {
	fmt.Println(fetchWithTimeout(50*time.Millisecond, 200*time.Millisecond))
	fmt.Println(fetchWithTimeout(300*time.Millisecond, 100*time.Millisecond))
}

fetchWithTimeout lancia il lavoro in una goroutine e poi aspetta nel select tre eventi: un risultato, un errore, o lo scadere di time.After(timeout). I due channel hanno capacità 1 per una ragione precisa: se il timeout vince e la funzione ritorna, la goroutine del lavoro, quando finisce, deve poter inviare il suo risultato e terminare. Con un channel senza buffer resterebbe bloccata sull'invio per sempre: è un goroutine leak.

Il main prova due casi: con ritardo 50 ms e timeout 200 ms ci si aspetta «risposta» e nil, con ritardo 300 ms e timeout 100 ms l'errore «timeout» (esito dedotto dai tempi, con un margine di 150 ms e di 200 ms rispettivamente; non osservato).

Due limiti da non dimenticare. Il timeout non ferma il lavoro: nel secondo caso slowFetch continua per altri 200 ms e spreca CPU o una connessione. E time.After crea un timer: le note di rilascio di Go 1.23 dicono che da quella versione i timer non più referenziati sono raccolti dal garbage collector anche se non fermati, mentre nelle versioni precedenti restavano vivi fino alla scadenza. In un ciclo molto stretto, su una versione più vecchia, conviene usare time.NewTimer e fermarlo.

context.Context: cancellazione che attraversa la catena

Il problema che il timeout di prima lasciava aperto è fermare davvero il lavoro, e farlo a tutti i livelli: il gestore HTTP, il servizio, la query. Il package context è il modo standard di Go: un valore che porta con sé una scadenza e un canale Done() che viene chiuso quando il lavoro va annullato. La documentazione indica due convenzioni: passare il context esplicitamente a ogni funzione che ne ha bisogno, come primo parametro chiamato ctx.

package main

import (
	"context"
	"fmt"
	"time"
)

func longOperation(ctx context.Context) error {
	for i := 1; i <= 10; i++ {
		select {
		case <-ctx.Done(): // il context è stato cancellato o è scaduto
			return ctx.Err()
		case <-time.After(100 * time.Millisecond): // un passo di lavoro
			fmt.Printf("passo %d/10\n", i)
		}
	}
	return nil
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 350*time.Millisecond)
	defer cancel() // rilascia le risorse del context anche se non scade

	if err := longOperation(ctx); err != nil {
		fmt.Println("interrotto:", err)
	}
}

Dentro longOperation il select attende due cose: la chiusura di ctx.Done() o il passare di 100 ms (un passo di lavoro). Un'alternativa diffusa è un default con time.Sleep, che ha un difetto: durante lo sleep la cancellazione non viene vista, quindi l'annullamento arriva con un ritardo fino a 100 ms. Aspettare i due eventi nello stesso select evita il ritardo. Il context ha scadenza di 350 ms, quindi, dai tempi, ci si aspetta di vedere «passo 1/10», «2/10», «3/10» e poi «interrotto: context deadline exceeded» (non osservato).

defer cancel() va scritto sempre: la documentazione dice che annullare il context rilascia le risorse associate, quindi chiamare cancel appena il lavoro finisce evita di tenerle fino alla scadenza. In una richiesta HTTP non crei tu il context radice: la documentazione di net/http dice che per le richieste in ingresso il context di r.Context() viene annullato alla chiusura della connessione del client o quando ServeHTTP ritorna, ed è quello che passi in giù fino al database.

Il limite: il context chiede di fermarsi, non obbliga; una funzione che non controlla Done() continua.

WaitGroup: aspettare N goroutine senza contare a mano

sync.WaitGroup sostituisce il conteggio dei segnali visto all'inizio: Add dice quante goroutine aspettare, Done ne segna una conclusa, Wait blocca fino a zero.

package main

import (
	"fmt"
	"strings"
	"sync"
)

func process(item string) string { return strings.ToUpper(item) }

func main() {
	items := []string{"mela", "pera", "kiwi"}
	results := make([]string, len(items))
	var wg sync.WaitGroup

	for i, item := range items {
		wg.Add(1) // prima di "go", mai dentro la goroutine
		go func() {
			defer wg.Done()
			results[i] = process(item) // ogni goroutine scrive un indice diverso
		}()
	}

	wg.Wait() // blocca finché Done() non è stato chiamato tre volte
	fmt.Println(results)
}

wg.Add(1) sta fuori dalla goroutine: se lo mettessi dentro, Wait potrebbe eseguirsi prima dell'Add e tornare subito. defer wg.Done() garantisce il conteggio su qualunque percorso di uscita della closure, anche un return anticipato. Ogni goroutine scrive in results[i], un indice diverso dagli altri, quindi non c'è contesa sullo stesso elemento; se tutte scrivessero la stessa variabile, servirebbe un mutex. L'uscita attesa è [MELA PERA KIWI] (dedotta).

C'è una trappola storica. La closure usa i e item del ciclo senza passarli come argomenti: dalla versione 1.22 di Go ogni iterazione ha le proprie variabili, e le note di rilascio precisano che questo vale quando il go.mod del modulo richiede Go 1.22 o più recente. Con un go.mod più vecchio il vecchio comportamento resta, e le goroutine vedrebbero tutte l'ultimo valore. Controlla la riga go del tuo go.mod prima di togliere i parametri esplicitamente passati alla closure.

Race detector: trovare le gare che non vedi

Anche con i channel, basta una variabile condivisa per avere un data race: due goroutine accedono alla stessa memoria, almeno una scrive, nessuna sincronizzazione. Go include un rivelatore: aggiungi -race a go run, go test o go build. Mille goroutine che incrementano lo stesso contatore sono il caso da manuale.

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var racy int
	var safe atomic.Int64
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			racy++      // leggi, somma, scrivi: tre passi, non uno
			safe.Add(1) // un'unica operazione atomica
		}()
	}

	wg.Wait()
	fmt.Println("racy:", racy, "safe:", safe.Load())
}

racy++ non è un'operazione sola: legge il valore, aggiunge uno, lo riscrive, e due goroutine possono leggere lo stesso valore e perdere un incremento. safe.Add(1) usa il tipo atomic.Int64 (introdotto con Go 1.19) e non perde nulla. Quello che puoi aspettarti: safe vale sempre 1000, mentre racy può essere 1000 o meno, e con go run -race il rivelatore segnala il data race sulla riga di racy++, con gli stack delle due goroutine in conflitto (la forma del report è descritta nella documentazione ufficiale; io non l'ho prodotto).

Un limite che la stessa documentazione dichiara: il rivelatore trova solo le gare che avvengono durante l'esecuzione, non quelle in percorsi di codice che i test non attraversano. Per questo si usa nei test e, se la copertura è incompleta, su un binario compilato con -race sotto carico realistico. Per le tecniche di prevenzione e diagnosi in generale c'è race condition e deadlock.

Goroutine leak: il guasto silenzioso

Una goroutine che resta bloccata per sempre non fa rumore: non lancia errori, occupa memoria e, moltiplicata per ogni richiesta, fa crescere il processo fino a un'interruzione. Le cause sono quasi sempre le stesse tre che hai già incontrato in questo articolo: un invio su un channel che nessuno leggerà più (il timeout senza buffer), un range su un channel che nessuno chiuderà (la pipeline senza close), una goroutine che ignora ctx.Done().

Il rimedio è progettare ogni goroutine con la domanda «chi la fa finire?», e un modo concreto per accorgersi del problema è confrontare runtime.NumGoroutine() prima e dopo un'operazione che dovrebbe averle concluse tutte.

Quando NON usare i channel

I channel servono per trasferire un valore da una goroutine all'altra, per coordinare fasi e per segnalare eventi. Per proteggere uno stato condiviso e piccolo (un contatore, una cache, un riferimento) un sync.Mutex o un tipo atomico sono più semplici e più veloci da leggere, come ammette la stessa Effective Go con l'esempio dei contatori di riferimento. Una regola pratica: se stai usando un channel solo per fare da lucchetto, hai scelto lo strumento sbagliato.

Che cosa sai fare adesso

Sai avviare e attendere goroutine con WaitGroup, scegliere tra channel con e senza buffer motivando la capacità, costruire pipeline che si chiudono da sole, fermare il lavoro con context invece di un timeout cieco, e individuare data race e leak con gli strumenti del linguaggio. Ricorda che le uscite di questo articolo sono dedotte, non osservate: il primo passo utile è compilare i programmi e confrontarli.

Il prossimo articolo passa a Python: asyncio.TaskGroup e la structured concurrency, che porta in async/await una disciplina simile sulla cancellazione. Se vieni dal confronto con le altre piattaforme, guarda anche le virtual thread di Java. Per i canali limitati e la cancellazione in Rust, vedi l'articolo su async di Rust e Tokio.

Fonti

Le fonti sono tutte documentazione ufficiale del linguaggio; per CSP in sé la fonte è l'articolo di Tony Hoare, «Communicating Sequential Processes», Communications of the ACM 21(8), 1978, che non ho collegato perché non sono riuscito a raggiungere la pagina dell'editore. Tutte le uscite dei programmi sono dedotte da queste pagine, non da esecuzioni.

Articolo precedente ← Event Loop JavaScript
Prossimo nella serie Python asyncio Avanzato →

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti