1. Il ruolo dei meccanismi di sincronizzazione

Nella programmazione concorrente, quando più goroutine condividono risorse, è necessario garantire che le risorse possano essere accessate solo da una goroutine alla volta per evitare le race condition. Questo richiede l'utilizzo di meccanismi di sincronizzazione. I meccanismi di sincronizzazione possono coordinare l'ordine di accesso di diverse goroutine alle risorse condivise, garantendo la coerenza dei dati e la sincronizzazione dello stato in un ambiente concorrente.

Il linguaggio Go fornisce un ricco insieme di meccanismi di sincronizzazione, tra cui ma non limitati a:

  • Mutex (sync.Mutex) e mutex di lettura-scrittura (sync.RWMutex)
  • Canali
  • WaitGroup
  • Funzioni atomiche (pacchetto atomic)
  • Variabili di condizione (sync.Cond)

2. Primitive di sincronizzazione

2.1 Mutex (sync.Mutex)

2.1.1 Concetto e ruolo della mutex

La mutex è un meccanismo di sincronizzazione che garantisce l'operazione sicura delle risorse condivise consentendo a una sola goroutine di detenere il blocco per accedere alla risorsa condivisa in un dato momento. La mutex raggiunge la sincronizzazione attraverso i metodi Lock e Unlock. Chiamare il metodo Lock bloccherà l'esecuzione finché il blocco non viene rilasciato e, in questo momento, le altre goroutine che cercano di acquisire il blocco aspetteranno. Chiamare Unlock rilascia il blocco, consentendo ad altre goroutine in attesa di acquisirlo.

var mu sync.Mutex

func sezioneCritica() {
    // Acquisisci il blocco per accedere in modo esclusivo alla risorsa
    mu.Lock()
    // Accedi alla risorsa condivisa qui
    // ...
    // Rilascia il blocco per consentire ad altre goroutine di acquisirlo
    mu.Unlock()
}

2.1.2 Uso pratico della mutex

Supponiamo che sia necessario mantenere un contatore globale e che più goroutine debbano incrementarne il valore. Usare una mutex può garantire l'accuratezza del contatore.

var (
    mu      sync.Mutex
    contatore int
)

func incremento() {
    mu.Lock()         // Blocca prima di modificare il contatore
    contatore++       // Incrementa in sicurezza il contatore
    mu.Unlock()       // Sblocca dopo l'operazione, consentendo ad altre goroutine di accedere al contatore
}

func main() {
    for i := 0; i < 10; i++ {
        go incremento()  // Avvia più goroutine per incrementare il valore del contatore
    }
    // Attendere un po' di tempo (nella pratica, si dovrebbero usare WaitGroup o altri metodi per attendere il completamento di tutte le goroutine)
    time.Sleep(1 * time.Second)
    fmt.Println(contatore)  // Stampare il valore del contatore
}

2.2 Mutex di lettura-scrittura (sync.RWMutex)

2.2.1 Concetto di mutex di lettura-scrittura

RWMutex è un tipo speciale di blocco che consente a più goroutine di leggere risorse condivise contemporaneamente, mentre le operazioni di scrittura sono esclusive. Rispetto alle mutex, i blocchi di lettura-scrittura possono migliorare le prestazioni in scenari multi-lettura. Ha quattro metodi: RLock, RUnlock per bloccare e sbloccare le operazioni di lettura, e Lock, Unlock per bloccare e sbloccare le operazioni di scrittura.

2.2.2 Casi d'uso pratici della mutex di lettura-scrittura

In un'applicazione di database, le operazioni di lettura possono essere molto più frequenti delle operazioni di scrittura. Utilizzare un blocco di lettura-scrittura può migliorare le prestazioni del sistema perché consente a più goroutine di leggere contemporaneamente.

var (
    rwMu  sync.RWMutex
    dati  int
)

func leggiDati() int {
    rwMu.RLock()         // Acquisisci il blocco di lettura, consentendo ad altre operazioni di lettura di proseguire contemporaneamente
    defer rwMu.RUnlock() // Assicurarsi che il blocco venga rilasciato utilizzando defer
    return dati          // Leggi i dati in sicurezza
}

