1. Le rôle des mécanismes de synchronisation

En programmation concurrente, lorsque plusieurs goroutines partagent des ressources, il est nécessaire de s'assurer que les ressources ne peuvent être accédées que par une goroutine à la fois pour éviter les conditions de course. Cela nécessite l'utilisation de mécanismes de synchronisation. Les mécanismes de synchronisation peuvent coordonner l'ordre d'accès des différentes goroutines aux ressources partagées, garantissant la cohérence des données et la synchronisation des états dans un environnement concurrent.

Le langage Go fournit un ensemble riche de mécanismes de synchronisation, comprenant entre autres:

  • Mutex (sync.Mutex) et mutex de lecture-écriture (sync.RWMutex)
  • Canaux (Channels)
  • WaitGroups
  • Fonctions atomiques (package atomic)
  • Variables conditionnelles (sync.Cond)

2. Primitives de synchronisation

2.1 Mutex (sync.Mutex)

2.1.1 Concept et rôle du mutex

Le mutex est un mécanisme de synchronisation qui garantit le bon fonctionnement des ressources partagées en permettant à une seule goroutine de détenir le verrou pour accéder à la ressource partagée à tout moment. Le mutex réalise la synchronisation à travers les méthodes Lock et Unlock. L'appel à la méthode Lock bloquera jusqu'à ce que le verrou soit libéré, à ce moment-là, les autres goroutines qui tentent d'acquérir le verrou attendront. L'appel à Unlock libère le verrou, permettant à d'autres goroutines en attente de l'acquérir.

var mu sync.Mutex

func sectionCritique() {
    // Acquérir le verrou pour accéder exclusivement à la ressource
    mu.Lock()
    // Accéder à la ressource partagée ici
    // ...
    // Libérer le verrou pour permettre à d'autres goroutines de l'acquérir
    mu.Unlock()
}

2.1.2 Utilisation pratique du mutex

Supposons que nous devons maintenir un compteur global, et que plusieurs goroutines doivent incrémenter sa valeur. En utilisant un mutex, nous pouvons garantir l'exactitude du compteur.

var (
    mu      sync.Mutex
    compteur int
)

func incrementer() {
    mu.Lock()         // Verrouiller avant de modifier le compteur
    compteur++         // Incrémenter en toute sécurité le compteur
    mu.Unlock()       // Déverrouiller après l'opération, permettant à d'autres goroutines d'accéder au compteur
}

func main() {
    for i := 0; i < 10; i++ {
        go incrementer()  // Démarrer plusieurs goroutines pour incrémenter la valeur du compteur
    }
    // Attendre un certain temps (en pratique, vous devriez utiliser WaitGroup ou d'autres méthodes pour attendre que toutes les goroutines se terminent)
    time.Sleep(1 * time.Second)
    fmt.Println(compteur)  // Afficher la valeur du compteur
}

2.2 Mutex de lecture-écriture (sync.RWMutex)

2.2.1 Concept du mutex de lecture-écriture

Le RWMutex est un type de verrou spécial qui permet à plusieurs goroutines de lire des ressources partagées simultanément, tandis que les opérations d'écriture sont exclusives. Par rapport aux mutex, les verrous de lecture-écriture peuvent améliorer les performances dans les scénarios à plusieurs lecteurs. Il dispose de quatre méthodes : RLock, RUnlock pour verrouiller et déverrouiller les opérations de lecture, et Lock, Unlock pour verrouiller et déverrouiller les opérations d'écriture.

2.2.2 Cas d'utilisation pratique du Mutex de Lecture-Écriture

Dans une application de base de données, les opérations de lecture peuvent être beaucoup plus fréquentes que les opérations d'écriture. L'utilisation d'un verrou de lecture-écriture peut améliorer les performances du système car il permet à plusieurs goroutines de lire simultanément.

var (
    rwMu  sync.RWMutex
    donnees  int
)

func lireDonnees() int {
    rwMu.RLock()         // Acquérir le verrou de lecture, permettant à d'autres opérations de lecture de se dérouler simultanément
    defer rwMu.RUnlock() // S'assurer que le verrou est libéré en utilisant defer
    return donnees          // Lire en toute sécurité les données
}

