Concorrenza in Go: Goroutine e Canali - Guida Completa

Padroneggia la concorrenza in Go con goroutine e canali. Pattern avanzati, sincronizzazione, istruzioni select e best practice con esempi di codice dettagliati.

Concorrenza Go - Goroutine e canali in azione

La concorrenza è uno dei punti di forza maggiori di Go. A differenza di altri linguaggi in cui il multithreading rimane complesso, Go offre un modello elegante basato su goroutine e canali che semplifica notevolmente lo sviluppo di applicazioni concorrenti.

Filosofia Go

"Non comunicare condividendo memoria; condividi memoria comunicando." Questo principio fondamentale guida tutta la progettazione della concorrenza in Go.

Comprendere le Goroutine

Le goroutine sono thread leggeri gestiti dal runtime di Go. Consumano circa 2 KB di stack (rispetto ai diversi MB dei thread del sistema operativo) e permettono di eseguire migliaia di task concorrenti senza sovraccarico.

Avviare una goroutine richiede semplicemente di anteporre la parola chiave go a una chiamata di funzione. Il runtime si occupa dello scheduling e della distribuzione tra i thread disponibili.

goroutines_basic.gogo
package main

import (
    "fmt"
    "time"
)

// fetchData simulates a network request
func fetchData(id int) {
    // Simulates network delay
    time.Sleep(100 * time.Millisecond)
    fmt.Printf("Data %d fetched\n", id)
}

func main() {
    // Sequential execution - 500ms total
    start := time.Now()
    for i := 1; i <= 5; i++ {
        fetchData(i)
    }
    fmt.Printf("Sequential: %v\n", time.Since(start))

    // Concurrent execution - ~100ms total
    start = time.Now()
    for i := 1; i <= 5; i++ {
        go fetchData(i) // Execute as goroutine
    }
    time.Sleep(150 * time.Millisecond) // Wait for completion
    fmt.Printf("Concurrent: %v\n", time.Since(start))
}

L'esecuzione concorrente riduce il tempo totale da 500ms a circa 100ms. Tuttavia, usare time.Sleep per sincronizzare le goroutine non è una buona pratica. I canali offrono una soluzione elegante.

Canali: Comunicazione tra Goroutine

Un canale è un condotto tipizzato per inviare e ricevere valori tra goroutine. I canali garantiscono la sincronizzazione: una goroutine che invia attende finché un'altra non riceve, e viceversa.

La creazione di un canale utilizza la funzione make. L'operatore <- invia e riceve dati a seconda della sua posizione rispetto al canale.

channels_basic.gogo
package main

import "fmt"

// worker performs computation and returns result via channel
func worker(id int, jobs <-chan int, results chan<- int) {
    // Receives jobs until channel closes
    for job := range jobs {
        result := job * 2 // Processing
        results <- result // Send result
    }
}

func main() {
    // Create channels
    jobs := make(chan int, 10)    // Buffered channel
    results := make(chan int, 10)

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

    // Send 5 jobs
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs) // Signal end of jobs

    // Collect results
    for r := 1; r <= 5; r++ {
        result := <-results
        fmt.Printf("Result: %d\n", result)
    }
}

I canali direzionali (<-chan per ricezione, chan<- per invio) rafforzano la sicurezza del codice limitando le operazioni possibili.

Canali con Buffer vs Senza Buffer

La distinzione tra questi tipi di canali influisce direttamente sul comportamento di sincronizzazione tra goroutine.

I canali senza buffer bloccano il mittente finché un ricevitore non è pronto. I canali con buffer permettono di inviare fino a N valori senza bloccare, dove N rappresenta la capacità del buffer.

buffered_channels.gogo
package main

import "fmt"

func main() {
    // Unbuffered channel - strict synchronization
    unbuffered := make(chan string)

    go func() {
        unbuffered <- "message" // Blocks until received
    }()

    msg := <-unbuffered // Unblocks the send
    fmt.Println(msg)

    // Buffered channel - capacity of 2
    buffered := make(chan int, 2)

    // These sends don't block
    buffered <- 1
    buffered <- 2
    // buffered <- 3 // Would block because buffer is full

    fmt.Println(<-buffered) // 1
    fmt.Println(<-buffered) // 2

    // Check capacity
    fmt.Printf("Length: %d, Capacity: %d\n",
        len(buffered), cap(buffered))
}

I canali con buffer disaccoppiano produttori e consumatori, mentre quelli senza buffer garantiscono una sincronizzazione punto a punto.

Attenzione ai Deadlock