func scriviDati(nuovoValore int) {
    rwMu.Lock()          // Acquisisci il blocco di scrittura, impedendo altre operazioni di lettura o scrittura in questo momento
    dati = nuovoValore   // Scrivi in sicurezza il nuovo valore
    rwMu.Unlock()        // Sblocca dopo che la scrittura è completata
}

func main() {
    go scriviDati(42)     // Avvia una goroutine per eseguire un'operazione di scrittura
    fmt.Println(leggiDati()) // La goroutine principale esegue un'operazione di lettura
    // Utilizzare WaitGroup o altri metodi di sincronizzazione per assicurarsi che tutte le goroutine siano terminate
}

Nell'esempio sopra, più lettori possono eseguire contemporaneamente la funzione leggiDati, ma un scrittore che esegue scriviDati bloccherà nuovi lettori e altri scrittori. Questo meccanismo fornisce vantaggi prestazionali per scenari con più letture che scritture.

2.3 Variabili di condizione (sync.Cond)

2.3.1 Concetto di Variabili Condizionali

Nel meccanismo di sincronizzazione del linguaggio Go, le variabili condizionali vengono utilizzate per attendere o notificare cambiamenti di condizione come primitiva di sincronizzazione. Le variabili condizionali vengono sempre utilizzate insieme a un mutex (sync.Mutex), che serve a proteggere la coerenza della condizione stessa.

Il concetto di variabili condizionali proviene dal dominio dei sistemi operativi, consentendo a un gruppo di goroutine di attendere che venga soddisfatta una certa condizione. Più specificamente, una goroutine può mettere in pausa l'esecuzione mentre attende che venga soddisfatta una condizione, e un'altra goroutine può notificare ad altre goroutine di riprendere l'esecuzione dopo aver modificato la condizione usando la variabile condizionale.

Nella libreria standard di Go, le variabili condizionali sono fornite attraverso il tipo sync.Cond, e i suoi principali metodi includono:

  • Wait: Chiamando questo metodo, si rilascerà il blocco detenuto e si bloccherà fino a quando un'altra goroutine chiama Signal o Broadcast sulla stessa variabile condizionale per svegliarla, dopodiché cercherà di acquisire nuovamente il blocco.
  • Signal: Sveglia una goroutine in attesa di questa variabile condizionale. Se nessuna goroutine è in attesa, chiamare questo metodo non avrà alcun effetto.
  • Broadcast: Sveglia tutte le goroutine in attesa di questa variabile condizionale.

Le variabili condizionali non dovrebbero essere copiate, quindi vengono generalmente utilizzate come campo puntatore di una certa struttura.

2.3.2 Casi Pratici delle Variabili Condizionali

Ecco un esempio che utilizza variabili condizionali e dimostra un modello semplice produttore-consumatore:

package main

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

// SafeQueue è una coda sicura protetta da un mutex
type SafeQueue struct {
    mu    sync.Mutex
    cond  *sync.Cond
    queue []interface{}
}

// Enqueue aggiunge un elemento alla fine della coda e notifica le goroutine in attesa
func (sq *SafeQueue) Enqueue(item interface{}) {
    sq.mu.Lock()
    defer sq.mu.Unlock()

    sq.queue = append(sq.queue, item)
    sq.cond.Signal() // Notifica alle goroutine in attesa che la coda non è vuota
}

// Dequeue rimuove un elemento dall'inizio della coda, attende se la coda è vuota
func (sq *SafeQueue) Dequeue() interface{} {
    sq.mu.Lock()
    defer sq.mu.Unlock()

    // Attendere quando la coda è vuota
    for len(sq.queue) == 0 {
        sq.cond.Wait() // Attendere un cambio di condizione
    }

    item := sq.queue[0]
    sq.queue = sq.queue[1:]
    return item
}

func main() {
    queue := make([]interface{}, 0)
    sq := SafeQueue{
        mu:    sync.Mutex{},
        cond:  sync.NewCond(&sync.Mutex{}),
        queue: queue,
    }

    // Goroutine Produttore
    go func() {
        for i := 0; i < 5; i++ {
            time.Sleep(1 * time.Second)         // Simulare il tempo di produzione
            sq.Enqueue(fmt.Sprintf("elemento%d", i)) // Producendo un elemento
            fmt.Println("Produce:", i)
        }
    }()

    // Goroutine Consumatore
    go func() {
        for i := 0; i < 5; i++ {
            elemento := sq.Dequeue() // Consuma un elemento, attende se la coda è vuota
            fmt.Printf("Consuma: %v\n", elemento)
        }
    }()

    // Attendere per un periodo sufficiente per garantire che tutta la produzione e il consumo siano completati
    time.Sleep(10 * time.Second)
}

