Le parallélisme en Go — Partie 1 : goroutines et WaitGroup

T'as un programme Go qui fait 10 appels HTTP à une API externe. Chaque appel prend environ 1 seconde. Résultat : ton programme met 10 secondes à finir. Avec les goroutines, ces 10 appels tournent en même temps et l'ensemble prend 1 seconde — la durée du plus lent, pas la somme de tous.

C'est la promesse de la concurrence en Go. Et contrairement à la plupart des langages où c'est une galère à mettre en place (threads OS, locks manuels, callbacks en cascade), Go rend ça accessible dès le premier jour. Encore faut-il éviter les pièges classiques, et il y en a quelques-uns.

Cette série de trois articles part de zéro :

C'est quoi une goroutine ?

L'analogie la plus honnête : imagine les onglets de ton navigateur. Chaque onglet charge sa page "en même temps" — YouTube met en buffer ta vidéo pendant que tu lis un article dans un autre onglet. Ton CPU ne fait pas vraiment tout à la fois (enfin, pas entièrement), mais le système bascule si vite entre les tâches que ça donne l'illusion du parallèle.

Une goroutine, c'est pareil : une fonction qui tourne "en arrière-plan" pendant que le reste du programme continue. La différence avec un thread OS classique, c'est que les goroutines sont gérées par le runtime Go et non par le système d'exploitation. Elles sont légères (quelques kilo-octets au démarrage contre plusieurs mégaoctets pour un thread OS) et tu peux en lancer des milliers sans transpirer.

Pour en lancer une, il suffit de mettre go devant un appel de fonction :

package main

import (
    "fmt"
    "time"
)

func direBonjour(nom string) {
    fmt.Println("Bonjour", nom)
}

func main() {
    go direBonjour("Alice")
    go direBonjour("Bob")
    time.Sleep(10 * time.Millisecond) // on y revient
    fmt.Println("Programme terminé")
}

C'est tout. go direBonjour("Alice") lance la fonction dans une nouvelle goroutine et n'attend pas qu'elle finisse pour passer à la ligne suivante. Le main() continue immédiatement.

Le premier piège — le programme qui finit trop tôt

Lance ce code sans le time.Sleep :

func main() {
    go direBonjour("Alice")
    go direBonjour("Bob")
    fmt.Println("Programme terminé")
}

Résultat probable : tu vois seulement "Programme terminé". Les deux goroutines n'ont jamais eu le temps d'afficher quoi que ce soit. Pourquoi ?

Règle fondamentale : quand main() retourne, le programme Go se termine immédiatement — peu importe combien de goroutines sont encore en train de tourner. Elles sont toutes tuées d'un coup, sans préavis, sans finir leur travail.

Le time.Sleep(10 * time.Millisecond) dans l'exemple précédent est une rustine, pas une solution. Tu "espères" que 10ms suffisent pour que les goroutines finissent. C'est fragile, non déterministe, et ça ne scale pas. Si les goroutines font un appel réseau qui prend 2 secondes, tu dois mettre Sleep(2s) ? Et si parfois ça prend 3 secondes ?

Il faut un mécanisme pour attendre proprement que toutes les goroutines aient terminé.

sync.WaitGroup — la bonne solution

Imagine un chef cuisinier qui distribue des tâches à ses commis avant le service : "Toi tu coupes les oignons, toi tu prépares la sauce, toi tu sors les assiettes." Avant d'ouvrir la salle, il attend que tout le monde ait fini sa préparation. Il ne regarde pas sa montre et espère — il attend explicitement la confirmation de chacun.

sync.WaitGroup, c'est ce mécanisme de synchronisation :

  • wg.Add(n) — "j'attends n goroutines de plus"
  • wg.Done() — "cette goroutine a fini" (décrémente le compteur)
  • wg.Wait() — "bloque jusqu'à ce que le compteur soit à zéro"
package main

import (
    "fmt"
    "sync"
)

func direBonjour(nom string, wg *sync.WaitGroup) {
    defer wg.Done() // appelé quand la fonction retourne, quoi qu'il arrive
    fmt.Println("Bonjour", nom)
}

func main() {
    var wg sync.WaitGroup

    wg.Add(2)
    go direBonjour("Alice", &wg)
    go direBonjour("Bob", &wg)

    wg.Wait() // bloque ici jusqu'à ce que les deux goroutines aient appelé Done()
    fmt.Println("Tout le monde a dit bonjour")
}

Le defer wg.Done() est important : en le plaçant en defer, on s'assure qu'il sera appelé même si la fonction plante sur une erreur ou panique à mi-chemin. Sans ça, une goroutine qui meurt prématurément bloquerait wg.Wait() indéfiniment.

Le pattern complet avec une boucle, par exemple sur une liste d'URLs :

urls := []string{
    "https://api.exemple.com/utilisateurs/1",
    "https://api.exemple.com/utilisateurs/2",
    "https://api.exemple.com/utilisateurs/3",
}

var wg sync.WaitGroup

for _, url := range urls {
    wg.Add(1)
    go func(u string) {
        defer wg.Done()
        telecharger(u)
    }(url)
}

wg.Wait()
fmt.Println("Tous les téléchargements sont terminés")

Note qu'on fait wg.Add(1) avant de lancer la goroutine, pas dedans. Si on le faisait à l'intérieur de la goroutine, wg.Wait() pourrait se déclencher avant que la goroutine ait eu le temps de s'inscrire.

Le piège classique des boucles

C'est probablement le bug le plus courant chez les débutants en Go concurrent, et il est vicieux parce qu'il fonctionne "parfois" selon les conditions d'exécution.

