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 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
- Effective Go - il motto «share memory by communicating», l'avvertenza «can be taken too far», il costo e la crescita degli stack, consultata il 2026-11-12
- The Go Programming Language Specification - selezione pseudo-casuale di select, panic su channel chiuso, main che non attende le altre goroutine, ordine di valutazione da sinistra a destra, consultata il 2026-11-12
- Go Concurrency Patterns: Pipelines and cancellation - convenzione di chiusura, fan-in con WaitGroup, il caso «Stopping short», consultata il 2026-11-12
- Package context - context come primo parametro, rilascio delle risorse con cancel, consultata il 2026-11-12
- Package net/http (Request.Context) - quando il context di una richiesta in ingresso viene annullato, consultata il 2026-11-12
- Data Race Detector - uso del flag -race, forma del report, limite ai soli percorsi eseguiti, consultata il 2026-11-12
- Go 1.22 Release Notes - variabili di ciclo per iterazione legate al go.mod, consultata il 2026-11-12
- Go 1.23 Release Notes - timer non più referenziati raccolti dal GC, consultata il 2026-11-12
- Sorgente del runtime di Go (runtime/proc.go) - il controllo che termina il programma quando tutte le goroutine sono bloccate, consultata il 2026-11-12
- Go 1.19 Release Notes - i tipi atomic.Int64 e simili, consultata il 2026-11-12
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.

Commenti
Caricamento commenti...
Accedi per commentare