In questo esempio, abbiamo definito una struttura SafeQueue con una coda interna e una variabile condizionale. Quando il consumatore chiama il metodo Dequeue e la coda è vuota, attende utilizzando il metodo Wait. Quando il produttore chiama il metodo Enqueue per inserire un nuovo elemento, utilizza il metodo Signal per risvegliare il consumatore in attesa.

2.4 WaitGroup

2.4.1 Concetto e Utilizzo di WaitGroup

sync.WaitGroup è un meccanismo di sincronizzazione utilizzato per attendere che un gruppo di goroutine completi. Quando si avvia una goroutine, è possibile aumentare il contatore chiamando il metodo Add, e ogni goroutine può chiamare il metodo Done (che in realtà esegue Add(-1)) quando ha finito. La goroutine principale può bloccarsi chiamando il metodo Wait fino a quando il contatore raggiunge 0, indicando che tutte le goroutine hanno completato i loro compiti.

Nell'uso di WaitGroup, si deve notare quanto segue:

  • I metodi Add, Done e Wait non sono thread-safe e non dovrebbero essere chiamati contemporaneamente in più goroutine.
  • Il metodo Add dovrebbe essere chiamato prima che la goroutine appena creata inizi l'esecuzione.

2.4.2 Casi d'Uso Pratici di WaitGroup

Ecco un esempio di utilizzo di WaitGroup:

package main

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

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done() // Notifica WaitGroup al termine

	fmt.Printf("Worker %d inizia\n", id)
time.Sleep(time.Second) // Simulazione di un'operazione time-consuming
	fmt.Printf("Worker %d completato\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 5; i++ {
		wg.Add(1) // Incrementa il contatore prima di avviare la goroutine
		go worker(i, &wg)
	}

	wg.Wait() // Attendi il completamento di tutte le goroutine worker
	fmt.Println("Tutti i workers completati")
}

In questo esempio, la funzione worker simula l'esecuzione di un'attività. Nella funzione principale, avviamo cinque goroutine worker. Prima di avviare ogni goroutine, chiamiamo wg.Add(1) per notificare a WaitGroup che una nuova attività viene eseguita. Quando ogni funzione worker completa, chiama defer wg.Done() per notificare a WaitGroup che l'attività è completata. Dopo aver avviato tutte le goroutine, la funzione principale si blocca su wg.Wait() fino a quando tutti i workers segnalano il completamento.

2.5 Operazioni Atomiche (sync/atomic)

2.5.1 Concetto di Operazioni Atomiche

Le operazioni atomiche si riferiscono alle operazioni nella programmazione concorrente che sono indivisibili, il che significa che non sono interrotte da altre operazioni durante l'esecuzione. Per più goroutine, l'uso di operazioni atomiche può garantire la coerenza dei dati e la sincronizzazione dello stato senza la necessità di locking, poiché le operazioni atomiche stesse garantiscono l'atomicità dell'esecuzione.

Nel linguaggio Go, il pacchetto sync/atomic fornisce operazioni atomiche a basso livello sulla memoria. Per tipi di dati di base come int32, int64, uint32, uint64, uintptr e pointer, i metodi del pacchetto sync/atomic possono essere utilizzati per operazioni concorrenti sicure. L'importanza delle operazioni atomiche risiede nel fatto che sono la base per la costruzione di altri primitivi concorrenti (come lock e variabili di condizione) e spesso sono più efficienti dei meccanismi di locking.

2.5.2 Casi d'Uso Pratici delle Operazioni Atomiche

Consideriamo uno scenario in cui è necessario tracciare il numero di visitatori contemporanei a un sito web. Utilizzando una semplice variabile contatore in modo intuitivo, incrementeremmo il contatore quando un visitatore arriva e lo decrementeremmo quando un visitatore parte. Tuttavia, in un ambiente concorrente, questo approccio porterebbe a corse dati. Pertanto, possiamo utilizzare il pacchetto sync/atomic per manipolare in modo sicuro il contatore.

package main

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