// BUG : toutes les goroutines partagent la même variable i
for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i) // i est capturé par référence
    }()
}
// Affiche souvent : 5 5 5 5 5

Pourquoi ? La goroutine ne capture pas la valeur de i au moment où elle est créée. Elle capture une référence à la variable i. Quand les goroutines finissent par s'exécuter (quelques microsecondes plus tard), la boucle a déjà terminé et i vaut 5. Toutes les goroutines lisent donc 5.

La correction est triviale : passer i en paramètre de la fonction anonyme.

// FIX : passer i en paramètre crée une copie locale
for i := 0; i < 5; i++ {
    go func(n int) {
        fmt.Println(n) // n est une copie propre à cette goroutine
    }(i) // i est évalué ici, au moment de l'appel
}
// Affiche : 0 1 2 3 4 (dans un ordre quelconque, mais toujours les bons chiffres)

En Go 1.22+, ce problème a été partiellement adressé : la variable de boucle for i := range ... crée maintenant une nouvelle variable à chaque itération au lieu de réutiliser la même. Mais le pattern avec les paramètres reste la forme la plus explicite et compatible avec toutes les versions.

Le race detector — ton meilleur ami

Une race condition (ou course critique), c'est quand deux goroutines accèdent à la même donnée en même temps et qu'au moins l'une d'elles la modifie. Le résultat est indéterministe et souvent catastrophique.

Exemple classique : deux goroutines qui incrémentent un compteur partagé.

package main

import (
    "fmt"
    "sync"
)

func main() {
    compteur := 0
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            compteur++ // DANGER : lecture + incrémentation + écriture, pas atomique
        }()
    }

    wg.Wait()
    fmt.Println("Compteur final:", compteur)
    // Résultat attendu : 1000
    // Résultat réel : quelque chose entre 800 et 1000, différent à chaque exécution
}

Sans outil adapté, ce genre de bug est quasi impossible à reproduire de façon fiable en tests. C'est là qu'intervient le race detector intégré à Go :

go run -race main.go

Le race detector instrumente le code à la compilation et détecte les accès concurrents non protégés à l'exécution. Sa sortie ressemble à ça :

==================
WARNING: DATA RACE
Write at 0x00c000126010 by goroutine 8:
  main.main.func1()
      /home/odilon/main.go:15 +0x44

Previous write at 0x00c000126010 by goroutine 7:
  main.main.func1()
      /home/odilon/main.go:15 +0x44
==================

Il te dit exactement quelle ligne pose problème et quelles goroutines sont en conflit. Le coût en performance est réel (environ 5 à 10x plus lent), mais c'est uniquement pour le développement et les tests — jamais en production.

Règle pratique : lance toujours tes tests avec -race. go test -race ./... devrait faire partie de ta CI.

Exemple concret — 10 URLs en parallèle

Voilà un exemple complet qui illustre le gain réel. On simule 10 appels HTTP qui prennent chacun environ 1 seconde (avec time.Sleep pour ne pas dépendre d'une vraie API).

package main

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

// Simule un appel HTTP qui prend ~1 seconde
func recupererDonnees(url string) string {
    time.Sleep(1 * time.Second)
    return "données de " + url
}

func main() {
    urls := []string{
        "https://api.meteo.com/paris",
        "https://api.meteo.com/lyon",
        "https://api.meteo.com/bordeaux",
        "https://api.meteo.com/marseille",
        "https://api.meteo.com/toulouse",
        "https://api.meteo.com/nice",
        "https://api.meteo.com/nantes",
        "https://api.meteo.com/strasbourg",
        "https://api.meteo.com/montpellier",
        "https://api.meteo.com/rennes",
    }

    // --- Version séquentielle ---
    debut := time.Now()
    for _, url := range urls {
        recupererDonnees(url)
    }
    fmt.Printf("Séquentiel : %v\n", time.Since(debut))
    // Séquentiel : ~10 secondes

    // --- Version parallèle ---
    debut = time.Now()
    var wg sync.WaitGroup

    for _, url := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            recupererDonnees(u)
        }(url)
    }

    wg.Wait()
    fmt.Printf("Parallèle : %v\n", time.Since(debut))
    // Parallèle : ~1 seconde
}

En pratique, la version parallèle prend la durée du plus lent des appels, plus un overhead minimal de gestion des goroutines. Pour des appels réseau, c'est souvent un facteur 5 à 20 de gain selon les latences et le nombre de requêtes.

Un détail important : dans cet exemple, on ignore les résultats des goroutines. En conditions réelles, il faudrait récupérer les données retournées et les erreurs éventuelles. C'est exactement ce que les channels permettent de faire — le sujet de la partie 2.

Ce qu'on a appris

  • go maFonction() lance une goroutine — le code qui suit s'exécute immédiatement sans attendre
  • main() qui retourne tue toutes les goroutines en cours, sans exception
  • sync.WaitGroup est le bon outil pour attendre la fin d'un ensemble de goroutines
  • defer wg.Done() garantit que le compteur est décrémenté même en cas d'erreur
  • Dans une boucle, toujours passer la variable de boucle en paramètre de la goroutine, jamais la capturer directement
  • go run -race détecte les race conditions — l'utiliser en dev et dans la CI

Dans la partie 2, on verra comment récupérer les résultats et les erreurs de ces goroutines avec les channels, et comment construire un worker pool qui limite le nombre de goroutines en parallèle — parce que lancer 10 000 goroutines d'un coup, c'est aussi une façon de faire tomber un serveur.

Commentaires (0)