Concurrency in Go: 3 Pattern per Codice Efficiente e Sicuro

Pubblicato: 27 giu 2025
Di: Paolo Galeotti

Go è famoso per le sue funzionalità di concorrenza integrate come goroutine e channel. Ma usarle efficacemente richiede più di un semplice go func() — serve struttura. In questo articolo, esploreremo tre pattern di concorrenza pratici che ti aiuteranno a costruire programmi concorrenti affidabili e manutenibili in Go.

Indice dei contenuti:

  • Worker Pool – limitare il numero di goroutine che processano job

  • Broadcast Stop Signal – fermare più goroutine in modo pulito

  • Fan-In – combinare output da più goroutine

Ogni pattern include esempi di codice, linee guida per l'uso e casi d'uso reali.

1. Worker Pool: Concorrenza Controllata

Immagina di avere una lista di task da eseguire — magari ridimensionare immagini, fare scraping di URL, o inviare email. Vuoi processarne molti in parallelo, ma senza generare centinaia di goroutine (che potrebbero esaurire le risorse di sistema o sovraccaricare servizi esterni).

Un Worker Pool risolve questo problema avviando un numero fisso di goroutine (i "worker") e alimentandole con task attraverso un channel. Ogni worker prende un task, lo processa, poi aspetta il successivo.

In questo modo, mantieni la concorrenza sotto controllo e il tuo programma diventa più prevedibile ed efficiente.

🔧 Esempio di Codice

go
func worker(id int, jobs <-chan int, results chan<- int) {
    for job := range jobs {
        fmt.Printf("Worker %d processing job %d\n", id, job)
        time.Sleep(time.Second) // Lavoro simulato
        results <- job * 2
    }
}

func main() {
    const numJobs = 5
    jobs := make(chan int, numJobs)
    results := make(chan int, numJobs)

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

    // Invia i job
    for j := 1; j <= numJobs; j++ {
        jobs <- j
    }
    close(jobs)

    // Raccoglie i risultati
    for a := 1; a <= numJobs; a++ {
        fmt.Println("Result:", <-results)
    }
}

✅ Quando Usarlo

  • Hai molti task piccoli e indipendenti

  • Vuoi limitare la concorrenza (per evitare di sovraccaricare un server)

  • I tuoi worker possono essere riutilizzati e non necessitano di stato unico persistente

❌ Quando Evitarlo

  • I task sono infrequenti — creare goroutine ad-hoc potrebbe essere più semplice

  • Ogni worker ha bisogno del proprio stato long-lived (come una connessione DB)

  • Hai bisogno di parallelismo istantaneo senza limiti (usa goroutine direttamente, con attenzione)

💡 Casi d'Uso Reali

  • Invio di email in batch

  • Chiamate API con rate limits

  • Processamento di job da una coda

2. Broadcast Stop Signal: Shutdown Coordinato

A volte hai più goroutine in esecuzione in background, magari in ascolto di eventi o processando job. Quando la tua app si spegne (o si verifica un errore), vuoi segnalare a tutte di fermarsi.

Questo pattern è semplice ed evita errori comuni come usare context.Context in struct long-lived (che è considerato un anti-pattern).

🔧 Esempio di Codice

go
type Worker struct {
    id     int
    stopCh <-chan struct{}
}

func (w Worker) Start() {
    go func() {
        for {
            select {
            case <-w.stopCh:
                fmt.Printf("Worker %d stopping\n", w.id)
                return
            default:
                fmt.Printf("Worker %d is working...\n", w.id)
                time.Sleep(500 * time.Millisecond)
            }
        }
    }()
}

func main() {
    stop := make(chan struct{})
    workers := []Worker{
        {1, stop},
        {2, stop},
    }

    for _, w := range workers {
        w.Start()
    }

    time.Sleep(2 * time.Second)
    close(stop) // Segnale di stop broadcast
    time.Sleep(1 * time.Second) // Aspetta che i worker si fermino
}

Questo funziona perché in Go quando chiudi un channel viene segnalato a tutti i consumer. Non può essere fatto semplicemente inviando dati sul channel perché questo verrebbe consumato solo dalla prima goroutine disponibile e non dalle altre.

✅ Quando Usarlo

  • Hai goroutine long-running (come listener o poller)

  • Vuoi un segnale di shutdown centralizzato

  • Non hai bisogno di timeout per operazione o propagazione di errori

❌ Quando Evitarlo

  • Per logica scoped alla richiesta: preferisci context.Context

  • Quando usi timeout, deadline, o catene di cancellazione parent-child

  • Quando ogni goroutine gestisce il proprio ciclo di vita separatamente

⚠️ Perché Non Usare context.Context Qui?

Usare context.Context come campo in una struct long-lived è un anti-pattern perché:

  • I context sono pensati per essere passati, non memorizzati

  • Rappresentano il lifetime di un'operazione, non di un componente

  • Riutilizzare un context cancellato porta a bug sottili e comportamenti indefiniti

Invece, usa un semplice channel come stopCh per segnalare lo shutdown del componente — è idiomatico e più sicuro.

💡 Casi d'Uso Reali

  • Fermare gracefully worker in background allo shutdown dell'app

  • Fermare consumer da un message broker (es. Kafka)

  • Interrompere listener TCP/UDP su SIGTERM

3. Fan-In: Aggregazione di Stream

A volte hai più goroutine che producono dati indipendentemente (scraper, sensori, lettori di file, ecc.), e vuoi combinare i loro output in un singolo stream.

Il pattern Fan-In unisce più channel di input in un channel di output. Questo rende facile centralizzare la logica di processing mantenendo i producer separati e indipendenti.

🔧 Esempio di Codice

go
func producer(id int, out chan<- string) {
    for i := 0; i < 3; i++ {
        out <- fmt.Sprintf("Producer %d: item %d", id, i)
        time.Sleep(time.Duration(id) * 200 * time.Millisecond)
    }
    close(out)
}

func fanIn(channels ...<-chan string) <-chan string {
    out := make(chan string)
    for _, ch := range channels {
        go func(c <-chan string) {
            for val := range c {
                out <- val
            }
        }(ch)
    }
    return out
}

func main() {
    a := make(chan string)
    b := make(chan string)

    go producer(1, a)
    go producer(2, b)

    merged := fanIn(a, b)

    for i := 0; i < 6; i++ {
        fmt.Println("Received:", <-merged)
    }
}

✅ Quando Usarlo

  • Più goroutine producono valori concorrentemente

  • Vuoi unire i loro output per processarli in un posto

  • Ogni producer può funzionare indipendentemente

❌ Quando Evitarlo

  • Se l'ordine dell'output è importante (fan-in non lo preserva)

  • Se hai bisogno di tracciare da quale sorgente viene ogni messaggio

  • Se i producer non stanno funzionando concorrentemente — un semplice merge potrebbe bastare

💡 Casi d'Uso Reali

  • Unire log da più microservizi

  • Combinare risultati da scraper HTTP paralleli

  • Aggregare eventi da diverse sorgenti di input

Conclusione

Ognuno di questi pattern di concorrenza aiuta a strutturare il tuo codice Go per chiarezza, sicurezza e performance.

Comprendendo quando usare questi pattern — e quando non usarli — puoi costruire sistemi concorrenti in Go che sono veloci, sicuri e manutenibili.

Questo articolo è stato scritto da Paolo Galeotti, Backend Developer di Quinck. Leggi altri articoli sul nostro blog per approfondimenti su sviluppo software e innovazione tecnologica.

Condividi sui social: