Contactez-nous

Synchronisation simple avec `sync.WaitGroup`

Apprenez à synchroniser vos goroutines en Go avec `sync.WaitGroup` pour attendre leur achèvement. Maîtrisez les méthodes Add, Done et Wait.

Le problème de l'attente : Quand les Goroutines doivent finir

Nous avons vu comment lancer des fonctions de manière concurrente à l'aide de goroutines avec le mot-clé `go`. Cependant, nous avons aussi constaté un problème potentiel : la goroutine principale (exécutant `main`) peut se terminer avant que les goroutines secondaires n'aient achevé leur travail, entraînant l'arrêt prématuré de tout le programme. Utiliser `time.Sleep` pour attendre est une mauvaise solution car elle est arbitraire et peu fiable.

Comment faire pour que la goroutine principale attende proprement qu'un certain nombre d'autres goroutines aient terminé leur exécution ? C'est un besoin de synchronisation très courant. Pour ce scénario spécifique – attendre la fin d'un groupe de goroutines – Go fournit un outil simple et efficace dans le paquet standard `sync` : le type `sync.WaitGroup`.

Un `WaitGroup` fonctionne comme un compteur de goroutines actives. La goroutine principale peut attendre que ce compteur retombe à zéro, signalant ainsi que toutes les goroutines comptabilisées ont terminé.

Mécanisme et méthodes clés : `Add`, `Done`, `Wait`

L'utilisation d'un `sync.WaitGroup` repose sur trois méthodes principales :

1. `Add(delta int)` : Incrémente le compteur interne du `WaitGroup` de la valeur `delta`. Vous appelez généralement `Add(1)` pour chaque goroutine que vous êtes sur le point de lancer et dont vous voulez attendre la fin. Crucial : `Add` doit être appelé par la goroutine qui lance les autres, et ce, avant de lancer la goroutine à compter.

2. `Done()` : Décrémente le compteur interne du `WaitGroup` de 1. Cette méthode doit être appelée par la goroutine elle-même lorsqu'elle a terminé son travail. La manière idiomatique et la plus sûre de le faire est d'utiliser `defer wg.Done()` au début du corps de la fonction exécutée par la goroutine. Ainsi, `Done()` sera appelée même si la fonction se termine prématurément (par exemple, via un `return` ou une panique récupérée).

3. `Wait()` : Bloque la goroutine qui appelle `Wait()` jusqu'à ce que le compteur interne du `WaitGroup` devienne zéro. C'est généralement la goroutine principale qui appelle `wg.Wait()` après avoir lancé toutes les goroutines qu'elle souhaite attendre.

Le cycle de vie typique est donc : initialiser le compteur avec `Add` pour le nombre de goroutines à attendre, lancer les goroutines (chacune appelant `defer wg.Done()`), puis appeler `Wait()` pour bloquer jusqu'à ce que toutes aient appelé `Done()`.

Mise en pratique : Exemple complet

Voyons comment utiliser `WaitGroup` pour attendre la fin de plusieurs goroutines "travailleuses" :

package main

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

// Fonction simulant un travail effectué par une goroutine
func travailleur(id int, wg *sync.WaitGroup) {
    // Assure que Done() est appelé lorsque la fonction travailleur se termine
    defer wg.Done()

    fmt.Printf("Travailleur %d : Commence\n", id)
    time.Sleep(time.Second) // Simule une tâche qui prend du temps
    fmt.Printf("Travailleur %d : Termine\n", id)
}

func main() {
    // Déclare une variable WaitGroup
    var wg sync.WaitGroup

    nombreTravailleurs := 3
    fmt.Printf("Lancement de %d travailleurs...\n", nombreTravailleurs)

    // Boucle pour lancer les goroutines
    for i := 1; i <= nombreTravailleurs; i++ {
        fmt.Printf("Main : Ajout et lancement du travailleur %d\n", i)
        // Incrémente le compteur AVANT de lancer la goroutine
        wg.Add(1)
        // Lance la goroutine travailleur
        // Important : passe 'i' comme argument pour éviter le problème de closure avec les boucles
        go travailleur(i, &wg) 
    }

    fmt.Println("Main : Toutes les goroutines lancées. En attente de la fin...")
    // Bloque ici jusqu'à ce que le compteur de wg tombe à 0
    // (c'est-à-dire, jusqu'à ce que les 3 travailleurs aient appelé wg.Done())
    wg.Wait()

    fmt.Println("Main : Tous les travailleurs ont terminé. Fin du programme.")
}

Dans cet exemple :

1. Nous créons une `WaitGroup` (`var wg sync.WaitGroup`).

2. Dans la boucle `for`, avant chaque `go travailleur(...)`, nous appelons `wg.Add(1)`.

3. La fonction `travailleur` prend un pointeur vers la `WaitGroup` en argument.

4. Au début de `travailleur`, `defer wg.Done()` garantit que le compteur sera décrémenté à la fin de la fonction.

5. Après la boucle de lancement, `main` appelle `wg.Wait()`, qui bloque.

6. Au fur et à mesure que chaque `travailleur` termine et appelle `wg.Done()`, le compteur diminue.

7. Lorsque le compteur atteint zéro (après que les 3 travailleurs ont fini), `wg.Wait()` se débloque et `main` peut afficher le message final et se terminer proprement.

Points d'attention et bonnes pratiques

Quelques points importants à garder à l'esprit lors de l'utilisation de `WaitGroup` :

  • `Add` avant `go` : Il est essentiel d'appeler `Add` avant de lancer la goroutine correspondante. Si vous appelez `Add` à l'intérieur de la nouvelle goroutine, il y a une race condition : `Wait` pourrait être appelé par la goroutine principale avant que `Add` n'ait eu le temps d'incrémenter le compteur, menant potentiellement `Wait` à retourner immédiatement si le compteur était à zéro.
  • Utiliser `defer wg.Done()` : C'est la manière la plus robuste d'appeler `Done`. Elle garantit que le compteur est décrémenté même si la fonction de la goroutine a plusieurs points de sortie (`return`) ou si elle panique (et que la panique est récupérée plus haut).
  • Passer `WaitGroup` par pointeur : Bien qu'un `WaitGroup` puisse techniquement être copié, il ne faut jamais le faire après sa première utilisation. Pour éviter les copies accidentelles et s'assurer que toutes les goroutines opèrent sur le même compteur, passez toujours le `WaitGroup` aux fonctions par pointeur (`*sync.WaitGroup`).
  • Eviter les compteurs négatifs : Appeler `Done` plus de fois que `Add` n'a été appelé (ou appeler `Add` avec une valeur négative qui rend le compteur négatif) provoquera une panique. Assurez-vous que votre logique maintient la cohérence du compteur.
  • Passer les variables de boucle : Comme vu dans l'exemple, lors du lancement de goroutines dans une boucle, passez la variable d'itération (et toute autre variable de la boucle nécessaire) comme argument à la fonction anonyme ou nommée lancée par `go`. Sinon, toutes les goroutines risquent de capturer la même instance de la variable, qui aura probablement changé de valeur avant qu'elles ne l'utilisent.

Conclusion : Synchronisation simple pour l'achèvement

`sync.WaitGroup` est l'outil standard et efficace en Go pour résoudre le problème simple mais courant d'attendre qu'un groupe de goroutines indépendantes aient toutes terminé leur travail.

En maîtrisant le cycle `Add` (avant le lancement), `defer Done` (dans la goroutine) et `Wait` (dans la goroutine appelante), vous disposez d'un mécanisme de synchronisation de base robuste et facile à utiliser.

C'est souvent la première étape de synchronisation que l'on apprend en Go, avant d'explorer les capacités plus riches mais aussi plus complexes offertes par les canaux pour la communication et la coordination plus fine entre goroutines.