Il tuo backend Go gestisce le richieste una dopo l’altra? Stai usando solo il 20% del motore. La concorrenza nativa di Go — goroutine e channel — è il suo superpotere più sottovalutato. Non servono librerie, non serve una coda esterna. Serve capire come far parlare i pezzi tra loro senza fare casino.
Noi, di Meteora Web, abbiamo scelto Go per alcuni progetti backend proprio per questa ragione: scrivere codice concorrente pulito, senza framework di messaggistica. In questa guida ti portiamo dentro il modello CSP (Communicating Sequential Processes) di Go: goroutine come attori leggeri, channel come pipeline. Pronto a smettere di usare Go come se fosse PHP?
Goroutine: il thread costa poco, lanciarlo costa pochissimo
Una goroutine è un thread leggero gestito dal runtime Go. Costa circa 4KB di stack iniziale (contro il MB di un thread OS). Puoi avviarne centinaia di migliaia senza impazzire.
package main
import (
"fmt"
"time"
)
func stampa(msg string) {
for i := 0; i < 3; i++ {
fmt.Println(msg, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go stampa("goroutine") // lanciata in background
stampa("main") // eseguita sul goroutine principale
time.Sleep(1 * time.Second)
}Problema: non aspetti la fine. Se il main termina, tutte le goroutine muoiono. Servono i WaitGroup.
Sponsored Protocol
sync.WaitGroup: aspetta che finiscano
var wg sync.WaitGroup
func lavoro(id int) {
defer wg.Done()
fmt.Printf("Lavoro %d iniziato\n", id)
time.Sleep(200 * time.Millisecond)
fmt.Printf("Lavoro %d finito\n", id)
}
func main() {
for i := 1; i <= 5; i++ {
wg.Add(1)
go lavoro(i)
}
wg.Wait()
fmt.Println("Tutti finiti")
}Errore comune: chiamare wg.Add dentro la goroutine invece che fuori. Fallo sempre prima del go, o usalo noto all’avvio. Noi lo vediamo spesso in code review: deadlock sicuro.
Channel: tubi che trasportano dati tra goroutine
Un channel è un canale tipizzato (chan Tipo) che blocca in lettura finché non arriva un dato, e blocca in scrittura finché il buffer non si libera (se bufferizzato) o finché qualcuno non legge (se unbuffered).
ch := make(chan string) // unbuffered
chBuff := make(chan int, 5) // bufferizzato, capacità 5Unbuffered: sincrono puro
Un produttore scrive → si blocca finché un consumatore legge. Perfetto per notifiche e sincronizzazione.
func main() {
ch := make(chan string)
go func() {
ch <- "Ciao da goroutine"
}()
msg := <-ch
fmt.Println(msg)
}Se scrivi senza mai leggere — deadlock. Il runtime Go se ne accorge e crasha.
Sponsored Protocol
Buffered: coda interna
Scrivi fino a esaurire buffer, poi si blocca. Utile per carichi di burst.
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 // bloccante: buffer pieno
fmt.Println(<-ch) // 1Quando usarlo? Quando il produttore è più veloce del consumatore per brevi periodi. Ma attento: se il buffer è troppo grande nascondi problemi di backpressure.
Chiudere un channel
Il mittente chiude con close(ch). Il ricevente può iterare con for v := range ch o controllare con la virgola v, ok := <-ch.
func produttore(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
}
func main() {
ch := make(chan int)
go produttore(ch)
for v := range ch {
fmt.Println(v)
}
}Regola d’oro: solo il mittente chiude. Mai il ricevente. E non scrivere su un channel chiuso — panic.
Select: il multiplexer dei channel
Serve per attendere più operazioni su channel contemporaneamente. Caso classico: timeout o priorità.
select {
case msg1 := <-ch1:
fmt.Println("Da ch1:", msg1)
case msg2 := <-ch2:
fmt.Println("Da ch2:", msg2)
case <-time.After(1 * time.Second):
fmt.Println("Timeout!")
}
Se più casi sono pronti, Go ne sceglie uno casuale (fairness). Puoi usare default per non bloccarti mai.
Sponsored Protocol
Pattern di concorrenza reali
Worker Pool
Lanci N goroutine che pescano lavoro da un channel. Il main produce lavoro e chiude il channel quando finisce.
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * 2 // simula lavoro
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
// lancia 3 workers
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// invia 5 job
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
// raccogli risultati
for r := 1; r <= 5; r++ {
fmt.Println(<-results)
}
}Adatto per batch processing, scraping, elaborazione di file.
Fan-In / Fan-Out
- Fan-Out: un produttore distribuisce lavoro a più goroutine (es. worker pool).
- Fan-In: più goroutine inviano sullo stesso channel.
func fanIn(ch1, ch2 <-chan string) <-chan string {
c := make(chan string)
go func() {
for {
select {
case v := <-ch1:
c <- v
case v := <-ch2:
c <- v
}
}
}()
return c
}Perfetto per aggregare risultati da più sorgenti (API, database, file).
Sponsored Protocol
Pipeline con channel
Ogni step è una goroutine che riceve da un channel e produce su un altro. Chaining pulito.
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func sq(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func main() {
c := gen(2, 3, 4)
out := sq(c)
for n := range out {
fmt.Println(n) // 4, 9, 16
}
}Gestire cancellazione e timeout con context
Non puoi uccidere una goroutine dall'esterno. Devi cooperare: passare un context.Context e controllare ctx.Done().
func lavoro(ctx context.Context, id int, out chan<- string) {
for {
select {
case <-ctx.Done():
out <- fmt.Sprintf("worker %d: mi fermo per %v", id, ctx.Err())
return
default:
// lavoro...
}
}
}Usa context.WithTimeout per limiti temporali, context.WithCancel per cancellazione esplicita.
Errori che vediamo in produzione
- Goroutine leak: goroutine che resta in attesa su un channel mai chiuso. Soluzione: sempre un timeout o ctx.Done().
- Race condition: accesso a variabili condivise senza mutex. Usa
-racenei test. - Blocco su channel non bufferizzato: se produttore è unico, ma consumatore non è pronto, deadlock.
- Copia di WaitGroup: passa sempre puntatore (
*sync.WaitGroup), non valore.
In sintesi — cosa fare adesso
- Scrivi una goroutine oggi. Prendi un ciclo for loop nel tuo codice e trasformalo in una goroutine con WaitGroup.
- Sostituisci le variabili condivise con channel. Se due goroutine si scambiano dati, usa un channel invece di struttura condivisa + mutex.
- Aggiungi un timeout a ogni lettura di channel che blocca potenzialmente per sempre.
select + time.After. - Testa con
go test -race. Il race detector trova accessi non sincronizzati. Fallo diventare parte della tua CI. - Leggi il codice sorgente di
net/http. Vedi come Go usa goroutine per ogni connessione. È la migliore didattica.
La concorrenza in Go è un linguaggio dentro il linguaggio. Una volta che padroneggi goroutine e channel, smetti di pensare a thread e lock. Inizi a pensare in pipeline e attori. Ed è lì che il backend scala senza farmi pagare AWS quattro volte tanto.