var visitorCount int32

func incrementVisitorCount() {
	atomic.AddInt32(&visitorCount, 1)
}

func decrementVisitorCount() {
	atomic.AddInt32(&visitorCount, -1)
}

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			incrementVisitorCount()
			time.Sleep(time.Second) // Tempo di visita del visitatore
			decrementVisitorCount()
			wg.Done()
		}()
	}
	wg.Wait()
	fmt.Printf("Conteggio attuale visitatori: %d\n", visitorCount)
}

In questo esempio, creiamo 100 goroutine per simulare l'arrivo e la partenza dei visitatori. Utilizzando la funzione atomic.AddInt32(), garantiamo che gli incrementi e i decrementi del contatore siano atomici, anche in situazioni altamente concorrenti, garantendo così l'accuratezza di visitorCount.

2.6 Meccanismo di Sincronizzazione dei Canali

2.6.1 Caratteristiche di Sincronizzazione dei Canali

I canali sono un modo per le goroutine di comunicare nel linguaggio Go a livello di linguaggio. Un canale fornisce la capacità di inviare e ricevere dati. Quando una goroutine tenta di leggere dati da un canale e il canale non ha dati, si bloccherà finché non ci saranno dati disponibili. Allo stesso modo, se il canale è pieno (per un canale non bufferizzato, questo significa che ha già dati), la goroutine che cerca di inviare dati si bloccherà anche finché non ci sarà spazio per la scrittura. Questa caratteristica rende i canali molto utili per la sincronizzazione tra goroutine.

2.6.2 Casi d'Uso della Sincronizzazione con i Canali

Supponiamo di avere un compito che deve essere completato da più goroutine, ognuna gestendo un sotto-compito, e poi dobbiamo aggregare i risultati di tutti i sotto-compiti. Possiamo utilizzare un canale per attendere che tutte le goroutine finiscano.

package main

import (
    "fmt"
    "sync"
)

func worker(id int, wg *sync.WaitGroup, resultChan chan<- int) {
    defer wg.Done()
    // Svolgi alcune operazioni...
    fmt.Printf("Worker %d inizia\n", id)
    // Assumiamo che il risultato del sotto-compito sia l'id del worker
    resultChan <- id
    fmt.Printf("Worker %d completato\n", id)
}

func main() {
    var wg sync.WaitGroup
    numWorkers := 5
    resultChan := make(chan int, numWorkers)

    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go worker(i, &wg, resultChan)
    }

    go func() {
        wg.Wait()
        close(resultChan)
    }()

    // Raccogli tutti i risultati
    for result := range resultChan {
        fmt.Printf("Risultato ricevuto: %d\n", result)
    }
}

In questo esempio, avviamo 5 goroutine per svolgere compiti e raccogliamo i risultati tramite il canale resultChan. La goroutine principale attende che tutto il lavoro venga completato in una goroutine separata e poi chiude il canale dei risultati. Successivamente, la goroutine principale attraversa il canale resultChan, raccogliendo e stampando i risultati di tutte le goroutine.

2.7 Esecuzione Singola (sync.Once)

sync.Once è una primitiva di sincronizzazione che garantisce che un'operazione venga eseguita una sola volta durante l'esecuzione del programma. Un uso tipico di sync.Once è nell'inizializzazione di un oggetto singleton o in scenari che richiedono inizializzazione ritardata. Indipendentemente da quante goroutine chiamino questa operazione, verrà eseguita solo una volta, da qui il nome della funzione Do.

sync.Once bilancia perfettamente le questioni di concorrenza ed efficienza di esecuzione, eliminando preoccupazioni per problemi di prestazioni causati da inizializzazioni ripetute.

Come semplice esempio per dimostrare l'uso di sync.Once:

package main

import (
    "fmt"
    "sync"
)

var once sync.Once
var instance *Singleton

type Singleton struct{}

func Instance() *Singleton {
    once.Do(func() {
        fmt.Println("Creazione istanza singola ora.")
        instance = &Singleton{}
    })
    return instance
}

func main() {
    for i := 0; i < 10; i++ {
        go Instance()
    }
    fmt.Scanln() // Attendere per vedere l'output
}

In questo esempio, anche se la funzione Instance è chiamata contemporaneamente più volte, la creazione dell'istanza Singleton avverrà solo una volta. Le chiamate successive restituiranno direttamente l'istanza singleton creata la prima volta, garantendo l'unicità dell'istanza.

