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 :
- Partie 1 : les goroutines et
sync.WaitGroup - Partie 2 : les channels et le pattern worker pool
- Partie 3 : les erreurs en contexte concurrent et la gestion propre des panics
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'attendsngoroutines 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 attendremain()qui retourne tue toutes les goroutines en cours, sans exceptionsync.WaitGroupest le bon outil pour attendre la fin d'un ensemble de goroutinesdefer 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 -racedé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.