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 appelleSignal
ouBroadcast
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
etWait
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.