func ecrireDonnees(nouvelleValeur int) {
    rwMu.Lock()          // Acquérir le verrou d'écriture, empêchant d'autres opérations de lecture ou d'écriture pour le moment
    donnees = nouvelleValeur      // Écrire en toute sécurité la nouvelle valeur
    rwMu.Unlock()        // Déverrouiller après l'écriture complète
}

func main() {
    go ecrireDonnees(42)     // Démarrer une goroutine pour effectuer une opération d'écriture
    fmt.Println(lireDonnees()) // La goroutine principale effectue une opération de lecture
    // Utiliser WaitGroup ou d'autres méthodes de synchronisation pour s'assurer que toutes les goroutines sont terminées
}

Dans l'exemple ci-dessus, plusieurs lecteurs peuvent exécuter la fonction lireDonnees simultanément, mais un écrivain exécutant ecrireDonnees bloquera de nouveaux lecteurs et d'autres écrivains. Ce mécanisme offre des avantages de performance pour les scénarios avec plus de lectures que d'écritures.

2.3 Variables Conditionnelles (sync.Cond)

2.3.1 Concept of Conditional Variables

Dans le mécanisme de synchronisation du langage Go, les variables conditionnelles sont utilisées pour attendre ou notifier des modifications de condition en tant que primitive de synchronisation. Les variables conditionnelles sont toujours utilisées en association avec un mutex (sync.Mutex), qui sert à protéger la cohérence de la condition elle-même.

Le concept de variables conditionnelles provient du domaine des systèmes d'exploitation, permettant à un groupe de goroutines d'attendre qu'une certaine condition soit remplie. Plus précisément, une goroutine peut suspendre son exécution pendant l'attente qu'une condition soit remplie, et une autre goroutine peut notifier les autres goroutines de reprendre leur exécution après avoir modifié la condition à l'aide de la variable conditionnelle.

Dans la bibliothèque standard Go, les variables conditionnelles sont fournies à travers le type sync.Cond, et ses principales méthodes incluent:

  • Wait: L'appel de cette méthode libérera le verrou tenu et se bloquera jusqu'à ce qu'une autre goroutine appelle Signal ou Broadcast sur la même variable conditionnelle pour la réveiller, après quoi elle tentera à nouveau d'acquérir le verrou.
  • Signal: Réveille une goroutine en attente de cette variable conditionnelle. Si aucune goroutine n'attend, appeler cette méthode n'aura aucun effet.
  • Broadcast: Réveille toutes les goroutines en attente de cette variable conditionnelle.

Les variables conditionnelles ne doivent pas être copiées, elles sont donc généralement utilisées en tant que champ de pointeur d'une certaine structure.

2.3.2 Cas pratiques des Variables Conditionnelles

Voici un exemple d'utilisation des variables conditionnelles qui démontre un modèle simple producteur-consommateur:

package main

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

// SafeQueue est une file d'attente sécurisée protégée par un mutex
type SafeQueue struct {
    mu    sync.Mutex
    cond  *sync.Cond
    queue []interface{}
}

// Enqueue ajoute un élément à la fin de la file d'attente et notifie les goroutines en attente
func (sq *SafeQueue) Enqueue(item interface{}) {
    sq.mu.Lock()
    defer sq.mu.Unlock()

    sq.queue = append(sq.queue, item)
    sq.cond.Signal() // Notifier les goroutines en attente que la file n'est pas vide
}

// Dequeue supprime un élément de la tête de la file d'attente, attend si la file est vide
func (sq *SafeQueue) Dequeue() interface{} {
    sq.mu.Lock()
    defer sq.mu.Unlock()

    // Attendre lorsque la file est vide
    for len(sq.queue) == 0 {
        sq.cond.Wait() // Attendre un changement de condition
    }

    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 Producteur
    go func() {
        for i := 0; i < 5; i++ {
            time.Sleep(1 * time.Second)         // Simuler le temps de production
            sq.Enqueue(fmt.Sprintf("élément%d", i)) // Produire un élément
            fmt.Println("Production:", i)
        }
    }()

    // Goroutine Consommateur
    go func() {
        for i := 0; i < 5; i++ {
            item := sq.Dequeue() // Consommer un élément, attendre si la file est vide
            fmt.Printf("Consommation: %v\n", item)
        }
    }()

    // Attendre un laps de temps suffisant pour s'assurer que toute la production et la consommation sont terminées
    time.Sleep(10 * time.Second)
}

