Retour au cours

backend / go

Goroutines

Leçon 111 exercice

Explication

Ce que vous allez apprendre

  • Lancer une exécution concurrente avec le mot-clé go
  • Comprendre pourquoi main() ne bloque jamais automatiquement sur des goroutines
  • Utiliser sync.WaitGroup pour attendre correctement la fin de plusieurs goroutines
  • Identifier et éviter le piège classique de capture d'une variable de boucle
  • Situer une goroutine par rapport à un thread du système d'exploitation

Dans quel contexte ?

Un développeur doit envoyer une notification par email à 500 utilisateurs après une mise à jour de service. Envoyer ces emails un par un, séquentiellement, prendrait plusieurs minutes si chaque envoi réseau prend une fraction de seconde. En lançant chaque envoi dans sa propre goroutine, ce traitement se termine en une fraction du temps — c'est exactement le problème que cette leçon commence à résoudre.

D'abord, une goroutine n'est pas un thread du système d'exploitation

Un thread OS classique coûte plusieurs Mo de mémoire de pile et impose un changement de contexte relativement coûteux. Une goroutine, elle, démarre avec seulement quelques Ko de pile (qui grandit dynamiquement si besoin), et le runtime Go multiplexe des milliers de goroutines sur un nombre bien plus restreint de threads OS réels.

C'est cette légèreté qui rend banal de lancer des dizaines de milliers de goroutines simultanées dans un serveur Go, là où le même nombre de threads OS ferait s'écrouler la machine.

Une fois une goroutine lancée, un problème surgit immédiatement

go tache("A", 100*time.Millisecond) démarre l'exécution en arrière-plan et rend immédiatement la main au code appelant, sans attendre la fin de la tâche. Si main() se termine avant que la goroutine ait fini, cette dernière est brutalement interrompue — le programme entier s'arrête dès que main() retourne, peu importe ce qui tourne encore en arrière-plan.

Mauvaise solutionBonne solution
time.Sleep(200 * time.Millisecond)sync.WaitGroup avec Add/Done/Wait
Deviner une durée d'attente approximativeAttendre un signal explicite de fin
Fonctionne "en général" en testGaranti correct quelle que soit la charge

Prérequis

Cette leçon suppose une bonne aisance avec les fonctions et les closures (leçon sur les fonctions) : les exemples utilisent des fonctions anonymes qui capturent des variables externes.

Il reste LE piège le plus commun sur les goroutines : la capture de variable de boucle

Lancer une goroutine à l'intérieur d'un for i := 0; i < 5; i++ { go func() { ... utilise i ... }() } sans précaution fait que toutes les goroutines risquent de lire la même valeur finale de i, car la variable de boucle est partagée entre toutes les itérations tant qu'elle n'est pas explicitement capturée par valeur.

Piège fréquent

La solution consiste à passer i en argument à la fonction anonyme : go func(index int) { ... }(i). Cela fige la valeur de i au moment de l'appel, une pour chaque goroutine. Depuis Go 1.22, la sémantique des boucles a changé et ce piège spécifique à for a disparu par défaut — mais il reste fréquent dans du code plus ancien et dans d'autres contextes de capture.

Enfin, comment savoir proprement que tout est terminé

sync.WaitGroup fonctionne comme un compteur : wg.Add(1) avant chaque lancement, wg.Done() (souvent via defer) à la fin de chaque goroutine, et wg.Wait() qui bloque jusqu'à ce que le compteur revienne à zéro.

Bonne pratique

N'utilise jamais time.Sleep pour "attendre" que des goroutines se terminent en dehors d'un test rapide et jetable : c'est fragile et lent en production. sync.WaitGroup est le mécanisme correct et idiomatique.

Maintenant que tu sais lancer du travail concurrent, il te manque un moyen pour que ces goroutines communiquent entre elles proprement, sans variables partagées dangereuses : c'est exactement le rôle des channels, sujet de la prochaine leçon.

Commandes & code

Goroutines

Une goroutine est un thread léger géré par le runtime Go (quelques Ko de stack, multiplexé sur des threads OS).

go
package main

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

func tache(nom string, duree time.Duration) {
	time.Sleep(duree)
	fmt.Println("tache terminee:", nom)
}

func main() {
	// go lance une fonction en goroutine : execution asynchrone
	go tache("A", 100*time.Millisecond)
	go tache("B", 50*time.Millisecond)

	// PROBLEME : main() peut se terminer avant les goroutines !
	time.Sleep(200 * time.Millisecond) // solution naive, a eviter en vrai code

	// Solution correcte : sync.WaitGroup pour attendre la fin des goroutines
	var wg sync.WaitGroup
	resultats := make([]int, 5)

	for i := 0; i < 5; i++ {
		wg.Add(1) // signale une goroutine de plus a attendre

		// PIEGE CLASSIQUE : capturer i par valeur, pas par reference
		go func(index int) {
			defer wg.Done() // signale la fin de cette goroutine
			resultats[index] = index * index
		}(i) // passer i en argument fige sa valeur pour cette goroutine
	}

	wg.Wait() // bloque jusqu'a ce que toutes les goroutines aient appele Done()
	fmt.Println(resultats)

	// Goroutine avec closure anonyme immediate
	done := make(chan bool)
	go func() {
		fmt.Println("travail en arriere-plan")
		done <- true
	}()
	<-done // attend le signal

	// Combien de goroutines actives (utile pour debug/monitoring)
	fmt.Println("nombre de goroutines lancees dans cet exemple: variable")
}

Résumé

  • go f() démarre une goroutine : légère, gérée par le scheduler Go (pas 1:1 avec un thread OS).
  • main() ne bloque jamais automatiquement sur les goroutines : il faut synchroniser explicitement.
  • sync.WaitGroup (Add/Done/Wait) est le pattern standard pour attendre N goroutines.
  • Toujours passer les variables de boucle en argument à la closure pour éviter la capture partagée.

Exercices pratiques

1 disponible
1

Mission : un envoi de 500 emails qui s'arrête après trois notifications

Objectif : Corriger un programme qui termine avant que ses goroutines aient fini, et raisonner sur la fiabilité d'une synchronisation par délai fixe.

Contexte

Après une mise à jour de service, le programme d'envoi de notifications doit contacter 500 utilisateurs en parallèle via des goroutines. En production, les logs montrent qu'à peine une poignée de notifications partent avant que le programme ne s'arrête complètement, sans aucune erreur affichée.

Résoudre l’exercice →