Développement Web

La concurrence en Go pour débuter : goroutines et channels

12 دقائق للقراءة
📍 Article principal du parcours : Go (Golang) : le guide complet pour débuter
Ce tutoriel fait partie du parcours « Go de zéro ». Pour la vue d’ensemble, lisez d’abord le guide principal.

La concurrence en Go pour débuter : goroutines et channels

Imaginez que GareBook doive vérifier la disponibilité de quinze trajets auprès d’autant de transporteurs, et que chaque vérification prenne une demi-seconde. Les faire l’une après l’autre, c’est presque huit secondes d’attente — une éternité pour un utilisateur. Les lancer toutes en même temps, et c’est réglé en une demi-seconde. Cette capacité à faire plusieurs choses à la fois est la raison principale pour laquelle Go existe, et c’est étonnamment simple à écrire grâce aux goroutines et aux channels.

La concurrence fait peur à beaucoup de débutants, à cause des bugs subtils qu’elle peut introduire. Ce tutoriel pose les bases solidement : ce qu’est une goroutine, comment des goroutines s’échangent des données sans se marcher dessus, et les deux outils — WaitGroup et Mutex — qui évitent les pièges classiques. On reste sur les fondations ; pour les schémas avancés, un tutoriel dédié prend le relais en fin de page.

🎯 Ce que vous allez apprendre

  • Lancer une fonction en parallèle avec le mot-clé go (une goroutine) ;
  • Attendre la fin de plusieurs goroutines avec un sync.WaitGroup ;
  • Faire circuler des résultats en sécurité par un channel ;
  • Protéger une donnée partagée avec un sync.Mutex et reconnaître une « course de données ».

🛠️ Ce que vous allez construire

Une fonction qui interroge la disponibilité de plusieurs trajets GareBook en parallèle et rassemble les résultats, puis un compteur de réservations partagé que plusieurs goroutines incrémentent sans le corrompre. Vous verrez concrètement la différence entre un code qui « marche par chance » et un code correct.

Prérequis

  • Avoir suivi le tutoriel sur les structs, slices et maps ;
  • Être à l’aise avec les fonctions et le type Trajet de GareBook ;
  • Niveau : débutant motivé. Test express : si vous savez écrire et appeler une fonction qui prend un Trajet, vous êtes prêt.
  • ⏱️ Temps estimé : ~40 minutes.

Étape 1 — Lancer une goroutine

Une goroutine, c’est une fonction qui s’exécute en parallèle du reste du programme. Pour en lancer une, il suffit de mettre le mot-clé go devant l’appel. C’est tout. Go gère lui-même la répartition sur les cœurs du processeur ; vous, vous décrivez juste ce qui peut tourner en parallèle. Mais attention au premier piège, qu’on va volontairement provoquer :

package main

import "fmt"

func verifier(trajet string) {
	fmt.Printf("Disponibilité vérifiée pour %s\n", trajet)
}

func main() {
	go verifier("Dakar → Saint-Louis")
	go verifier("Dakar → Thiès")
	fmt.Println("Vérifications lancées")
}

Lancez ce code : il est très probable qu’aucune ligne « Disponibilité vérifiée » ne s’affiche. Pourquoi ? Parce que main ne les attend pas : dès qu’elle atteint sa dernière ligne, le programme se termine, tuant les goroutines avant qu’elles aient parlé. C’est le piège fondamental de la concurrence : il faut explicitement attendre la fin des goroutines. Voyons comment.

Point d’étape — Vous avez constaté de vos yeux que des goroutines non attendues sont perdues à la fin de main. Ce n’est pas un bug à craindre, c’est un comportement à maîtriser. La suite donne l’outil.

Étape 2 — Attendre avec un WaitGroup

Le sync.WaitGroup est un compteur d’attente : on l’incrémente avant de lancer chaque goroutine, chaque goroutine le décrémente en finissant, et main attend que le compteur revienne à zéro. C’est l’outil standard pour « attends que tout le monde ait terminé ». Reprenons la vérification, correctement cette fois :

package main

import (
	"fmt"
	"sync"
)

func main() {
	trajets := []string{
		"Dakar → Saint-Louis",
		"Dakar → Thiès",
		"Thiès → Touba",
		"Dakar → Mbour",
	}

	var wg sync.WaitGroup

	for _, t := range trajets {
		wg.Add(1) // une goroutine de plus à attendre
		go func(trajet string) {
			defer wg.Done() // signaler la fin, quoi qu'il arrive
			fmt.Printf("Disponibilité vérifiée pour %s\n", trajet)
		}(t)
	}

	wg.Wait() // bloque jusqu'à ce que toutes aient appelé Done
	fmt.Println("Toutes les vérifications sont terminées")
}