Dans cet exemple, nous avons défini une structure SafeQueue avec une file interne et une variable conditionnelle. Lorsque le consommateur appelle la méthode Dequeue et que la file est vide, il attend en utilisant la méthode Wait. Lorsque le producteur appelle la méthode Enqueue pour mettre en file un nouvel élément, il utilise la méthode Signal pour réveiller le consommateur en attente.

2.4 WaitGroup

2.4.1 Concept et Utilisation de WaitGroup

sync.WaitGroup est un mécanisme de synchronisation utilisé pour attendre qu'un groupe de goroutines se termine. Lorsque vous démarrez une goroutine, vous pouvez incrémenter le compteur en appelant la méthode Add, et chaque goroutine peut appeler la méthode Done (qui effectue en réalité Add(-1)) lorsqu'elle a fini. La goroutine principale peut bloquer en appelant la méthode Wait jusqu'à ce que le compteur atteigne 0, indiquant que toutes les goroutines ont terminé leurs tâches.

Lors de l'utilisation de WaitGroup, les points suivants doivent être notés:

  • Les méthodes Add, Done et Wait ne sont pas sûres pour les threads et ne doivent pas être appelées simultanément dans plusieurs goroutines.
  • La méthode Add doit être appelée avant le démarrage de la nouvelle goroutine.

2.4.2 Cas d'utilisation pratique de WaitGroup

Voici un exemple d'utilisation de WaitGroup :

package main

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

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done() // Notifier WaitGroup à la fin

	fmt.Printf("Le travailleur %d commence\n", id)
	time.Sleep(time.Second) // Simuler une opération prenant du temps
	fmt.Printf("Le travailleur %d a terminé\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 5; i++ {
		wg.Add(1) // Incrémenter le compteur avant de démarrer la goroutine
		go worker(i, &wg)
	}

	wg.Wait() // Attendre que toutes les goroutines des travailleurs aient terminé
	fmt.Println("Tous les travailleurs ont terminé")
}

Dans cet exemple, la fonction worker simule l'exécution d'une tâche. Dans la fonction principale, nous démarrons cinq goroutines de worker. Avant de démarrer chaque goroutine, nous appelons wg.Add(1) pour notifier à WaitGroup qu'une nouvelle tâche est en cours d'exécution. Lorsque chaque fonction de travailleur est terminée, elle appelle defer wg.Done() pour informer WaitGroup que la tâche est terminée. Après le démarrage de toutes les goroutines, la fonction principale bloque à wg.Wait() jusqu'à ce que tous les travailleurs signalent la fin.

2.5 Opérations atomiques (sync/atomic)

2.5.1 Concept des opérations atomiques

Les opérations atomiques font référence à des opérations en programmation concurrente qui sont indivisibles, c'est-à-dire qu'elles ne sont pas interrompues par d'autres opérations pendant l'exécution. Pour plusieurs goroutines, l'utilisation d'opérations atomiques peut garantir la cohérence des données et la synchronisation des états sans avoir besoin de verrouillage, car les opérations atomiques garantissent elles-mêmes l'atomicité de l'exécution.

En langage Go, le package sync/atomic fournit des opérations atomiques bas niveau sur la mémoire. Pour les types de données de base tels que int32, int64, uint32, uint64, uintptr et pointer, les méthodes du package sync/atomic peuvent être utilisées pour des opérations concurrentes sûres. L'importance des opérations atomiques réside dans le fait qu'elles sont la pierre angulaire pour la construction d'autres primitives concurrentes (telles que les verrous et les variables conditionnelles) et sont souvent plus efficaces que les mécanismes de verrouillage.

2.5.2 Cas d'utilisation pratique des opérations atomiques

