Rob Pike a dit en 2012 : "Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once." Tout le monde hoche la tête en conférence. Personne ne pose de question. Et puis on rentre chez soi et on ne sait toujours pas concrètement ce que ça change dans le code.
Cet article part de zéro : c'est quoi la différence, comment Go l'exprime avec les goroutines et les channels, et pourquoi c'est utile dès qu'on commence à toucher à des architectures type Event Sourcing ou CQRS. Pas de prérequis Go avancé nécessaire.
La différence avec une analogie de cuisine
Imaginez un cuisinier seul qui prépare dix plats en même temps. Il lance le risotto, met les saint-jacques à dorer, file surveiller la sauce, revient au risotto. Il ne fait qu'une chose à la fois — mais il gère dix choses en jonglant avec son attention. C'est de la concurrence.
Maintenant mettez dix cuisiniers dans la même cuisine. Dix plats cuisent physiquement en même temps, chacun sur son poste. C'est du parallélisme.
La différence en une phrase :
- Concurrence = gérer plusieurs tâches en alternant entre elles (même sur un seul cœur)
- Parallélisme = exécuter plusieurs tâches vraiment en même temps (plusieurs cœurs)
Un programme concurrent peut très bien tourner sur un seul cœur — il donne juste l'impression de faire plusieurs choses à la fois parce qu'il alterne rapidement. Le parallélisme, lui, nécessite du vrai matériel multi-core.
Les goroutines : la concurrence à la sauce Go
En Go, la primitive de base pour la concurrence c'est la goroutine.
C'est comme un thread léger, mais géré par Go plutôt que par le système d'exploitation.
On en lance une avec le mot-clé go devant une fonction :
package main
import (
"fmt"
"time"
)
func direBonjour(nom string) {
fmt.Println("Bonjour", nom)
}
func main() {
go direBonjour("Alice") // lancé en arrière-plan
go direBonjour("Bob") // lancé en arrière-plan
go direBonjour("Charlie")
time.Sleep(100 * time.Millisecond) // attendre que les goroutines finissent
fmt.Println("Tout le monde a dit bonjour")
}
Les trois appels tournent en concurrence. L'ordre d'affichage n'est pas garanti — Go décide qui passe en premier. Sur une machine multi-core, ils peuvent même tourner vraiment en même temps (parallélisme).
Pourquoi les goroutines plutôt que des threads classiques ? Parce qu'une goroutine démarre avec ~2 KB de mémoire, contre 1 à 8 MB pour un thread OS. Vous pouvez lancer 100 000 goroutines sans problème. 100 000 threads, et c'est la machine qui pleure.
Les channels : comment les goroutines se parlent
Le problème avec la concurrence : comment partager des données entre tâches sans tout casser ? Go répond avec les channels — des tuyaux par lesquels les goroutines s'envoient des valeurs.
package main
import "fmt"
func calculer(a, b int, resultat chan<- int) {
resultat <- a + b // envoie le résultat dans le channel
}
func main() {
ch := make(chan int) // crée un channel qui transporte des int
go calculer(3, 4, ch) // lance le calcul en arrière-plan
go calculer(10, 20, ch)
r1 := <-ch // attend et reçoit le premier résultat
r2 := <-ch // attend et reçoit le second
fmt.Println(r1, r2) // 7 et 30, dans un ordre quelconque
}
La règle d'or des channels : c'est celui qui envoie (le producteur) qui ferme le channel, pas celui qui reçoit. Fermer un channel depuis le receiver est une erreur classique qui fait planter le programme.
Event Sourcing et CQRS — c'est quoi d'abord ?
Avant d'appliquer la concurrence, un rappel rapide sur ces deux patterns, parce que les noms font peur pour rien.
Event Sourcing : au lieu de stocker l'état actuel d'une donnée (solde = 150€), on stocke tous les événements qui ont mené à cet état (compte créé → dépôt 200€ → retrait 50€). L'état courant se recalcule en rejouant les événements dans l'ordre.
CQRS (Command Query Responsibility Segregation) : on sépare les opérations d'écriture (les Commands, qui modifient l'état) des opérations de lecture (les Queries, qui lisent l'état). Deux chemins différents, deux modèles différents.
Ces deux patterns fonctionnent très bien ensemble — et leur structure est naturellement asymétrique : l'écriture exige de l'ordre, la lecture peut se faire en parallèle. C'est exactement là où la distinction concurrence/parallélisme devient concrète.
Écriture : une file d'attente par compte
Prenons un exemple simple : un système bancaire en Event Sourcing. Un compte a un solde. On peut faire un dépôt ou un retrait. La règle métier : le solde ne peut pas passer sous zéro.
Problème : si deux retraits arrivent en même temps sur le même compte, les deux vérifient le solde en même temps (disons 100€), les deux voient que c'est OK, les deux débitent 80€ — et le solde finit à -60€. C'est une race condition.
Solution Go : un channel par compte. Toutes les commandes sur un même compte passent dans ce channel et sont traitées une par une. Pendant ce temps, d'autres comptes traitent leurs commandes en parallèle — eux ont leur propre channel et leur propre goroutine.
package main
import (
"fmt"
"sync"
)
// Compte gère son état et sa file de commandes
type Compte struct {
id string
solde float64
commandes chan Commande
}
type Commande struct {
montant float64
reponse chan error
}
// NewCompte crée un compte et démarre sa goroutine de traitement
func NewCompte(id string, soldeInitial float64) *Compte {
c := &Compte{
id: id,
solde: soldeInitial,
commandes: make(chan Commande, 10),
}
go c.traiter() // une seule goroutine traite les commandes de ce compte
return c
}
// traiter lit les commandes une par une — pas de race condition possible
func (c *Compte) traiter() {
for cmd := range c.commandes {
if c.solde+cmd.montant < 0 {
cmd.reponse <- fmt.Errorf("solde insuffisant (%.2f€)", c.solde)
continue
}
c.solde += cmd.montant
cmd.reponse <- nil
}
}
// Débiter envoie une commande et attend la réponse
func (c *Compte) Débiter(montant float64) error {
rep := make(chan error, 1)
c.commandes <- Commande{montant: -montant, reponse: rep}
return <-rep
}
func main() {
compte := NewCompte("C001", 100)
var wg sync.WaitGroup
// 5 retraits de 30€ arrivent en même temps
for i := range 5 {
wg.Add(1)
go func(n int) {
defer wg.Done()
err := compte.Débiter(30)
if err != nil {
fmt.Printf("Retrait %d refusé : %v\n", n, err)
} else {
fmt.Printf("Retrait %d OK\n", n)
}
}(i)
}
wg.Wait()
}
Ce qui se passe : les 5 goroutines envoient toutes leur commande dans le channel,
mais la goroutine traiter() les prend une par une.
Les premières réussissent (100€ → 70€ → 40€), les suivantes sont refusées.
Pas de race condition, pas de mutex compliqué.
C'est la beauté de l'approche : la concurrence entre les goroutines est totale (chaque compte a sa propre goroutine, 1000 comptes tournent en parallèle), mais la sérialisation à l'intérieur d'un compte est garantie par le channel.
Lecture : tout en parallèle
Côté CQRS, les Queries lisent les données. Elles ne modifient rien — pas de risque de corrompre l'état. On peut donc les lancer en parallèle sans restriction.
En Event Sourcing, les projections sont des vues calculées depuis les événements. Par exemple : "le solde de tous les comptes" est une projection. "La liste des 10 dernières transactions" en est une autre. Ces projections se construisent en relisant les événements — et chacune peut le faire dans sa propre goroutine, indépendamment des autres.
package main
import (
"fmt"
"log/slog"
"sync"
)
type Evenement struct {
CompteID string
Montant float64
Type string
}
type Projection interface {
Nom() string
Traiter(e Evenement) error
}
// PublierEvenement envoie un événement à toutes les projections en parallèle.
// Si une projection échoue, les autres continuent.
func PublierEvenement(evt Evenement, projections []Projection) {
var wg sync.WaitGroup
for _, p := range projections {
wg.Add(1)
go func(proj Projection) {
defer wg.Done()
if err := proj.Traiter(evt); err != nil {
slog.Error("projection échouée",
"projection", proj.Nom(),
"erreur", err,
)
// On continue — une projection qui plante ne bloque pas les autres
}
}(p)
}
wg.Wait() // on attend que toutes les projections aient traité l'événement
}
func main() {
evt := Evenement{CompteID: "C001", Montant: 50, Type: "depot"}
fmt.Printf("Événement publié : %+v\n", evt)
// PublierEvenement(evt, []Projection{projSoldes, projHistorique, projStats})
}
En pratique, si vous avez 3 projections (soldes, historique, stats) et que chacune prend 20ms à traiter un événement, la version séquentielle prend 60ms. La version parallèle prend 20ms. Sur un gros volume d'événements, ça compte.
Le piège classique : deux goroutines créées pour le même compte
Une subtilité à connaître : si vous stockez vos comptes dans une map
(map[string]*Compte), deux requêtes qui arrivent simultanément
pour un compte qui n'existe pas encore peuvent créer deux instances différentes
du même compte. Les deux goroutines travaillent sur des états différents —
et vous perdez des événements.
Fix : protéger l'accès à la map avec un mutex, juste pour la création. Une fois le compte créé, plus besoin de mutex — le channel fait le travail.
type Banque struct {
mu sync.Mutex
comptes map[string]*Compte
}
func (b *Banque) ObtenirOuCréer(id string) *Compte {
b.mu.Lock()
defer b.mu.Unlock()
if c, ok := b.comptes[id]; ok {
return c // déjà existant, on retourne le même
}
// Créer une seule fois, protégé par le mutex
c := NewCompte(id, 0)
b.comptes[id] = c
return c
}
Résumé
Pour retenir l'essentiel :
- Concurrence = gérer plusieurs tâches en alternant. Go : goroutines + channels.
- Parallélisme = exécuter plusieurs tâches en même temps. Go : plusieurs goroutines sur plusieurs cœurs.
- Event Sourcing côté écriture : un channel par aggregate → traitement séquentiel garanti, pas de race condition.
- CQRS côté lecture : plusieurs projections en goroutines parallèles → lecture sans contrainte d'ordre.
- Règle channels : c'est le producteur qui ferme, jamais le consumer.
Ces deux patterns sont souvent présentés comme complexes parce qu'on les accompagne de beaucoup de jargon. La réalité : ce sont des solutions à des problèmes très concrets (comment écrire sans corrompre l'état, comment lire vite). Go donne les primitives pour l'implémenter proprement — goroutines pour la concurrence, channels pour la coordination.