2.8 ErrGroup

ErrGroup è una libreria nel linguaggio Go usata per sincronizzare più goroutine e raccogliere i loro errori. Fa parte del pacchetto "golang.org/x/sync/errgroup", offrendo un modo conciso per gestire scenari di errore in operazioni concorrenti.

2.8.1 Concetto di ErrGroup

L'idea principale di ErrGroup è legare insieme un gruppo di compiti correlati (di solito eseguiti in modo concorrente) e se uno dei compiti fallisce, l'esecuzione dell'intero gruppo verrà cancellata. Allo stesso tempo, se una di queste operazioni concorrenti restituisce un errore, ErrGroup catturerà e restituirà questo errore.

Per utilizzare ErrGroup, importa prima il pacchetto:

import "golang.org/x/sync/errgroup"

Quindi, crea un'istanza di ErrGroup:

var g errgroup.Group

Dopo di che, puoi passare i compiti a ErrGroup sotto forma di chiusure e avviare una nuova Goroutine chiamando il metodo Go:

g.Go(func() error {
    // Svolgi un certo compito
    // Se tutto va bene
    return nil
    // Se si verifica un errore
    // return fmt.Errorf("errore occorso")
})

Infine, chiama il metodo Wait, che bloccherà e attende il completamento di tutti i compiti. Se uno di questi compiti restituisce un errore, Wait restituirà quell'errore:

if err := g.Wait(); err != nil {
    // Gestire l'errore
    log.Fatalf("Errore durante l'esecuzione del compito: %v", err)
}

2.8.2 Caso pratico di ErrGroup

Consideriamo uno scenario in cui è necessario recuperare dati in modo concorrente da tre diverse fonti di dati e, se una qualsiasi delle fonti di dati fallisce, vogliamo annullare immediatamente le altre operazioni di recupero dati. Questo compito può essere facilmente realizzato utilizzando ErrGroup:

package main

import (
    "fmt"
    "golang.org/x/sync/errgroup"
)

func fetchDataFromSource1() error {
    // Simuliamo il recupero dei dati dalla fonte 1
    return nil // oppure restituire un errore per simulare un fallimento
}

func fetchDataFromSource2() error {
    // Simuliamo il recupero dei dati dalla fonte 2
    return nil // oppure restituire un errore per simulare un fallimento
}

func fetchDataFromSource3() error {
    // Simuliamo il recupero dei dati dalla fonte 3
    return nil // oppure restituire un errore per simulare un fallimento
}

func main() {
    var g errgroup.Group

    g.Go(fetchDataFromSource1)
    g.Go(fetchDataFromSource2)
    g.Go(fetchDataFromSource3)

    // Attendere il completamento di tutte le Goroutine e raccogliere i loro errori
    if err := g.Wait(); err != nil {
        fmt.Printf("Si è verificato un errore durante il recupero dei dati: %v\n", err)
        return
    }

    fmt.Println("Tutti i dati sono stati recuperati con successo!")
}

In questo esempio, le funzioni fetchDataFromSource1, fetchDataFromSource2 e fetchDataFromSource3 simulano il recupero dei dati da differenti fonti di dati. Queste funzioni sono passate al metodo g.Go ed eseguite in Goroutine separate. Se una qualsiasi delle funzioni restituisce un errore, g.Wait restituirà immediatamente quell'errore, consentendo un'opportuna gestione degli errori quando si verifica un errore. Se tutte le funzioni vengono eseguite correttamente, g.Wait restituirà nil, indicando che tutti i compiti sono stati completati con successo.

Un'altra caratteristica importante di ErrGroup è che se una qualsiasi delle Goroutine genera un panic, cercherà di recuperare questo panic e restituirlo come un errore. Questo aiuta a evitare che altre Goroutine in esecuzione concorrente non riescano a chiudersi in modo corretto. Naturalmente, se si desidera che i compiti rispondano a segnali esterni di annullamento, è possibile combinare la funzione WithContext di errgroup con il pacchetto di contesto per fornire un contesto annullabile.

In questo modo, ErrGroup diventa un meccanismo molto pratico di sincronizzazione e gestione degli errori nella pratica della programmazione concorrente di Go.