Considérons un scénario où nous devons suivre le nombre concurrent de visiteurs sur un site web. En utilisant une simple variable de compteur de manière intuitive, nous augmenterions le compteur lorsqu'un visiteur arrive et le diminuerions lorsqu'un visiteur part. Cependant, dans un environnement concurrent, cette approche entraînerait des courses de données. Par conséquent, nous pouvons utiliser le package sync/atomic pour manipuler en toute sécurité le compteur.

package main

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

var nombreVisiteurs int32

func incrementerNombreVisiteurs() {
	atomic.AddInt32(&nombreVisiteurs, 1)
}

func diminuerNombreVisiteurs() {
	atomic.AddInt32(&nombreVisiteurs, -1)
}

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			incrementerNombreVisiteurs()
			time.Sleep(time.Second) // Durée de la visite du visiteur
			diminuerNombreVisiteurs()
			wg.Done()
		}()
	}
	wg.Wait()
	fmt.Printf("Nombre actuel de visiteurs : %d\n", nombreVisiteurs)
}

Dans cet exemple, nous créons 100 goroutines pour simuler l'arrivée et le départ des visiteurs. En utilisant la fonction atomic.AddInt32(), nous nous assurons que les augmentations et les diminutions du compteur sont atomiques, même dans des situations fortement concurrentes, garantissant ainsi l'exactitude de nombreVisiteurs.

2.6 Mécanisme de synchronisation des canaux

2.6.1 Caractéristiques de synchronisation des canaux

Les canaux sont un moyen pour les goroutines de communiquer dans le langage Go au niveau du langage. Un canal offre la possibilité d'envoyer et de recevoir des données. Lorsqu'une goroutine tente de lire des données depuis un canal et que le canal n'a pas de données, elle bloquera jusqu'à ce qu'il y ait des données disponibles. De même, si le canal est plein (pour un canal non mis en mémoire tampon, cela signifie qu'il a déjà des données), la goroutine qui tente d'envoyer des données bloquera également jusqu'à ce qu'il y ait de l'espace pour écrire. Cette fonctionnalité rend les canaux très utiles pour la synchronisation entre les goroutines.

2.6.2 Cas d'utilisation de la Synchronisation avec les Canaux

Supposons que nous avons une tâche qui doit être réalisée par plusieurs goroutines, chacune s'occupant d'une sous-tâche, et que nous devons ensuite agréger les résultats de toutes les sous-tâches. Nous pouvons utiliser un canal pour attendre que toutes les goroutines se terminent.

package main

import (
    "fmt"
    "sync"
)

func worker(id int, wg *sync.WaitGroup, resultChan chan<- int) {
    defer wg.Done()
    // Effectuer certaines opérations...
    fmt.Printf("Worker %d démarre\n", id)
    // Supposons que le résultat de la sous-tâche est l'identifiant du travailleur
    resultChan <- id
    fmt.Printf("Worker %d terminé\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)
    }()

    // Collecter tous les résultats
    for result := range resultChan {
        fmt.Printf("Résultat reçu : %d\n", result)
    }
}

Dans cet exemple, nous démarrons 5 goroutines pour effectuer des tâches et collectons les résultats via le canal resultChan. La goroutine principale attend que tout le travail soit terminé dans une goroutine séparée, puis ferme le canal de résultats. Ensuite, la goroutine principale parcourt le canal resultChan, collecte et imprime les résultats de toutes les goroutines.

2.7 Exécution unique (sync.Once)

sync.Once est un élément de synchronisation qui garantit qu'une opération n'est exécutée qu'une seule fois pendant l'exécution du programme. Une utilisation typique de sync.Once se trouve dans l'initialisation d'un objet singleton ou dans des scénarios nécessitant une initialisation différée. Peu importe le nombre de goroutines appelant cette opération, elle ne s'exécutera qu'une seule fois, d'où le nom de la fonction Do.

sync.Once équilibre parfaitement les problèmes de concurrence et l'efficacité d'exécution, éliminant les préoccupations concernant les problèmes de performance causés par une initialisation répétée.

À titre d'exemple simple pour démontrer l'utilisation de 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("Création d'une instance unique maintenant.")
        instance = &Singleton{}
    })
    return instance
}

func main() {
    for i := 0; i < 10; i++ {
        go Instance()
    }
    fmt.Scanln() // Attendre pour voir la sortie
}