Trois détails essentiels. wg.Add(1) avant chaque lancement annonce une goroutine à attendre. defer wg.Done() garantit que le compteur sera décrémenté même si la fonction se termine par un chemin inattendu — defer exécute son contenu à la sortie de la fonction. Enfin, on passe t en argument de la fonction (}(t)) plutôt que de le capturer de l’extérieur ; depuis Go 1.22 la variable de boucle est propre à chaque tour, mais passer l’argument reste l’habitude la plus claire et la plus sûre. wg.Wait() bloque main jusqu’à la fin de toutes les goroutines.

Étape 3 — Récupérer des résultats par un channel

Attendre, c’est bien ; récupérer ce que chaque goroutine a calculé, c’est mieux. Le channel est un tuyau typé par lequel les goroutines s’envoient des valeurs en toute sécurité. On envoie avec canal <- valeur et on reçoit avec valeur := <-canal. Faisons remonter le nombre de places disponibles de chaque trajet :

type Dispo struct {
	Trajet string
	Places int
}

func main() {
	trajets := map[string]int{
		"Dakar → Saint-Louis": 7,
		"Dakar → Thiès":       0,
		"Thiès → Touba":       12,
	}

	resultats := make(chan Dispo)

	for nom, places := range trajets {
		go func(n string, p int) {
			// (ici on simulerait un appel réseau au transporteur)
			resultats <- Dispo{Trajet: n, Places: p}
		}(nom, places)
	}

	// On attend exactement autant de résultats qu'on a lancé de goroutines
	for i := 0; i < len(trajets); i++ {
		d := <-resultats
		fmt.Printf("%s : %d places\n", d.Trajet, d.Places)
	}
}

Le channel resultats transporte des Dispo. Chaque goroutine y dépose son résultat ; la boucle de réception en retire exactement autant qu’il y a de trajets. Point crucial : la réception <-resultats bloque jusqu’à ce qu’une valeur arrive, ce qui synchronise naturellement sans WaitGroup. Le channel fait d’une pierre deux coups : il transmet la donnée et coordonne le timing. C’est l’incarnation de la devise de Go : « partagez la mémoire en communiquant ».

Et si un transporteur ne répond jamais ? Attendre indéfiniment bloquerait toute l’API. Go règle ça avec select, qui attend sur plusieurs channels à la fois et réagit au premier prêt. Couplé à un timeout via time.After, il permet d’abandonner une attente trop longue : on écoute le channel de résultat et un channel de minuterie, et si la minuterie se déclenche en premier, on renonce proprement. C’est exactement ce dont a besoin une application qui interroge des services réseau peu fiables — ne jamais laisser un appel lent geler tout le reste. Le tutoriel avancé en fin de page détaille ce schéma select + timeout ; gardez en tête, dès maintenant, qu’une attente concurrente bien faite prévoit toujours une porte de sortie.

Point d’étape — Le programme affiche les places de chaque trajet, dans un ordre potentiellement différent à chaque exécution (c’est normal, les goroutines finissent dans le désordre). Si le programme se fige (« deadlock »), c’est que vous recevez plus de valeurs que vous n’en envoyez : vérifiez que le nombre de réceptions égale le nombre de goroutines.

Étape 4 — Protéger une donnée partagée avec un Mutex

Parfois, plusieurs goroutines doivent modifier la même variable — un compteur de réservations, par exemple. Et là surgit le bug le plus vicieux de la concurrence : la course de données. Si deux goroutines lisent et écrivent le compteur en même temps, des incréments se perdent. Provoquons le problème, puis corrigeons-le. Le sync.Mutex (verrou d’exclusion mutuelle) garantit qu’une seule goroutine touche la donnée à la fois :

package main

import (
	"fmt"
	"sync"
)

type Compteur struct {
	mu          sync.Mutex
	reservations int
}

func (c *Compteur) Incrementer() {
	c.mu.Lock()         // une seule goroutine passe
	c.reservations++
	c.mu.Unlock()       // on libère pour la suivante
}

func main() {
	c := &Compteur{}
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			c.Incrementer()
		}()
	}

	wg.Wait()
	fmt.Println("Réservations enregistrées :", c.reservations)
}

Avec le Mutex, le résultat est toujours 1000. Retirez les lignes Lock/Unlock et lancez avec le détecteur intégré, go run -race . : Go vous signalera une « DATA RACE » et le total sera souvent inférieur à 1000, de façon imprévisible. Ce détecteur de courses est un cadeau du langage — prenez l’habitude de lancer vos programmes concurrents avec -race pendant le développement. Le verrou encadre la « section critique » (ici, l’incrément) ; tout le reste continue en parallèle.

Étape 5 — Vérification finale

Vous avez maintenant les trois outils de base. Combinez-les : lancez la vérification de disponibilité de plusieurs trajets en goroutines, faites remonter les résultats par un channel, et tenez un compteur global protégé par un Mutex. Lancez le tout avec go run -race . : si le détecteur ne signale rien et que les totaux sont stables d’une exécution à l’autre, votre code concurrent est correct. C’est le critère qui compte — pas « ça a marché une fois », mais « c’est correct à chaque fois ».