Un deadlock si verifica quando tutte le goroutine sono bloccate in attesa. Il runtime di Go lo rileva e termina il programma con un messaggio di errore esplicito.

Select: Multiplexing dei Canali

L'istruzione select attende operazioni simultanee su più canali. Assomiglia a uno switch per le comunicazioni concorrenti.

Questa costruzione è essenziale per gestire timeout, cancellazioni e comunicazioni multiple senza bloccarsi indefinitamente su un singolo canale.

select_example.gogo
package main

import (
    "fmt"
    "time"
)

// fetchAPI simulates an API call with variable delay
func fetchAPI(name string, delay time.Duration, ch chan<- string) {
    time.Sleep(delay)
    ch <- fmt.Sprintf("%s: data received", name)
}

func main() {
    api1 := make(chan string)
    api2 := make(chan string)

    // Launch two API calls in parallel
    go fetchAPI("API-1", 100*time.Millisecond, api1)
    go fetchAPI("API-2", 200*time.Millisecond, api2)

    // Global timeout of 150ms
    timeout := time.After(150 * time.Millisecond)

    // Collect results with timeout
    for i := 0; i < 2; i++ {
        select {
        case result := <-api1:
            fmt.Println(result)
        case result := <-api2:
            fmt.Println(result)
        case <-timeout:
            fmt.Println("Timeout - operation cancelled")
            return
        }
    }
}

Il select sceglie il primo canale disponibile. Se più canali sono pronti, la scelta è pseudo-casuale per evitare lo starvation.

Pronto a superare i tuoi colloqui su Go?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

Pattern Worker Pool

Il pattern worker pool distribuisce le task tra più worker, limitando la concorrenza e ottimizzando l'uso delle risorse. Questo pattern è indispensabile per elaborare grandi quantità di dati.

L'implementazione si basa su un canale di task condiviso tra i worker e un canale di risultati per la raccolta.

worker_pool.gogo
package main

import (
    "fmt"
    "sync"
    "time"
)

// Task represents a unit of work
type Task struct {
    ID   int
    Data string
}

// Result contains the processing result
type Result struct {
    TaskID int
    Output string
}

// worker processes received tasks
func worker(id int, tasks <-chan Task, results chan<- Result, wg *sync.WaitGroup) {
    defer wg.Done()

    for task := range tasks {
        // Simulate processing
        time.Sleep(50 * time.Millisecond)

        results <- Result{
            TaskID: task.ID,
            Output: fmt.Sprintf("Worker %d processed: %s", id, task.Data),
        }
    }
}

func main() {
    const numWorkers = 3
    const numTasks = 10

    tasks := make(chan Task, numTasks)
    results := make(chan Result, numTasks)

    var wg sync.WaitGroup

    // Start workers
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, tasks, results, &wg)
    }

    // Send tasks
    for i := 1; i <= numTasks; i++ {
        tasks <- Task{ID: i, Data: fmt.Sprintf("task-%d", i)}
    }
    close(tasks)

    // Close results channel after workers finish
    go func() {
        wg.Wait()
        close(results)
    }()

    // Collect results
    for result := range results {
        fmt.Printf("Task %d: %s\n", result.TaskID, result.Output)
    }
}

Il sync.WaitGroup coordina l'attesa di tutti i worker prima di chiudere il canale dei risultati.

Pattern Fan-Out/Fan-In

Questo pattern distribuisce il lavoro tra più goroutine (fan-out) e poi aggrega i risultati (fan-in). Massimizza il parallelismo semplificando la raccolta dei risultati.

fan_out_fan_in.gogo
package main

import (
    "fmt"
    "sync"
)

// generate produces numbers on a channel
func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

// square computes the square of received numbers
func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

// merge combines multiple channels into one (fan-in)
func merge(channels ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup

    // Output function for each channel
    output := func(c <-chan int) {
        defer wg.Done()
        for n := range c {
            out <- n
        }
    }

    // Launch a goroutine per channel
    wg.Add(len(channels))
    for _, c := range channels {
        go output(c)
    }

    // Close after all goroutines finish
    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}

func main() {
    // Generate data
    numbers := generate(1, 2, 3, 4, 5, 6, 7, 8)

    // Fan-out: distribute to 3 workers
    sq1 := square(numbers)
    sq2 := square(numbers)
    sq3 := square(numbers)

    // Fan-in: aggregate results
    for result := range merge(sq1, sq2, sq3) {
        fmt.Println(result)
    }
}

Questo pattern eccelle per operazioni CPU-bound distribuibili e pipeline di elaborazione dati.