Dans cet exemple, même si la fonction Instance est appelée de manière concurrente plusieurs fois, la création de l'instance Singleton ne se produira qu'une seule fois. Les appels ultérieurs retourneront directement l'instance singleton créée la première fois, garantissant l'unicité de l'instance.

2.8 ErrGroup

ErrGroup est une bibliothèque en langage Go utilisée pour synchroniser plusieurs goroutines et collecter leurs erreurs. Elle fait partie du package "golang.org/x/sync/errgroup", offrant une manière concise de gérer les scénarios d'erreur dans les opérations concurrentes.

2.8.1 Concept d'ErrGroup

L'idée principale d'ErrGroup est de lier un groupe de tâches connexes (généralement exécutées de manière concurrente) ensemble, et si l'une des tâches échoue, l'exécution de tout le groupe sera annulée. Dans le même temps, si l'une de ces opérations concurrentes retourne une erreur, ErrGroup la capturera et la retournera.

Pour utiliser ErrGroup, importez d'abord le package :

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

Ensuite, créez une instance d'ErrGroup :

var g errgroup.Group

Ensuite, vous pouvez passer les tâches à ErrGroup sous forme de fermetures et démarrer une nouvelle goroutine en appelant la méthode Go :

g.Go(func() error {
    // Effectuer une certaine tâche
    // Si tout se passe bien
    return nil
    // Si une erreur se produit
    // return fmt.Errorf("une erreur s'est produite")
})

Enfin, appelez la méthode Wait, qui bloquera et attendra la fin de toutes les tâches. Si l'une de ces tâches retourne une erreur, Wait renverra cette erreur :

if err := g.Wait(); err != nil {
    // Gérer l'erreur
    log.Fatalf("Erreur d'exécution de la tâche : %v", err)
}

2.8.2 Cas pratique de ErrGroup

Considérons un scénario où nous devons récupérer des données simultanément à partir de trois sources de données différentes, et si l'une d'entre elles échoue, nous voulons annuler immédiatement les autres opérations de récupération des données. Cette tâche peut être facilement accomplie en utilisant ErrGroup :

package main

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

func fetchDataFromSource1() error {
    // Simuler la récupération des données à partir de la source 1
    return nil // ou renvoyer une erreur pour simuler un échec
}

func fetchDataFromSource2() error {
    // Simuler la récupération des données à partir de la source 2
    return nil // ou renvoyer une erreur pour simuler un échec
}

func fetchDataFromSource3() error {
    // Simuler la récupération des données à partir de la source 3
    return nil // ou renvoyer une erreur pour simuler un échec
}

func main() {
    var g errgroup.Group

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

    // Attendez que toutes les goroutines soient terminées et collectez leurs erreurs
    if err := g.Wait(); err != nil {
        fmt.Printf("Une erreur s'est produite lors de la récupération des données : %v\n", err)
        return
    }

    fmt.Println("Toutes les données ont été récupérées avec succès !")
}

Dans cet exemple, les fonctions fetchDataFromSource1, fetchDataFromSource2 et fetchDataFromSource3 simulent la récupération de données à partir de différentes sources de données. Elles sont passées à la méthode g.Go et exécutées dans des Goroutines séparées. Si l'une des fonctions renvoie une erreur, g.Wait renverra immédiatement cette erreur, permettant une gestion appropriée des erreurs lorsqu'elles se produisent. Si toutes les fonctions s'exécutent avec succès, g.Wait renverra nil, indiquant que toutes les tâches ont été réalisées avec succès.

Une autre caractéristique importante d'ErrGroup est que si l'une des Goroutines panique, elle tentera de récupérer cette panique et de la renvoyer comme une erreur. Cela aide à éviter que d'autres Goroutines en cours d'exécution échouent à s'arrêter gracieusement. Bien sûr, si vous souhaitez que les tâches répondent à des signaux d'annulation externes, vous pouvez combiner la fonction WithContext de errgroup avec le package context pour fournir un contexte annulable.

De cette manière, ErrGroup devient un mécanisme de synchronisation et de gestion des erreurs très pratique dans la pratique de la programmation concurrente en Go.