🐞 Pièges fréquents

Symptôme / erreur Cause probable Correctif
Les goroutines ne s’exécutent pas main se termine avant elles Attendre avec WaitGroup ou recevoir leurs résultats sur un channel
fatal error: all goroutines are asleep - deadlock! Vous recevez plus de valeurs qu’il n’en est envoyé sur le channel Équilibrez le nombre d’envois et de réceptions, ou fermez le channel avec close
Le résultat varie d’une exécution à l’autre Course de données sur une variable partagée Protégez l’accès avec un Mutex, et déboguez avec go run -race .
Un Mutex reste verrouillé, le programme se fige Unlock oublié sur un chemin de sortie Utilisez defer c.mu.Unlock() juste après le Lock
Panique en écrivant une map depuis plusieurs goroutines Les maps ne sont pas sûres en accès concurrent Protégez la map avec un Mutex, ou passez par un channel

🌍 Adaptation au contexte ouest-africain

La concurrence brille particulièrement quand on attend des ressources lentes — et une connexion réseau capricieuse en est l’exemple parfait. Si GareBook interroge plusieurs API de transporteurs, chaque appel peut traîner à cause de la latence. Les lancer en parallèle masque cette lenteur : l’utilisateur attend le plus lent, pas la somme de tous. Sur un réseau mobile à la qualité variable, ce gain est très concret.

Cela dit, ne lancez pas dix mille appels réseau d’un coup : vous saturerez la connexion. La bonne pratique est de limiter le nombre de goroutines simultanées (un « pool »), un schéma que le tutoriel avancé ci-dessous détaille. Pour des calculs purs (sans réseau), Go exploite naturellement les cœurs disponibles de votre machine, même modeste.

✅ Récapitulatif

Vous avez désarmé la concurrence. Vous savez lancer une fonction en parallèle avec go, attendre un groupe de goroutines avec un WaitGroup, faire remonter et coordonner des résultats par un channel, et protéger une donnée partagée avec un Mutex. Surtout, vous connaissez le réflexe qui sauve : déboguer avec go run -race . pour traquer les courses de données. GareBook vérifie maintenant ses trajets en parallèle, prêt à servir des utilisateurs réels. La prochaine étape l’ouvre sur le réseau : une vraie API REST.

🧾 Aide-mémoire

Élément Rôle
go f() Lance f dans une goroutine
var wg sync.WaitGroup Compteur d’attente de goroutines
wg.Add(1) / wg.Done() / wg.Wait() Ajouter / signaler la fin / attendre
ch := make(chan T) Crée un channel de valeurs de type T
ch <- v / v := <-ch Envoyer / recevoir (bloquant)
var mu sync.Mutex Verrou d’exclusion mutuelle
mu.Lock() / defer mu.Unlock() Entrer / sortir d’une section critique
go run -race . Détecte les courses de données

💪 À vous de jouer

Écrivez une fonction qui reçoit un slice de Trajet et calcule, en parallèle (une goroutine par trajet), le total des places disponibles, en utilisant un channel pour faire remonter chaque nombre de places puis en les additionnant côté main.

Voir une solution
func placesParallele(trajets []Trajet) int {
	ch := make(chan int)
	for _, t := range trajets {
		go func(places int) {
			ch <- places
		}(t.Places)
	}
	total := 0
	for range trajets {
		total += <-ch
	}
	return total
}

On lance une goroutine par trajet qui envoie son nombre de places, puis on reçoit exactement len(trajets) valeurs en les additionnant. La boucle for range trajets sans variable compte simplement le bon nombre de réceptions.

Tutoriels frères

Pour aller plus loin

FAQ

Goroutine et thread, est-ce la même chose ?
Non. Une goroutine est beaucoup plus légère qu’un thread système : elle démarre avec quelques kilo-octets de pile et c’est Go (pas le système d’exploitation) qui les répartit sur les threads réels. On peut en lancer des dizaines de milliers ; on ne ferait jamais ça avec des threads classiques.

Quand utiliser un channel plutôt qu’un Mutex ?
Channel pour transmettre une donnée d’une goroutine à une autre et se coordonner ; Mutex pour protéger une donnée partagée que plusieurs goroutines modifient sur place. La culture Go penche pour les channels, mais un Mutex sur un compteur reste plus simple et tout à fait correct.

Faut-il toujours fermer un channel ?
Pas toujours. On ferme un channel (close(ch)) surtout quand un récepteur le parcourt avec for v := range ch et a besoin de savoir qu’il n’y aura plus de valeurs. Pour un nombre fixe de réceptions comme dans nos exemples, ce n’est pas nécessaire.

Le -race ralentit-il mon programme ?
Oui, sensiblement — c’est un outil de développement, pas de production. Lancez-le pendant vos tests, puis compilez normalement pour déployer.

Mots-clés : concurrence Go, goroutines, channels, sync.WaitGroup, sync.Mutex, course de données, go race, débuter Golang.

مشاركة