Table des matières du cours
Leçon 6 — Concurrence
Par Dorian Chávez · fondateur de Hábil et architecte d'intégration ·
Durée : 2 séances de 60-90 minutes. C'est la raison pour laquelle Go existe, et la seule leçon du cours
qui se fait en plusieurs passes : les goroutines, puis les canaux et WaitGroup, puis context et la limite
de parallélisme appliquée au revisor. En une seule passe, cela ne prend pas — c'est ce que confirme l'ordre
même qu'utilise le cours de Go qui compte le plus d'apprenants au monde.
À la fin, tu seras capable de :
- Lancer une goroutine et expliquer, avec une vraie preuve, pourquoi le programme peut se terminer avant qu'elle ne se termine.
- Utiliser
sync.WaitGrouppour attendre un groupe de goroutines, et reconnaître le message exact que donne Go quand lesAdd/Donene concordent pas. - Expliquer pourquoi le
revisordistribue les résultats par un canal au lieu d'écrire dans une map partagée, et provoquer toi-même l'erreur qui se produirait s'il ne le faisait pas. - Lire, littéralement, la sortie de
go test -racequand il trouve une vraie data race. - Utiliser
context.WithTimeoutpour imposer une limite de temps à une opération, et expliquer pourquoidefer cancelar()n'est pas facultatif. - Limiter le nombre de goroutines qui tournent en même temps avec un sémaphore de canal, et mesurer que la limite est vraiment respectée.
Pourquoi c'est important
Jusqu'à la leçon 5, le revisor interroge les services un par un : si tu as dix services et que chacun
met une seconde à répondre, le rapport complet prend dix secondes, quel que soit l'ordre. Avec cent services,
presque deux minutes. Et le temps d'attente, ce n'est pas toi qui le décides : c'est le service le plus lent de
la liste qui le décide, multiplié par le nombre de services.
Cela n'a pas à être ainsi, parce qu'interroger un service, c'est attendre, pas travailler : pendant presque
tout le temps de cette attente, le CPU ne fait rien, il attend seulement une réponse du réseau. Go a été conçu
par des gens qui, chez Google, passaient leurs journées à attendre exactement cela —des réponses réseau entre
des milliers de services— et c'est pourquoi la concurrence n'est pas une bibliothèque ajoutée après coup :
elle fait partie du langage dès la première ligne (go, un mot réservé, pas une fonction d'une bibliothèque).
Le résultat que tu vas construire dans cette leçon : les mêmes dix services d'une seconde chacun, interrogés tous en même temps, en un peu plus d'une seconde au total au lieu de dix. Ce chiffre —la différence entre « en série » et « en parallèle »— est la récompense de la leçon, et tu vas le mesurer toi-même, pas le croire sur parole.
🔑 Et l'avertissement qui rend cette leçon différente des précédentes : dans les leçons 2 à 5, un programme qui compile et dont les tests passent est presque toujours correct. En concurrence, non. Un programme avec une data race peut compiler, tourner, passer ses tests quatre-vingt-dix-neuf fois, et échouer la centième — ou ne jamais échouer sur ta machine et échouer tous les jours en production, avec plus de cœurs et plus de charge. Les erreurs de cette leçon sont celles qui ne se voient pas à l'œil nu, et c'est pourquoi le point 4 de cette leçon —les vraies erreurs, provoquées et capturées— est le plus important de tout le cours.
Les concepts
6.1 Les goroutines : démarrer est trivial, attendre non
Lancer une goroutine, c'est un mot :
go revisar(s)
Et c'est tout. Cela suffit pour que revisar(s) tourne de façon concurrente avec le reste du programme,
sans attendre qu'elle se termine pour passer à la ligne suivante. Chacune coûte environ 2 Ko de mémoire au
départ (elles grandissent si nécessaire), pas les mégaoctets d'un thread du système d'exploitation — c'est
pourquoi un programme en Go peut avoir des centaines de milliers de goroutines vivantes sans rien prévoir de
spécial, ce qui serait impensable avec des threads du système.
Mais regarde ce programme, avec le bug le plus courant du premier jour de concurrence en Go (complet dans
programas/revisor/ejemplos/06-goroutines-sin-esperar/main.go) :
func main() {
servicios := []string{"catalogo", "pagos", "inventario"}
for _, s := range servicios {
go fmt.Println("revisando", s)
}
// el programa termina AQUÍ, sin haber esperado a ninguna goroutine
}
Je l'ai lancé 260 fois sur cette machine pour mesurer à quelle fréquence on VOIT le problème, pas pour le
deviner : 40 avec go run, 20 de plus en forçant un seul processeur logique (GOMAXPROCS=1, pour ôter au
programme toute chance qu'une goroutine parvienne à tourner sur un autre cœur pendant que main se termine), et
200 avec le binaire déjà compilé (go build + ./gor61, pour écarter le temps que prend la compilation). Le
résultat :
$ go run main.go
$ go run main.go
$ go run main.go
Vide. Les trois, et pratiquement toutes les 260 (une seule des 200 exécutions du binaire compilé a affiché quelque chose — le reste, rien).
🔴 Et il faut lire ce chiffre avec beaucoup de soin, parce qu'il est facile d'en tirer la mauvaise
conclusion. Le défaut n'est pas présent « 1 fois sur 260 » : il est présent 260 fois sur 260, sans
exception. Dans aucune des 260 exécutions le programme n'a attendu ses goroutines — pas même dans la seule
qui a affiché quelque chose. Ce qui change d'une exécution à l'autre, ce n'est pas si le programme attend (il ne
le fait jamais) : c'est si, par pur hasard de la façon dont le système d'exploitation répartit le temps entre
les processus, une goroutine parvient à exécuter fmt.Println dans l'interstice de temps qu'il y a entre son
lancement et le moment où main tue le programme entier. Cet interstice ne suffit presque jamais — c'est
pourquoi on ne voit presque jamais rien — mais le programme est tout aussi cassé dans les 259 exécutions
silencieuses que dans celle qui a affiché quelque chose.
Voici ce qu'il faut retenir : toujours cassé, presque jamais visible. Ce n'est pas « il y a une petite probabilité que cela échoue » —le lire ainsi est exactement l'erreur à éviter— : c'est que le programme ne fait jamais ce qui est correct, et que la plupart du temps le symptôme n'a pas le temps de se montrer pour que tu le remarques. C'est ainsi que ce type de bug s'infiltre : il passe la revue de code (cela compile, tourne, ne plante pas), il passe les tests manuels (qui lance un programme 260 fois pour lui faire confiance ?), il passe la CI (qui l'exécute une fois, peut-être deux) — et il ne se manifeste qu'une fois en production, avec plus de charge, plus de cœurs, et un interstice de temps qui, un jour, finit par s'ouvrir, au pire moment possible pour le découvrir.
⚠️ Ne le prends pas non plus comme « cela n'affiche jamais rien, sur aucune machine ». Avec plus de charge, un autre système d'exploitation, ou simplement de la malchance, l'interstice peut s'ouvrir plus souvent — de fait, il s'est ouvert une fois dans ces mêmes 260 exécutions. Ce qui ne change pas d'une machine à l'autre, c'est que le programme n'attend jamais : c'est structurel, cela ne dépend pas de la chance. Lance toi-même l'expérience sur ta machine, avec suffisamment de répétitions pour que le chiffre signifie quelque chose, et compare.
Ce n'est pas un bug intermittent du programme : c'est que main se termine dès qu'il arrive à la fin de son
corps, sans se soucier qu'il y ait encore des goroutines en cours — et quand main se termine, le programme
entier se termine avec lui, goroutines vivantes comprises, sans avertissement et sans erreur. Lancer une
goroutine est facile ; le vrai travail est de t'assurer que le programme attend qu'elles se terminent avant de
continuer.
6.2 sync.WaitGroup : la bonne façon d'attendre
var wg sync.WaitGroup
for _, s := range servicios {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("revisando", s)
}()
}
wg.Wait() // aquí sí espera a que las tres hayan llamado Done
Le modèle est toujours le même, et il vaut mieux le mémoriser dans cet ordre :
wg.Add(1)avant de lancer la goroutine, pas à l'intérieur. Si tu le mets à l'intérieur,Wait()peut s'exécuter avant que la goroutine n'ait eu le temps de faire son propreAdd, et alors il ne compte pas avec cette attente.defer wg.Done()comme première ligne de la goroutine. Ledefergarantit qu'il s'exécute quoi qu'il arrive à l'intérieur —même si la goroutine fait un panic—, et le mettre en premier évite qu'unreturnanticipé au milieu du code te fasse oublier de le compter.wg.Wait()là où tu as vraiment besoin du résultat, normalement juste avant d'utiliser ce que les goroutines ont produit.
Ce qui se passe si tu sautes l'étape 1 — Add manquant —, provoqué exprès (complet dans
programas/revisor/ejemplos/06-waitgroup-sin-add/main.go) :
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
go func(n int) { // <- sin wg.Add(1) antes de esta línea
defer wg.Done()
time.Sleep(10 * time.Millisecond)
}(i)
}
wg.Wait()
fmt.Println("listo")
Je l'ai lancé six fois d'affilée, avec -race, pour que tu voies ce qui se passe vraiment, pas seulement
l'erreur finale :
$ go run -race sinesperar.go
listo
panic: sync: negative WaitGroup counter
...
exit status 2
$ go run -race sinesperar.go
listo
panic: sync: negative WaitGroup counter
...
exit status 2
(cuatro corridas más, idénticas: "listo" primero, el panic después — 6 de 6, CON -race)
🔑 Lis cela avec soin, parce que c'est l'erreur la plus trompeuse de la leçon : le programme affiche listo
AVANT de planter, les six fois. Ce n'est pas un hasard de cette exécution : wg.Wait() n'attend rien parce que
le compteur n'a jamais été incrémenté (il n'y a jamais eu d'Add(1)), donc Wait() revient
immédiatement, main affiche listo comme si tout s'était bien passé, et c'est seulement alors que
l'une des goroutines retardataires —qui tournait bel et bien, simplement Go ne l'a jamais attendue— termine son
Sleep et appelle Done(), en retranchant un à un compteur qui était déjà à zéro. Le panic n'est pas la
première chose que tu vois : c'est la dernière, après que le programme t'a déjà dit que tout allait bien.
⚠️ Et voici la partie que j'ai mal mesurée la première fois, et il vaut la peine de laisser la correction par
écrit : sans -race, ce même programme ne plante jamais. Les mêmes six exécutions, sans -race :
$ go run sinesperar.go
listo
$ go run sinesperar.go
listo
(cuatro corridas más, idénticas: "listo" y nada más — 6 de 6, SIN -race, saliendo con código 0)
Le time.Sleep(10 * time.Millisecond) n'est pas ce qui FAIT apparaître le panic — c'est ce qui
l'EMPÊCHE, sans -race. main arrive à wg.Wait(), qui revient immédiatement, affiche listo, et le
programme entier se termine bien avant que les 10 millisecondes ne s'écoulent — si bien qu'aucune des cinq
goroutines retardataires n'a même le temps de se réveiller de son Sleep et d'appeler Done(). Le programme
a, littéralement, l'air « terminé avec succès » : code de sortie 0, sans aucune trace que quelque chose était
mal monté. Ce qui fait bel et bien apparaître le panic, de façon constante, c'est l'instrumentation de -race :
surveiller chaque accès mémoire pour détecter les data races rend le programme nettement plus lent, et ce
ralentissement supplémentaire est exactement ce qui donne à une goroutine retardataire le temps de terminer son
Sleep et d'appeler Done() avant que le processus ne se termine.
C'est exactement le type d'erreur qui n'échoue pas à chaque fois, et c'est pourquoi c'est la plus précieuse de
cette leçon — et maintenant avec la bonne donnée : tu viens de mesurer qu'il n'est même pas nécessaire d'avoir
« un travail plus court » pour qu'elle passe inaperçue. En l'exécutant tel quel, SANS -race, elle passe déjà
inaperçue 6 fois sur 6. Un apprenant qui lance cet exemple sans -race —ce qui est évident, si personne ne
l'avertit— va voir listo et rien d'autre, et va conclure, à juste titre, que le programme fonctionne. Il ne
fonctionne pas : l'Add(1) manque toujours exactement de la même façon. La seule chose qui a changé, c'est si
quelque chose a eu le temps de se montrer pour le trahir — le même modèle « toujours cassé, presque jamais
visible » de la section 6.1, ici avec un acteur différent : ce n'est pas la charge de la machine, c'est si tu as
exécuté avec le détecteur de data races activé ou non.
Done() retranche un au compteur interne du WaitGroup. Si tu n'as jamais fait Add(1), le compteur
commence à zéro, et lui retrancher un le rend négatif — et Go, au lieu de le laisser passer en silence, fait un
panic avec un message qui dit exactement ce qui ne va pas : negative WaitGroup counter. Ce message littéral est
ton indice : si tu le vois, il manque presque toujours un Add(1) quelque part, ou il y a plus de Done()
qu'il ne devrait y en avoir. Mais la vraie leçon n'est pas le message du panic — c'est que le programme avait
déjà dit listo avant qu'il n'apparaisse.
6.3 Pourquoi le revisor ne partage pas de map : les canaux
La devise du langage, et elle vaut la peine d'être mémorisée telle quelle : « ne communique pas en partageant la mémoire ; partage la mémoire en communiquant ». Au lieu que chaque goroutine écrive son résultat dans une map ou un slice partagé —ce qui exigerait de protéger chaque accès avec un verrou—, chacune envoie son résultat par un canal, et une seule goroutine (ou le code principal) les récupère de l'autre côté.
ch := make(chan servicio.Estado) // sin buffer: cada envío espera a que alguien reciba
ch := make(chan servicio.Estado, 10) // con buffer: hasta 10 caben sin esperar a que nadie reciba
ch <- estado // enviar
e := <-ch // recibir
close(ch) // cerrar: después de cerrado, recibir sigue funcionando hasta vaciarlo
for e := range ch { ... } // recibe hasta que el canal se cierre Y se vacíe
J'ai vérifié ce qui se passe si, au lieu d'un canal, j'utilise un slice partagé sans protection, exactement
l'erreur que les canaux évitent. Ce programme lance mille goroutines qui incrémentent la même variable (complet
dans programas/revisor/ejemplos/06-carrera-de-datos/main.go) :
contador := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
contador++ // dos goroutines pueden leer el mismo valor antes de que la otra escriba
}()
}
wg.Wait()
fmt.Println("contador:", contador)
Sans le détecteur de data races, le programme ne plante pas — il donne seulement un résultat incorrect, et différent à chaque fois :
$ go run carrera.go
contador: 956
956, pas 1000. Aucune erreur, aucun panic, aucun signe que quelque chose s'est mal passé — seulement un
nombre plus petit que ce qu'il devrait être, parce que certaines des mille additions se sont perdues quand deux
goroutines ont lu contador en même temps, ont toutes deux ajouté un à la même ancienne valeur, et que l'une
des deux écritures a écrasé l'autre sans que personne ne s'en aperçoive. C'est le bug le plus dangereux de la
concurrence : il n'échoue pas, il donne un résultat plausible et faux. Un programme de ce genre peut passer la
revue, passer des tests superficiels, et échouer en production de façon sporadique pendant des mois avant que
quelqu'un ne le remarque.
6.4 go test -race : voir le détecteur trouver le bug
Le même programme, exécuté avec -race, le trahit bel et bien — avec la preuve exacte de l'endroit :
$ go run -race carrera.go
==================
WARNING: DATA RACE
Read at 0x00c00013c018 by goroutine 11:
main.main.func1()
carrera.go:15 +0x68
Previous write at 0x00c00013c018 by goroutine 8:
main.main.func1()
carrera.go:15 +0x78
Goroutine 11 (running) created at:
main.main()
carrera.go:13 +0x6c
Goroutine 8 (finished) created at:
main.main()
carrera.go:13 +0x6c
==================
contador: 847
Found 2 data race(s)
exit status 66
Lis-le de haut en bas, parce que chaque bloc répond à une question différente :
Read at ... by goroutine 11etPrevious write at ... by goroutine 8: deux goroutines différentes ont touché la même adresse mémoire (0x00c00013c018, la variablecontador), l'une en lecture et l'autre en écriture, sans aucun mécanisme garantissant que l'une attende l'autre.carrera.go:15dans les deux : la ligne exacte du code où cela s'est produit (contador++), la même ligne pour les deux, parce que c'est la seule ligne qui touche cette variable.Goroutine 11 (running) created at ... carrera.go:13: d'où vient cette goroutine —la ligne dugo func() {...}()à l'intérieur dufor—, pour que tu puisses retrouver quel lancement c'était.Found 2 data race(s)etexit status 66: le détecteur ne s'arrête pas à la première data race qu'il trouve ; il continue de tourner et les rapporte toutes, et le programme se termine avec un code de sortie différent de 0 et de 1 (66 est le code que Go réserve à cela), pour qu'un pipeline d'intégration continue puisse le distinguer d'un échec normal.
Et le chiffre final, contador: 847, a changé par rapport à l'exécution sans -race (956). Pas par
hasard : -race fait tourner le programme plus lentement et avec plus d'instrumentation, ce qui change l'ordre
exact dans lequel les goroutines s'entrelacent — une autre raison pour laquelle ce bug est si traître : le
chiffre faux n'est même pas le même faux à chaque fois.
🔴 La règle qui en découle, sans exception : un programme concurrent qui passe ses tests sans -race
n'est pas testé. Lance -race dès le premier test de concurrence que tu écris, pas quand « quelque chose a
l'air bizarre » — parce que, comme tu viens de le voir, rien n'a l'air bizarre jusqu'à ce qu'il soit trop tard.
6.5 Les deux autres façons d'échouer : panic et deadlock
Panic, si tu fermes mal un canal :
- Envoyer sur un canal déjà fermé fait un panic :
panic: send on closed channel. - Fermer un canal deux fois fait un panic :
panic: close of closed channel.
La règle qui évite les deux : c'est celui qui envoie qui ferme le canal, jamais celui qui reçoit, et il le
ferme une seule fois, normalement depuis une goroutine dédiée qui sait quand il n'y aura plus d'envois (tu le
verras dans la section 6.7, avec wg.Wait() suivi de close).
Deadlock, si personne n'écoute de l'autre côté. Je l'ai provoqué avec le programme le plus court possible
(complet dans programas/revisor/ejemplos/06-deadlock/main.go) :
func main() {
canal := make(chan int)
canal <- 1 // nadie del otro lado está leyendo: se bloquea
fmt.Println(<-canal)
}
$ go run candado.go
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
candado.go:7 +0x38
exit status 2
Ceci corrige une chose que beaucoup d'explications tiennent pour acquise : un deadlock en Go ne « reste pas
toujours bloqué pour toujours ». Le runtime de Go lui-même a un détecteur de deadlocks : si à un moment donné
toutes les goroutines du programme sont endormies à attendre quelque chose qui n'arrivera jamais, Go s'en
aperçoit et tue le programme avec fatal error: all goroutines are asleep - deadlock!, avec la ligne exacte où
il s'est coincé ([chan send], dans ce cas, parce qu'il était en train d'envoyer). Si ton programme semble
« bloqué pour toujours » au lieu de se terminer avec cette erreur, ce n'est presque sûrement pas un vrai
deadlock : il est plus probable que tu aies au moins une goroutine vivante qui fait autre chose (par exemple, un
minuteur ou un serveur HTTP à l'écoute), et c'est elle qui empêche le runtime de déclarer que « toutes » sont
endormies.
6.6 context : comment on annule et comment on impose une limite de temps
ctx, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelar() // SIEMPRE, incluso si terminas antes de que se cumplan los 5 segundos
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req) // se aborta solo si pasan los 5 s
context est le standard maison en Go pour deux choses : imposer une limite de temps à une opération qui
pourrait prendre trop longtemps, et propager une annulation vers le bas (si celui du dessus est annulé, tout
ce qui dépend de lui est annulé aussi, sans que chaque fonction ait à réinventer son propre mécanisme).
⚠️ defer cancelar() n'est pas facultatif, même quand l'opération s'est déjà terminée d'elle-même.
context.WithTimeout démarre un minuteur interne ; si tu n'appelles jamais la fonction d'annulation, ce
minuteur reste vivant jusqu'à l'expiration du délai d'origine, en retenant de la mémoire pendant tout ce temps
— et si ton programme crée des contextes ainsi en permanence (un par service interrogé, comme le revisor),
sans defer cancelar() tu accumules des fuites de mémoire proportionnelles au nombre de requêtes que tu as
faites. C'est la fuite la plus courante des programmes Go qui utilisent context, et c'est pourquoi il vaut
mieux écrire le defer sur la même ligne que celle où tu crées le contexte, avant d'écrire quoi que ce soit
d'autre.
6.7 Todos : la fonction qui réunit les trois pièces
Voici, pour de vrai, à quoi ressemble la fonction centrale du revisor
(programas/revisor/internal/revisar/todos.go), après avoir réuni goroutines, canaux, sémaphore et
context :
func Todos(ctx context.Context, r Revisor, servicios []servicio.Servicio, paralelo int) []servicio.Estado {
if len(servicios) == 0 {
return nil
}
if paralelo <= 0 {
paralelo = ParaleloPorOmision
}
resultados := make(chan servicio.Estado, len(servicios))
semaforo := make(chan struct{}, paralelo)
var wg sync.WaitGroup
for _, s := range servicios {
wg.Add(1)
go func() {
defer wg.Done()
semaforo <- struct{}{} // pide turno; se bloquea si ya hay «paralelo» corriendo
defer func() { <-semaforo }() // devuelve el turno pase lo que pase
// Cada servicio tiene su propio tiempo límite, hijo del general. Si
// el de arriba se cancela, este muere con él.
propio, cancelar := context.WithTimeout(ctx, s.TimeoutEfectivo())
defer cancelar() // sin esto, el temporizador no se libera: es la fuga más común de Go
resultados <- r.Revisar(propio, s)
}()
}
// Quien envía cierra, nunca quien recibe. Y se cierra desde otra goroutine
// porque Wait tiene que poder esperar mientras el bucle de abajo ya está
// recibiendo: si cerráramos aquí mismo, con el canal lleno nos trabaríamos.
go func() {
wg.Wait()
close(resultados)
}()
estados := make([]servicio.Estado, 0, len(servicios))
for e := range resultados {
estados = append(estados, e)
}
return estados
}
Lis-la avec les sections précédentes en tête, parce que chaque pièce répond à un problème que tu as déjà vu :
- Une goroutine par service (6.1), comptée avec
wg.Add(1)/defer wg.Done()(6.2) pour que le programme sache quand toutes se sont terminées. - Un canal
resultadosavec tampon (6.3) recueille les états : personne n'écrit dans un slice ou une map partagés, donc aucun verrou n'est nécessaire pour cette partie. semaforo := make(chan struct{}, paralelo)est un canal utilisé comme quota : il a de la place pourparalelovaleurs, donc laparalelo + 1-ième goroutine qui essaie d'y écrire (semaforo <- struct{}{}) se bloque jusqu'à ce qu'une autre libère sa place (<-semaforo, dans ledefer). C'est le même canal avec tampon que dans la section 6.3, utilisé non pas pour transporter des données mais pour tenir le compte des « tours » qui restent.- Un
context.WithTimeoutpropre à chaque service (6.6), enfant ductxgénéral : si celui du dessus est annulé (par exemple, si le temps total du rapport est épuisé), tous les enfants sont annulés avec lui. close(resultados)depuis UNE AUTRE goroutine, aprèswg.Wait()— et cela mérite une explication, parce que c'est la partie qui se voit le moins à l'œil nu : si nous fermions le canal dans la même goroutine qui fait lefor e := range resultadosplus bas, nous nous bloquerions, parce queWait()a besoin que toutes les goroutines lisent dans le canal pour libérer de la place et pouvoir rendre leur tour, mais la boucle de lecture ne démarrerait jamais parce que nous attendrions d'abordWait(). En le lançant à part, la fermeture et la lecture se produisent en même temps, pas l'une après l'autre.
6.8 Le tester sans réseau : Falso et la limite de parallélisme mesurée
Tester Todos contre de vrais services serait lent et non déterministe. Le revisor utilise un Revisor
factice (programas/revisor/internal/revisar/falso.go) qui simule des réponses, des délais et même des services
qui ne répondent jamais, tout en mémoire :
type Falso struct {
Respuestas map[string]RespuestaFalsa
mu sync.Mutex
Contador int
}
func (f *Falso) Revisar(ctx context.Context, s servicio.Servicio) servicio.Estado {
f.mu.Lock()
f.Contador++
f.mu.Unlock()
// ...
}
🔒 Ici, un mutex est bel et bien nécessaire, et c'est l'exception à la règle « préfère les canaux » de la
section 6.3. Contador est un entier partagé que beaucoup de goroutines incrémentent en même temps
pendant les tests (Falso.Revisar est appelé de façon concurrente, une fois pour chaque service que Todos
est en train d'interroger). Un canal servirait à envoyer des résultats, mais pour un simple compteur partagé,
un sync.Mutex autour de l'unique ligne qui le touche est plus simple et plus clair. La règle n'est pas
« n'utilise jamais de mutex » : c'est « avant d'en utiliser un, demande-toi si un canal exprime mieux ce que tu
es en train de faire » — et pour envoyer des résultats, presque toujours oui ; pour un compteur partagé, le
mutex est presque toujours le bon outil.
Avec Falso, ce test mesure quelque chose qu'il serait autrement presque impossible de vérifier avec
confiance : que le sémaphore de la section 6.7 limite vraiment le nombre de requêtes qui tournent en même
temps, pas seulement qu'il « fonctionne en général » (version complète, non abrégée, dans
programas/revisor/internal/revisar/todos_test.go) :
func TestTodos_elParaleloLimitaCuantasCorrenALaVez(t *testing.T) {
const totalServicios = 20
const limite = 3
var enVuelo, maximoObservado int32
// medidorDeConcurrencia cuenta, con un contador atómico, cuántas llamadas
// a Revisar están abiertas AL MISMO TIEMPO, y se queda con el máximo.
medidor := &medidorDeConcurrencia{enVuelo: &enVuelo, maximoObservado: &maximoObservado, espera: 15 * time.Millisecond}
servicios := make([]servicio.Servicio, totalServicios)
// ... llena servicios ...
Todos(context.Background(), medidor, servicios, limite)
if max := atomic.LoadInt32(&maximoObservado); max > int32(limite) {
t.Errorf("se observaron %d consultas simultáneas; el límite era %d", max, limite)
}
}
Exécution réelle, avec le détecteur de data races actif (pour confirmer que même la mesure elle-même n'introduit pas de data race) :
$ go test ./internal/revisar/... -race -v -run TestTodos_elParaleloLimitaCuantasCorrenALaVez
=== RUN TestTodos_elParaleloLimitaCuantasCorrenALaVez
--- PASS: TestTodos_elParaleloLimitaCuantasCorrenALaVez (0.11s)
PASS
ok github.com/habil/revisor/internal/revisar 1.435s
20 services, limite de 3, et le test confirme qu'il n'y a jamais eu plus de 3 appels à Revisar en cours en
même temps — non pas parce que nous le supposons à partir du code, mais parce qu'un compteur atomique l'a
mesuré pendant l'exécution.
6.9 Le temps, mesuré : en série contre en parallèle
Avec Falso configuré pour simuler des délais réels, le test TestTodos_respetaElTimeoutPorServicio confirme
l'autre face de la médaille : un service qui ne répond jamais (Colgado: true) avec un
Timeout: 30 * time.Millisecond ne peut pas faire durer Todos plus longtemps que cela :
$ go test ./internal/revisar/... -run TestTodos_respetaElTimeoutPorServicio -v
=== RUN TestTodos_respetaElTimeoutPorServicio
--- PASS: TestTodos_respetaElTimeoutPorServicio (0.03s)
0,03 seconde, pas plus. Le context.WithTimeout de la section 6.7, enfant du contexte général, a coupé
cette requête exactement quand il le fallait, sans que le reste du programme ait à savoir que cela s'était
produit.
L'erreur que tu vas voir
Tous ceux-ci sont littéraux, provoqués exprès pour cette leçon :
| Le symptôme | Message / preuve littérale | Ce qui se passe et quoi faire |
|---|---|---|
main se termine avant que les goroutines ne tournent |
(aucune sortie, ou une sortie partielle et incohérente d'une exécution à l'autre) | Il manque un sync.WaitGroup (ou un canal) qui fasse attendre main. Section 6.1 |
Les Add/Done ne concordent pas |
Le programme affiche que tout s'est bien passé d'abord, et panic: sync: negative WaitGroup counter arrive APRÈS |
Il manque un wg.Add(1) avant de lancer une goroutine, ou il y a un Done() de trop. Wait() est revenu sans rien attendre. Section 6.2 |
| Données partagées sans protection | WARNING: DATA RACE + Found 2 data race(s) + exit status 66 (avec -race) ; un nombre incorrect sans explication, sans -race |
Deux goroutines touchent la même mémoire sans synchronisation. Utilise un canal pour envoyer le résultat, ou un mutex si tu as vraiment besoin d'un compteur partagé. Sections 6.3 et 6.4 |
| Envoyer sur un canal fermé | panic: send on closed channel |
Quelqu'un d'autre a déjà fermé le canal, ou c'est celui qui ne devait pas le faire qui l'a fermé. Ferme toujours depuis celui qui envoie, une seule fois |
| Fermer deux fois le même canal | panic: close of closed channel |
Deux goroutines (ou deux chemins de code) essaient de fermer le même canal. Centralise la fermeture en un seul endroit |
| Personne de l'autre côté d'un canal sans tampon | fatal error: all goroutines are asleep - deadlock! |
Le runtime de Go détecte que tout le programme s'est endormi à attendre quelque chose qui n'arrivera jamais, et le tue avec cette ligne exacte. Section 6.5 |
Tu as oublié defer cancelar() |
(pas d'erreur immédiate ; fuite de mémoire accumulée avec le temps) | Chaque context.WithTimeout non annulé garde son minuteur vivant jusqu'à ce qu'il expire de lui-même. Section 6.6 |
Et la consigne qui résume toute cette leçon : si tu vas écrire de la concurrence, lance -race dès ton
premier test, pas quand quelque chose sent mauvais — parce que, comme tu l'as vu dans la section 6.3, rien ne
sent mauvais jusqu'à ce qu'il soit trop tard.
Ce qu'on fait mal
- Lancer une goroutine par élément, sans aucune limite, contre une ressource externe. Avec 5 services, cela ne se remarque pas. Avec 5 000, le programme ouvre 5 000 connexions d'un coup, et le goulot d'étranglement cesse d'être le service distant pour devenir ta propre machine (ou le réseau, ou le serveur lui-même qui reçoit une pluie de 5 000 requêtes simultanées). Le sémaphore de la section 6.7 existe exactement pour cela — ne laisse jamais « combien de goroutines je lance » dépendre uniquement de « combien d'éléments j'ai ».
- Ignorer le
contextqu'on te donne, ou ne pas le propager. Si une fonction reçoit unctxet appelle une autre opération qui peut prendre du temps sans le lui passer, cette opération ne sera pas annulée quand lectxoriginal le sera — tu as deux horloges qui ne se parlent pas. Tout ce qui peut prendre du temps reçoit lectxde celui qui l'a appelé. - Ne pas fermer ce qu'on ouvre. Un
context.WithTimeoutsans soncancelar(), une connexion HTTP sansresp.Body.Close()(leçon 7), un fichier non fermé : chacun est une fuite différente, mais la façon de les éviter est la même — undeferjuste après l'ouverture, avant d'écrire la moindre autre ligne. - Partager la mémoire au lieu de la communiquer « parce que c'est plus rapide à écrire ». Une map partagée
avec un mutex autour de tout le bloc peut sembler plus court que de monter un canal, mais il est plus facile
d'oublier un
Lock()à un seul point d'accès que d'oublier d'envoyer par un canal — et le premier oubli ne se remarque pas jusqu'à ce que le détecteur de data races (ou, pire, la production) le trouve. - Croire que « cela n'a jamais échoué » signifie « c'est correct ». Une data race peut passer
quatre-vingt-dix-neuf exécutions et échouer à la centième, ou n'échouer qu'avec plus de cœurs que ceux de ton
ordinateur portable. La seule preuve qu'un programme concurrent n'a pas de data races est de l'exécuter avec
-race, pas de le voir passer plusieurs fois sans.
Exercices
- Écris le programme de la section 6.1 (goroutines sans attente) et lance-le cinq fois d'affilée. Note combien de fois il a affiché quelque chose et combien de fois non.
- Ajoute-lui un
sync.WaitGroupcorrect (section 6.2) et confirme que maintenant les trois lignes s'affichent toujours, dans les cinq exécutions. - Reproduis le bug de l'
Addmanquant de la section 6.2 et colle lepaniccomplet dans ton journal de bord. - Reproduis la data race de la section 6.3 (un compteur partagé sans protection) et lance le même programme
avec et sans
-race. Compare les deux nombres finaux et colle la sortie complète de-racedans ton journal de bord. - Reproduis le deadlock de la section 6.5 avec un canal sans tampon et sans récepteur. Confirme que tu vois le
fatal error: all goroutines are asleep - deadlock!, et pas un blocage silencieux. - Prends ton propre
revisor(ou celui de ce cours) et lanceTestTodos_elParaleloLimitaCuantasCorrenALaVezen changeant lalimiteà 1 puis à 10. Explique, avec tes mots, pourquoi le résultat du test ne change pas (il passe toujours) mais le temps qu'il prend, si. - (Un peu plus difficile) Retire le sémaphore de
Todos(laisse toutes les goroutines se lancer sans limite) et relanceTestTodos_elParaleloLimitaCuantasCorrenALaVez. Confirme que maintenant il échoue, et colle le message d'erreur exact que donnet.Errorfavec le maximum qui a été effectivement observé.
Corrigés
1-2. Il n'y a pas de nombre unique : cela dépend de ta machine. Ce qui compte, c'est la comparaison — sans
WaitGroup, incohérent ; avec lui, toujours les trois lignes.
Le programme affiche d'abord
listo—Wait()est revenu immédiatement parce que le compteur n'a jamais été incrémenté — et lepanic: sync: negative WaitGroup counterarrive après, avec une trace qui inclutsync.(*WaitGroup).Add(...). Exécuté plusieurs fois d'affilée, l'ordre est toujours le même :listo, puis le panic.Sans
-race, un nombre inférieur à celui attendu (par exemple, 956 sur 1000), sans aucune erreur. Avec-race, le blocWARNING: DATA RACEavec la ligne exacte du code, plusFound N data race(s)etexit status 66.fatal error: all goroutines are asleep - deadlock!, avecgoroutine 1 [chan send]:(ou[chan receive], selon le côté où cela s'est coincé) et la ligne exacte du canal.Le test passe dans les deux cas parce qu'il mesure le maximum réel et le compare à la limite qu'il a lui-même configurée (1 ou 10) — jamais à un nombre fixe attendu. Le temps total, lui, change : avec
limite=1les 20 requêtes sont strictement en série (l'une attend l'autre), aveclimite=10elles tournent en deux fournées de 10 au lieu de vingt d'une seule.Sans sémaphore, les 20 goroutines tournent toutes en même temps, donc
maximoObservadosera proche de 20 (pas exactement la limite du test, qui continue de demander 3). Let.Errorfdit quelque chose commese observaron 20 consultas simultáneas; el límite era 3— le test détecte BIEN la régression, ce qui est justement sa raison d'être.
Comment savoir que j'y suis arrivé
- J'ai vu, de mes propres yeux, un programme se terminer sans avoir attendu ses goroutines (section 6.1).
- J'ai provoqué le
panic: sync: negative WaitGroup counteret j'ai vu que le programme a affichélistoAVANT le panic — et je peux expliquer pourquoi. - J'ai provoqué une vraie data race et j'ai vu la différence entre l'exécuter avec et sans
-race. - Je peux lire un bloc de
WARNING: DATA RACEet dire quelle ligne, quelles goroutines et quelle variable sont en conflit. - J'ai provoqué le
fatal error: all goroutines are asleep - deadlock!et je sais pourquoi Go le détecte au lieu de rester bloqué pour toujours. - Je peux expliquer, avec le vrai code de
Todos, à quoi sert chacune de ses quatre pièces : goroutines, canal de résultats, sémaphore etcontextpar service. - J'ai mesuré, avec un vrai test (pas de mémoire), que la limite de parallélisme du
revisorest bien respectée. - Je sais expliquer pourquoi
Falso.Contadorutilise un mutex au lieu d'un canal, et pourquoi cela ne contredit pas la règle générale de la section 6.3.
Pour aller plus loin
- A Tour of Go: Concurrency — le parcours officiel des goroutines, des
canaux et de
sync.WaitGroup, interactif. - Go Blog: Share Memory By Communicating — l'article original où est expliquée la devise citée dans la section 6.3.
- Package context — la documentation officielle, avec les quatre cas
d'utilisation canoniques (
WithCancel,WithTimeout,WithDeadline,WithValue). - Data Race Detector — la documentation officielle de
-race: ce qu'il détecte, ce qu'il NE détecte PAS (par exemple, il ne trouve pas les deadlocks, seulement les data races), et son coût en temps d'exécution.
Termes de cette leçon
| Terme | Ce qu'il signifie |
|---|---|
| goroutine | une fonction qui tourne de façon concurrente avec le reste du programme, bien moins coûteuse qu'un thread du système d'exploitation |
sync.WaitGroup |
mécanisme pour attendre qu'un groupe de goroutines se termine, en comptant les Add/Done |
| data race (situation de compétition) | deux goroutines accèdent à la même mémoire en même temps, au moins l'une en écriture, sans synchronisation |
canal (chan) |
le mécanisme de Go pour qu'une goroutine envoie des données à une autre sans mémoire partagée |
| sémaphore de canal | un canal avec tampon utilisé comme quota de tours disponibles, pas pour transporter des données |
context |
mécanisme standard pour propager des limites de temps et l'annulation entre fonctions |
| deadlock | état dans lequel toutes les goroutines d'un programme sont endormies à attendre quelque chose qui n'arrivera jamais ; le runtime de Go le détecte et termine le programme |
Vous préférez l'e-mail ? Écrivez-nous à hola@habil.mx