Concurrence vs parallélisme
Imaginez un cuisinier seul dans une cuisine. Il peut préparer la salade pendant que l'eau bout et que le poulet est au four. Il ne fait qu'une chose à la fois, mais il jongle entre plusieurs tâches. C'est la concurrence.
Maintenant imaginez trois cuisiniers qui préparent chacun un plat en même temps. C'est le parallélisme.
Go excelle dans les deux. Et il rend la concurrence ridiculement simple avec un seul mot-clé : go.
Les goroutines : la concurrence en un mot
Une goroutine est une fonction qui s'exécute en arrière-plan. Pour la lancer, ajoutez go devant l'appel :
func afficher(msg string) {
for i := 0; i < 3; i++ {
fmt.Println(msg)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go afficher("goroutine") // S'exécute en arrière-plan
afficher("main") // S'exécute au premier plan
}
Les deux fonctions s'exécutent en même temps. La sortie peut varier à chaque exécution :
Sortie possible :
main
goroutine
goroutine
main
main
goroutine
Une goroutine coûte environ 2 Ko de mémoire. Un thread système en coûte 1 Mo. Vous pouvez créer des millions de goroutines sans problème : c'est comme ça que Go gère des milliers de connexions simultanées.
Le modèle mental. Une goroutine n'est pas un thread système. Le runtime de Go embarque son propre ordonnanceur (scheduler) qui répartit des milliers de goroutines sur une poignée de threads de l'OS. Quand une goroutine bloque (attente d'un channel, I/O réseau), le scheduler glisse une autre goroutine sur le thread libéré. C'est pour ça qu'une goroutine est si légère : la créer, la démarrer et basculer de l'une à l'autre ne passe jamais par le système d'exploitation.
Les channels : communiquer entre goroutines
Les goroutines travaillent en parallèle, mais comment échangent-elles des données ? Via des channels : des tuyaux typés :
Sur la seule goroutine main, on crée un channel non bufferisé (ch := make(chan int)), on y envoie une valeur (ch <- 5), puis on la lit (fmt.Println(<-ch)). Avant de dérouler : est-ce que ça affiche 5, ou est-ce que ça plante ? Pourquoi ?
Voir la réponse
Ça plante, avec fatal error: all goroutines are asleep - deadlock!. Un channel non bufferisé impose un rendez-vous : l'envoi ch <- 5 bloque tant qu'une autre goroutine n'est pas prête à recevoir. Or ici tout se passe sur la seule goroutine main : elle se bloque sur l'envoi et n'atteint jamais la ligne de lecture, donc personne ne peut débloquer la situation. Go détecte que toutes les goroutines sont bloquées et arrête le programme. Pour que ça marche, l'envoi et la réception doivent être sur des goroutines différentes (ex. go func(){ ch <- 5 }() puis fmt.Println(<-ch) dans main), ou utiliser un channel bufferisé (make(chan int, 1)) qui accepte une valeur sans receveur immédiat.
func calculer(ch chan int) {
resultat := 42 * 2
ch <- resultat // Envoyer dans le channel
}
func main() {
ch := make(chan int) // Créer un channel d'entiers
go calculer(ch) // Lancer la goroutine
resultat := <-ch // Recevoir du channel (bloquant)
fmt.Println(resultat) // 84
}
L'opérateur <- fonctionne dans les deux sens :
ch <- valeur: envoyer une valeur dans le channelvaleur := <-ch: recevoir une valeur du channel
La réception est bloquante : le programme attend jusqu'à ce qu'une valeur arrive. Pas besoin de mutex ou de verrous compliqués.
Par défaut, un channel est non bufferisé : l'envoi attend qu'un récepteur soit prêt (remise en main propre). Avec make(chan int, 3), on crée un channel bufferisé : l'envoi ne bloque pas tant que le buffer n'est pas plein. Pratique pour découpler producteur et consommateur ; on s'en resservira plus loin.
Le proverbe de Go : "Ne communiquez pas en partageant de la mémoire. Partagez de la mémoire en communiquant."
main lance les goroutines avec go func() ; elles communiquent via le channel.select : écouter plusieurs channels
select permet d'attendre sur plusieurs channels en même temps, comme un switch pour les channels :
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(100 * time.Millisecond)
ch1 <- "résultat 1"
}()
go func() {
time.Sleep(200 * time.Millisecond)
ch2 <- "résultat 2"
}()
select {
case msg := <-ch1:
fmt.Println("Reçu de ch1:", msg)
case msg := <-ch2:
fmt.Println("Reçu de ch2:", msg)
}
}
select exécute le premier channel qui est prêt. C'est la base des serveurs web performants et des systèmes temps réel.
Deux précisions importantes. Si plusieurs cas sont prêts en même temps, select en choisit un au hasard (il n'y a pas de priorité du haut vers le bas). Et un cas default rend le select non bloquant : il s'exécute immédiatement si aucun channel n'est prêt, au lieu d'attendre.
L'erreur n°1 du débutant : le deadlock. Si toutes les goroutines sont bloquées à attendre un channel que personne n'alimente, Go le détecte et stoppe le programme avec fatal error: all goroutines are asleep - deadlock!. Causes classiques : avoir oublié de lancer la goroutine qui écrit, ou lire en range un channel jamais fermé. Le réflexe : vérifier qui envoie et qui ferme.
sync.WaitGroup : attendre toutes les goroutines
Comment savoir quand toutes vos goroutines ont terminé ? Avec sync.WaitGroup :
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // +1 goroutine à attendre
go func(n int) {
defer wg.Done() // -1 quand terminé
fmt.Printf("Tâche %d terminée\n", n)
}(i)
}
wg.Wait() // Attendre que toutes soient terminées
fmt.Println("Tout est fini !")
}
Trois méthodes : Add(n) ajoute n tâches, Done() signale qu'une tâche est terminée, Wait() bloque jusqu'à ce que le compteur atteigne zéro.
Le piège n°1 : les data races
Dès que deux goroutines touchent la même variable en même temps et qu'au moins l'une écrit, vous avez une data race. Le résultat devient imprévisible. Exemple typique : mille goroutines qui incrémentent un compteur partagé (on utilise le WaitGroup qu'on vient de voir pour les attendre).
func main() {
compteur := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
compteur++ // data race : plusieurs goroutines écrivent
}()
}
wg.Wait()
fmt.Println(compteur) // Presque jamais 1000 !
}
On attend 1000. En réalité on obtient 973, 988, un nombre différent à chaque exécution. Pourquoi ? Parce que compteur++ n'est pas une seule opération : c'est lire la valeur, ajouter 1, puis réécrire. Deux goroutines lisent 41 en même temps, ajoutent 1, et réécrivent 42 toutes les deux : une incrémentation est perdue.
Le modèle « ligne par ligne » ne tient plus en concurrence. Votre intuition de code séquentiel (les instructions s'exécutent dans l'ordre, une écriture est instantanée et visible partout) est fausse dès qu'il y a plusieurs goroutines. Sans synchronisation, Go ne garantit ni l'ordre, ni qu'une écriture soit vue par une autre goroutine. C'est l'erreur la plus fréquente quand on débute la concurrence.
Go fournit un détecteur de course intégré. Lancez votre programme avec le flag -race :
go run -race main.go
Il instrumente le code et signale précisément quelles goroutines accèdent à quelle variable sans synchronisation, numéros de ligne à l'appui. Réflexe à prendre : dès qu'un comportement vous semble non déterministe, relancez avec -race. Il a un coût (temps et mémoire multipliés par ~5 à 10), donc on l'active en développement et dans les tests, jamais en production. Les règles exactes de ce qui est garanti (la relation happens-before) sont définies dans le Go Memory Model officiel.
Mutex ou channel ? relier la donnée à sa primitive
La data race précédente se corrige de deux façons, selon votre intention : protéger un état partagé, ou transférer une donnée d'une goroutine à une autre. Le vrai piège n'est pas la syntaxe, c'est de savoir quelle primitive pour quelle donnée.
Pour protéger un état, on pose un verrou : sync.Mutex. Une seule goroutine à la fois tient le verrou, les autres attendent leur tour :
func main() {
compteur := 0
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
compteur++ // un seul accès à la fois
mu.Unlock()
}()
}
wg.Wait()
fmt.Println(compteur) // 1000, à chaque exécution
}
L'autre façon, côté channel : confier le compteur à une seule goroutine et lui envoyer les +1 par un channel. La donnée n'est jamais partagée, donc aucun verrou n'est nécessaire :
func main() {
incr := make(chan int)
done := make(chan int)
go func() { // seule cette goroutine touche le compteur
compteur := 0
for range incr {
compteur++
}
done <- compteur
}()
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() { defer wg.Done(); incr <- 1 }()
}
wg.Wait()
close(incr) // plus d'envois : la boucle range se termine
fmt.Println(<-done) // 1000
}
Choisir la primitive selon l'intention. Un sync.Mutex quand plusieurs goroutines lisent et écrivent un même état en place (un compteur, une map, un cache). Un channel quand vous transférez la propriété d'une donnée d'une goroutine à une autre, ou orchestrez des étapes. Le proverbe « partagez la mémoire en communiquant » pousse vers les channels, mais un simple compteur protégé par un mutex est souvent plus clair et plus rapide : ne forcez pas un channel là où un mutex suffit (c'est la règle du Go Wiki).
À vous d'essayer
Reprenez l'exemple goroutine + channel et exécutez-le directement :
package main
import "fmt"
func calculer(ch chan int) {
resultat := 42 * 2
ch <- resultat
}
func main() {
ch := make(chan int)
go calculer(ch)
resultat := <-ch
fmt.Println("Résultat reçu du channel :", resultat)
}
Concurrency vs parallelism
Imagine a single cook in a kitchen. They can prepare the salad while the water boils and the chicken is in the oven. They only do one thing at a time, but they juggle multiple tasks. That's concurrency.
Now imagine three cooks each preparing a dish at the same time. That's parallelism.
Go excels at both. And it makes concurrency ridiculously simple with a single keyword: go.
Goroutines: concurrency in one word
A goroutine is a function that runs in the background. To launch it, add go before the call:
func display(msg string) {
for i := 0; i < 3; i++ {
fmt.Println(msg)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go display("goroutine") // Runs in the background
display("main") // Runs in the foreground
}
Both functions execute at the same time. The output may vary each time:
Possible output:
main
goroutine
goroutine
main
main
goroutine
A goroutine costs about 2 KB of memory. A system thread costs 1 MB. You can create millions of goroutines without issues — that's how Go handles thousands of simultaneous connections.
The mental model. A goroutine is not a system thread. Go's runtime ships its own scheduler that spreads thousands of goroutines across a handful of OS threads. When a goroutine blocks (waiting on a channel, network I/O), the scheduler slides another goroutine onto the freed thread. That's why a goroutine is so cheap: creating, starting and switching between them never goes through the operating system.
Channels: communicating between goroutines
Goroutines work in parallel, but how do they exchange data? Through channels — typed pipes:
On the sole main goroutine, we create an unbuffered channel (ch := make(chan int)), send a value into it (ch <- 5), then read it back (fmt.Println(<-ch)). Before scrolling down: does it print 5, or does it crash? Why?
See the answer
It crashes, with fatal error: all goroutines are asleep - deadlock!. An unbuffered channel enforces a rendezvous: the send ch <- 5 blocks until another goroutine is ready to receive. But here everything happens on the single main goroutine: it blocks on the send and never reaches the receive line, so nobody can unblock the situation. Go detects that all goroutines are stuck and halts the program. To make it work, the send and the receive must be on different goroutines (e.g. go func(){ ch <- 5 }() then fmt.Println(<-ch) in main), or use a buffered channel (make(chan int, 1)) that accepts a value without an immediate receiver.
func compute(ch chan int) {
result := 42 * 2
ch <- result // Send to the channel
}
func main() {
ch := make(chan int) // Create a channel of integers
go compute(ch) // Launch the goroutine
result := <-ch // Receive from channel (blocking)
fmt.Println(result) // 84
}
The <- operator works both ways:
ch <- value— send a value to the channelvalue := <-ch— receive a value from the channel
Receiving is blocking: the program waits until a value arrives. No need for mutexes or complicated locks.
By default, a channel is unbuffered: a send waits until a receiver is ready (a direct hand-off). With make(chan int, 3) you create a buffered channel: a send doesn't block until the buffer is full. Handy to decouple producer and consumer — we'll use it again later.
Go's proverb: "Don't communicate by sharing memory. Share memory by communicating."
main launches the goroutines with go func(); they communicate through the channel.select: listening to multiple channels
select lets you wait on multiple channels at once, like a switch for channels:
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(100 * time.Millisecond)
ch1 <- "result 1"
}()
go func() {
time.Sleep(200 * time.Millisecond)
ch2 <- "result 2"
}()
select {
case msg := <-ch1:
fmt.Println("Received from ch1:", msg)
case msg := <-ch2:
fmt.Println("Received from ch2:", msg)
}
}
select executes the first channel that's ready. It's the foundation of performant web servers and real-time systems.
Two important details. If several cases are ready at once, select picks one at random (there's no top-to-bottom priority). And a default case makes the select non-blocking: it runs immediately when no channel is ready, instead of waiting.
The beginner's #1 error: the deadlock. If every goroutine is blocked waiting on a channel that nobody feeds, Go detects it and stops the program with fatal error: all goroutines are asleep - deadlock!. Classic causes: forgetting to launch the goroutine that writes, or range-ing over a channel that's never closed. The reflex: check who sends and who closes.
sync.WaitGroup: waiting for all goroutines
How do you know when all your goroutines are done? With sync.WaitGroup:
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // +1 goroutine to wait for
go func(n int) {
defer wg.Done() // -1 when done
fmt.Printf("Task %d completed\n", n)
}(i)
}
wg.Wait() // Wait until all are done
fmt.Println("All done!")
}
Three methods: Add(n) adds n tasks, Done() signals a task is complete, Wait() blocks until the counter reaches zero.
Pitfall #1: data races
As soon as two goroutines touch the same variable at the same time and at least one writes, you have a data race. The result becomes unpredictable. Classic example: a thousand goroutines incrementing a shared counter (we use the WaitGroup we just saw to wait for them).
func main() {
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // data race: several goroutines write
}()
}
wg.Wait()
fmt.Println(counter) // Almost never 1000!
}
You expect 1000. In reality you get 973, 988, a different number every run. Why? Because counter++ isn't a single operation: it's read the value, add 1, then write back. Two goroutines read 41 at the same time, add 1, and both write 42: one increment is lost.
The "line by line" model breaks under concurrency. Your sequential intuition (statements run in order, a write is instant and visible everywhere) is wrong the moment several goroutines are involved. Without synchronization, Go guarantees neither ordering nor that one write is seen by another goroutine. It's the single most common mistake when starting with concurrency.
Go ships a built-in race detector. Run your program with the -race flag:
go run -race main.go
It instruments the code and reports exactly which goroutines access which variable without synchronization, with line numbers. Make it a reflex: the moment behavior looks non-deterministic, re-run with -race. It has a cost (time and memory multiplied by ~5 to 10), so enable it in development and tests, never in production. The exact rules of what is guaranteed (the happens-before relation) are defined in the official Go Memory Model.
Mutex or channel? matching data to its primitive
The data race above can be fixed two ways, depending on your intent: protect shared state, or transfer data from one goroutine to another. The real pitfall isn't the syntax, it's knowing which primitive for which data.
To protect state, you take a lock: sync.Mutex. Only one goroutine holds the lock at a time, the others wait their turn:
func main() {
counter := 0
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++ // one access at a time
mu.Unlock()
}()
}
wg.Wait()
fmt.Println(counter) // 1000, every run
}
The other way, on the channel side: hand the counter to a single goroutine and send it the +1s over a channel. The data is never shared, so no lock is needed:
func main() {
incr := make(chan int)
done := make(chan int)
go func() { // only this goroutine touches the counter
counter := 0
for range incr {
counter++
}
done <- counter
}()
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() { defer wg.Done(); incr <- 1 }()
}
wg.Wait()
close(incr) // no more sends: the range loop ends
fmt.Println(<-done) // 1000
}
Pick the primitive by intent. A sync.Mutex when several goroutines read and write the same state in place (a counter, a map, a cache). A channel when you transfer ownership of data from one goroutine to another, or orchestrate stages. The proverb "share memory by communicating" leans toward channels, but a plain counter guarded by a mutex is often clearer and faster: don't force a channel where a mutex is enough (that's the Go Wiki rule).
Try it yourself
Take the goroutine + channel example again and run it directly:
package main
import "fmt"
func compute(ch chan int) {
result := 42 * 2
ch <- result
}
func main() {
ch := make(chan int)
go compute(ch)
result := <-ch
fmt.Println("Result received from channel:", result)
}
🎯 Pratique
S'entraîner (clique pour ouvrir) :
✨ Prompt IA
Copiez ce prompt dans Claude ou ChatGPT :
Écris un programme Go qui télécharge 5 URLs en parallèle avec des goroutines et channels. Affiche le temps de réponse de chaque URL. Utilise un WaitGroup.
💬 Ré-explique sans regarder
Si l'IA te rend ce programme qui télécharge 5 URLs en parallèle : à quoi servent le channel et le WaitGroup, et pourquoi les deux ensemble ? Explique avec tes mots.
channel transporte les résultats de chaque goroutine vers main (chaque télécharge envoie son temps de réponse avec ch <-) ; le WaitGroup sert à savoir quand toutes ont fini (Add avant de lancer, defer Done à la fin de chaque goroutine, Wait bloque main). Sans le WaitGroup, main se terminerait avant les goroutines ; sans le channel, les résultats n'auraient aucun moyen de revenir.⚖️ Juge le code de l'IA
L'IA te propose ce code pour lancer 3 tâches en parallèle. Ton rôle de relecteur : l'accepter tel quel ou le rejeter, et dire pourquoi.
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
go func() {
defer wg.Done()
fmt.Printf("Tâche %d\n", i)
}()
wg.Add(1)
}
wg.Wait()
}
i par référence : quand elle s'exécute, i vaut souvent déjà 3 (ou 4), donc les trois tâches affichent le même numéro au lieu de 1, 2, 3. Le correctif : passer i en argument, go func(n int){…}(i) (avant Go 1.22). 2) Le wg.Add(1) est placé après le go func() : la goroutine peut atteindre Done() avant le Add, ce qui provoque un panic (compteur négatif) ou un Wait() qui ne compte pas la tâche. Règle : toujours Add avant de lancer la goroutine.🧠 Rappel libre
Sans remonter dans la leçon : quel mot-clé lance une goroutine, et que fait resultat := <-ch quand le channel est encore vide ?
go devant un appel de fonction (go calculer(ch)) lance la goroutine en arrière-plan. resultat := <-ch est une réception bloquante : si le channel est vide, la goroutine appelante s'arrête et attend qu'une valeur y soit envoyée (ch <- …) avant de continuer. C'est ce blocage qui synchronise les goroutines sans verrou explicite.🔧 Débugue le code
Symptôme : ce code devrait afficher 1000, mais il affiche 973 (un nombre différent à chaque exécution). Répare-le dans l'éditeur, puis exécute.
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++ // FIXME: data race
}()
}
wg.Wait()
fmt.Println(compteur)
}
compteur++ fait lire-ajouter-réécrire, donc mille goroutines s'écrasent (data race). La correction : un sync.Mutex autour de l'accès — déclare var mu sync.Mutex, puis encadre par mu.Lock() et mu.Unlock(). Relance : 1000 à chaque fois.Vous savez lancer, communiquer et synchroniser des goroutines. On passe à l'échelle supérieure : composer des pipelines, paralléliser avec fan-out/fan-in, annuler proprement avec context, et éviter les fuites de goroutines.
Leçon 9 : Concurrence avancée →