Context per Cancellazione e Deadline

Il package context standardizza la gestione di cancellazioni, deadline e valori tra goroutine. Qualsiasi goroutine di lunga durata dovrebbe accettare un context come primo parametro.

context_example.gogo
package main

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

// fetchWithContext simulates a cancellable request
func fetchWithContext(ctx context.Context, url string) (string, error) {
    // Simulates a long operation
    select {
    case <-time.After(2 * time.Second):
        return fmt.Sprintf("Data from %s", url), nil
    case <-ctx.Done():
        return "", ctx.Err() // context.Canceled or context.DeadlineExceeded
    }
}

func main() {
    // Context with 500ms timeout
    ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
    defer cancel() // Release resources

    result, err := fetchWithContext(ctx, "https://api.example.com")
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }
    fmt.Println(result)

    // Context with manual cancellation
    ctx2, cancel2 := context.WithCancel(context.Background())

    go func() {
        time.Sleep(100 * time.Millisecond)
        cancel2() // Explicit cancellation
    }()

    result, err = fetchWithContext(ctx2, "https://api2.example.com")
    if err != nil {
        fmt.Printf("Request cancelled: %v\n", err)
    }
}
Buona Pratica

Chiamare sempre defer cancel() immediatamente dopo aver creato un context per evitare leak di risorse.

Sincronizzazione con sync.Mutex

Sebbene i canali siano preferibili per la comunicazione, il package sync rimane necessario per proteggere l'accesso concorrente a strutture dati condivise.

mutex_example.gogo
package main

import (
    "fmt"
    "sync"
)

// SafeCounter is a thread-safe counter
type SafeCounter struct {
    mu    sync.Mutex
    value map[string]int
}

// Increment increments the value for a given key
func (c *SafeCounter) Increment(key string) {
    c.mu.Lock()         // Exclusive lock
    defer c.mu.Unlock() // Guaranteed unlock
    c.value[key]++
}

// Value returns the current value
func (c *SafeCounter) Value(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value[key]
}

func main() {
    counter := SafeCounter{value: make(map[string]int)}

    var wg sync.WaitGroup

    // 1000 concurrent increments
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Increment("visits")
        }()
    }

    wg.Wait()
    fmt.Printf("Total: %d\n", counter.Value("visits")) // 1000
}

Il sync.RWMutex ottimizza le letture concorrenti con RLock()/RUnlock() per operazioni di sola lettura.

Errori Comuni e Soluzioni

La concorrenza in Go presenta insidie classiche. Ecco gli errori più frequenti e come evitarli.

common_mistakes.gogo
package main

import (
    "fmt"
    "sync"
)

func main() {
    // ERROR: Loop variable capture
    // All goroutines would print the same value
    for i := 0; i < 3; i++ {
        go func() {
            fmt.Println(i) // Capture by reference - BUG
        }()
    }

    // SOLUTION: Pass value as parameter
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            fmt.Println(n) // Local copy - CORRECT
        }(i)
    }
    wg.Wait()

    // ERROR: Send on nil channel
    var ch chan int
    // ch <- 1 // Blocks forever

    // SOLUTION: Always initialize with make
    ch = make(chan int, 1)
    ch <- 1
    fmt.Println(<-ch)

    // ERROR: Send on closed channel
    done := make(chan bool)
    close(done)
    // done <- true // Panic!

    // SOLUTION: Check before send or use sync.Once
    select {
    case done <- true:
        fmt.Println("Sent")
    default:
        fmt.Println("Channel closed or full")
    }
}

Il rilevamento di race condition usa il flag -race durante la compilazione o i test: go test -race ./....

Inizia a praticare!

Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.

Conclusione

Padroneggiare la concorrenza in Go si basa su alcuni concetti chiave che, una volta ben compresi, permettono di costruire applicazioni ad alte prestazioni.

Punti chiave:

✅ Le goroutine sono leggere ed economiche - crearne migliaia rimane accettabile

✅ I canali sincronizzano e trasferiscono dati tra goroutine

✅ L'istruzione select gestisce comunicazioni multiple e timeout

✅ Il pattern worker pool limita la concorrenza e ottimizza le risorse

✅ Il package context standardizza cancellazione e deadline

✅ I mutex proteggono i dati condivisi quando i canali non bastano

✅ Il flag -race rileva le race condition durante i test

La filosofia "Condividere memoria comunicando" guida verso design più sicuri e manutenibili rispetto al multithreading tradizionale con lock.

Tag

#go
#golang
#concurrency
#goroutines
#channels

Condividi

Articoli correlati