Après la série Winery et l’exo Interfaces, voici le morceau de bravoure de la journée d’entretien : Concurrence ⚙️.
Un producteur/consommateur classique : des workers qui comptent, un lecteur qui agrège.

Sur le papier, du Go de tous les jours.

Sauf que le test fourni ne finit jamais.
Pas un échec d’assertion : le runtime Go l’arrête de lui-même, fatal error: all goroutines are asleep - deadlock!. Un deadlock global, tapi dans un channel, à tous les coups. 🔒

C’est un exercice bien plus riche que les précédents : là où le Winery cachait un piège par test, celui-ci en empile quatre dans une trentaine de lignes, et il faut les démonter dans le bon ordre.
On va aussi voir que sur ce terrain-là, le meilleur débogueur n’est pas l’œil : ce sont le runtime lui-même, -race, -count et -timeout.

💡 Tout le code est sur GitHub.

🎯 Le décor

L’énoncé tient en une phrase : le test échoue, fais-le passer ; tu peux modifier le code et le test, mais pas les assertions.
Le contrat, c’est donc l’assertion, rien d’autre.

Une interface décrit une progression de job : un identifiant, une valeur d’avancement.

type JobProgress interface {
    JobId() string
    Progression() int
}

Et un Counter qui vit dans une goroutine, incrémente sa valeur et publie chaque étape sur un channel :

type Counter struct {
    lck             sync.RWMutex       // protège value des accès concurrents
    identifier      string             // identifiant du job traité
    value           int                // progression courante
    progressChannel chan<- JobProgress // channel où chaque étape est publiée
}

// Incrémente la progression et la publie au consommateur
func (c *Counter) Inc() {
    c.lck.Lock()
    defer c.lck.Unlock()
    c.value++
    c.progressChannel <- c
}

func (c *Counter) Progression() int {
    c.lck.RLock()
    defer c.lck.RUnlock()
    return c.value
}

func (c *Counter) JobId() string { return c.identifier }

📋 Ce que le test attend

Le test simule 5 workers qui comptent chacun jusqu’à 100, et 1 lecteur qui collecte chaque étape.
Son rôle : vérifier que chaque palier de 1 à 100 a bien été atteint par les 5 workers.

func TestCheckProgress(t *testing.T) {
    worker := 5
    countTo := 100

    progress := make(chan JobProgress)
    wg := sync.WaitGroup{}
    wg.Add(worker)

    readerTestSuccess := false
    // Le lecteur : une goroutine qui agrège la progression
    go func() {
        result := JobTracker{}
        for p := range progress {
            result.RegisterProgression(p.JobId(), p.Progression())
        }

        if len(result) != countTo {
            t.Errorf("Some workers results are missing expected %d - got %d", countTo, len(result))
            return
        }
        for value := 1; value <= countTo; value++ {
            finishedJobs := result[value]
            if finishedCount := len(finishedJobs); finishedCount != worker {
                t.Errorf("Invalid count for progression value %d step (expected %d - got %d)", value, worker, finishedCount)
                return
            }
        }
        readerTestSuccess = true
    }()

    // 5 workers, chacun compte jusqu'à 100
    i := 0
    for i < worker {
        i++
        go func() {
            c := NewProgressCounter(fmt.Sprintf(JOBID_FORMAT, i), progress)
            for i := 0; i < countTo; i++ {
                c.Inc()
            }
            wg.Done()
        }()
    }

    wg.Wait()
    close(progress)

    if !readerTestSuccess {
        t.Errorf("Reader tests not performed")
    }
}

Le JobTracker est une simple map[int]map[string]struct{} : pour chaque palier de progression, l’ensemble des workers qui l’ont atteint.

type JobTracker map[int]map[string]struct{}

func (jt JobTracker) RegisterProgression(jobId string, progress int) {
    track, exists := jt[progress]
    if !exists {
        jt[progress] = make(map[string]struct{})
        track = jt[progress]
    }
    track[jobId] = struct{}{}
}

L’invariant vérifié par le lecteur est double, et c’est lui le vrai cahier des charges :

  • len(result) == 100 : les 100 paliers de progression ont tous été vus. Aucune étape ne manque.
  • Pour chaque palier, len(result[value]) == 5 : les 5 workers ont atteint chaque étape. Personne n’est resté en route.

Sur le papier, c’est mécaniquement vrai : 5 workers × 100 incréments, chacun publie 1, 2, …, 100.
Chaque palier devrait donc voir exactement 5 identifiants distincts.
Et pourtant, ce test ne se termine jamais. 🤔

Deadlock Lecteur non attendu Variable de boucle -race + parallel

⏱️ La solution réalisée en séance

Petite parenthèse honnête avant de disséquer.

Le jour de l’entretien, j’ai fini par faire passer ce test : ça compilait, ça devenait vert, l’exercice était « validé ».
Mais à froid, en relisant mon propre code, le constat est moins flatteur.
La solution que j’avais posée n’était pas vraiment celle qu’on écrirait à tête reposée. 😅

Rien d’infamant là-dedans, et c’est même tout l’intérêt du débrief.
En séance, le chrono tourne, quelqu’un te regarde taper, et le cerveau optimise pour une seule chose : que le test passe.
Dans cet état, on répare les symptômes sans toujours nommer la maladie.
J’ai fait taire le deadlock mais sans identifier sur le moment tout ce qui clochait vraiment : un verrou que je gardais par réflexe, un choix de receiver que je n’ai pas questionné, un harnais de test qui planquait ses propres pièges.
Autant de points qui me saute aux yeux aujourd’hui après avoir pris le temps d’ analyser à tête reposée cet exercice. 🔍

C’est pour ça que la suite reprend l’exercice à froid.
Nous allons décortiquer chaque piège un par un, pas seulement « comment faire passer le test », mais pourquoi chaque réflexe de séance méritait un second regard. 👇

🐛 Bug 1 : Le deadlock qui dort dans le channel

Premier go test, et le programme s’arrête net sur un message que le runtime Go crache de lui-même :

Analyse détaillée de ce qui se passe

Déjà, il faut lire attentivement la sortie du programme et prendre le temps de l’analyser en détails:

=== RUN   TestCheckProgress
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
testing.(*T).Run(0x685dde1fc008, {0x104c4e3fa?, 0x1df2a04004c071f0?}, 0x104da4aa0)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2109 +0x3bc
testing.runTests.func1(0x685dde1fc008)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2585 +0x40
testing.tRunner(0x685dde1fc008, 0x685dde1efc48)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2036 +0xc4
testing.runTests({0x104c4edd9, 0x13}, {0x104c4edd9, 0x13}, 0x685dde1aa060, {0x104dc5150, 0x1, 0x1}, {0x8080808060362517?, 0x104c4db8c?, ...})
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2583 +0x3f0
testing.(*M).Run(0x685dde1a8140)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2443 +0x578
main.main()
	_testmain.go:46 +0x80

goroutine 22 [sync.WaitGroup.Wait]:
sync.runtime_SemacquireWaitGroup(0x104b84250?, 0xd0?)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/runtime/sema.go:114 +0x38
sync.(*WaitGroup).Wait(0x685dde19e220)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/sync/waitgroup.go:206 +0xa8
imagino/concurrency.TestCheckProgress(0x685dde1fc248)
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:78 +0x150
testing.tRunner(0x685dde1fc248, 0x104da4aa0)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2036 +0xc4
created by testing.(*T).Run in goroutine 1
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2101 +0x3a8

goroutine 23 [sync.RWMutex.RLock]:
sync.runtime_SemacquireRWMutexR(0x685dde124dd8?, 0x1?, 0x685dde124e08?)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/runtime/sema.go:100 +0x28
sync.(*RWMutex).RLock(...)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/sync/rwmutex.go:74
imagino/concurrency.(*Counter).Progression(0x685dde19a940)
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur.go:28 +0x74
imagino/concurrency.TestCheckProgress.func1()
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:46 +0xf0
created by imagino/concurrency.TestCheckProgress in goroutine 22
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:43 +0xc4

goroutine 24 [chan send]:
imagino/concurrency.(*Counter).Inc(0x685dde19a980)
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur.go:23 +0x70
imagino/concurrency.TestCheckProgress.func2()
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:71 +0xc0
created by imagino/concurrency.TestCheckProgress in goroutine 22
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:68 +0xe0

goroutine 25 [chan send]:
imagino/concurrency.(*Counter).Inc(0x685dde19aa00)
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur.go:23 +0x70
imagino/concurrency.TestCheckProgress.func2()
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:71 +0xc0
created by imagino/concurrency.TestCheckProgress in goroutine 22
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:68 +0xe0

goroutine 26 [chan send]:
imagino/concurrency.(*Counter).Inc(0x685dde284000)
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur.go:23 +0x70
imagino/concurrency.TestCheckProgress.func2()
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:71 +0xc0
created by imagino/concurrency.TestCheckProgress in goroutine 22
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:68 +0xe0

goroutine 27 [chan send]:
imagino/concurrency.(*Counter).Inc(0x685dde19a9c0)
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur.go:23 +0x70
imagino/concurrency.TestCheckProgress.func2()
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:71 +0xc0
created by imagino/concurrency.TestCheckProgress in goroutine 22
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:68 +0xe0

goroutine 28 [chan send]:
imagino/concurrency.(*Counter).Inc(0x685dde19a940)
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur.go:23 +0x70
imagino/concurrency.TestCheckProgress.func2()
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:71 +0xc0
created by imagino/concurrency.TestCheckProgress in goroutine 22
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:68 +0xe0


Process finished with the exit code 1

Ici on peut voir que 8 goroutines sont en train de tourner.

Comment peut - t - on avoir 8 go routines❓

C’est assez simple, go ne lance applicativement que des goroutines :

  • 1 goroutine (1) pour le test
goroutine 1 [chan receive]:
testing.(*T).Run(0x685dde1fc008, {0x104c4e3fa?, 0x1df2a04004c071f0?}, 0x104da4aa0)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2109 +0x3bc
testing.runTests.func1(0x685dde1fc008)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2585 +0x40
testing.tRunner(0x685dde1fc008, 0x685dde1efc48)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2036 +0xc4
testing.runTests({0x104c4edd9, 0x13}, {0x104c4edd9, 0x13}, 0x685dde1aa060, {0x104dc5150, 0x1, 0x1}, {0x8080808060362517?, 0x104c4db8c?, ...})
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2583 +0x3f0
testing.(*M).Run(0x685dde1a8140)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2443 +0x578
main.main()
	_testmain.go:46 +0x80

On sait que c’est elle qui porte le runner du test. On le voit facilement, car il s’agit de la goroutine 1 🫠
Elle est bloquée (chan receive) dans l’ attente la fin du test qui n’arrivera jamais.

👉 Le test échoue donc parce que le runtime coupe le programme sur un deadlock global, pas à cause d’une assertion du test en soi.

Ce qui détecte le deadlock (voir plus bas 👇), c’est le runtime Go lui-même, une couche sous les goroutines : constatant que plus aucune goroutine ne peut avancer, il termine le programme.

  • 1 goroutine (22) bloqué sur un waitgroup.Wait()
goroutine 22 [sync.WaitGroup.Wait]:
sync.runtime_SemacquireWaitGroup(0x104b84250?, 0xd0?)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/runtime/sema.go:114 +0x38
sync.(*WaitGroup).Wait(0x685dde19e220)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/sync/waitgroup.go:206 +0xa8
imagino/concurrency.TestCheckProgress(0x685dde1fc248)
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:78 +0x150
testing.tRunner(0x685dde1fc248, 0x104da4aa0)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2036 +0xc4
created by testing.(*T).Run in goroutine 1
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2101 +0x3a8

C’est celle de notre test principal qui attend la fin des workers.

  • 1 goroutine (23) bloqué sur un verrou de lecture sync.RWMutex.RLock
goroutine 23 [sync.RWMutex.RLock]:
sync.runtime_SemacquireRWMutexR(0x685dde124dd8?, 0x1?, 0x685dde124e08?)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/runtime/sema.go:100 +0x28
sync.(*RWMutex).RLock(...)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/sync/rwmutex.go:74
imagino/concurrency.(*Counter).Progression(0x685dde19a940)
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur.go:28 +0x74
imagino/concurrency.TestCheckProgress.func1()
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:46 +0xf0
created by imagino/concurrency.TestCheckProgress in goroutine 22
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:43 +0xc4

C’est une goroutine qui tente de lire la progression d’un Counter, c’est-à-dire notre moniteur (reader) de progression en train de lire la progression.

Ce qui est intéressant ici, c’est de relever 📝 le counter en train d’être lu (0x685dde19a940), cela nous sera utile dans notre analyse 🫠.

  • 1 goroutine (24) bloqué sur l’envoi du channel de progression chan send
goroutine 24 [chan send]:
imagino/concurrency.(*Counter).Inc(0x685dde19a980)
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur.go:23 +0x70
imagino/concurrency.TestCheckProgress.func2()
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:71 +0xc0
created by imagino/concurrency.TestCheckProgress in goroutine 22
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:68 +0xe0

C’est un de nos workers qui est bloqué sur l’envoi dans le channel de la progression du counter chan send. La goroutine travaille sur le counter 0x685dde19a980.

  • 1 goroutine (25) bloqué sur l’envoi du channel de progression chan send
goroutine 25 [chan send]:
imagino/concurrency.(*Counter).Inc(0x685dde19aa00)
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur.go:23 +0x70
imagino/concurrency.TestCheckProgress.func2()
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:71 +0xc0
created by imagino/concurrency.TestCheckProgress in goroutine 22
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:68 +0xe0

C’est un de nos workers qui est bloqué sur l’envoi dans le channel de la progression du counter chan send. La goroutine travaille sur le counter 0x685dde19aa00.

  • 1 goroutine (26) bloqué sur l’envoi du channel de progression chan send
goroutine 26 [chan send]:
imagino/concurrency.(*Counter).Inc(0x685dde284000)
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur.go:23 +0x70
imagino/concurrency.TestCheckProgress.func2()
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:71 +0xc0
created by imagino/concurrency.TestCheckProgress in goroutine 22
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:68 +0xe0

C’est un de nos workers qui est bloqué sur l’envoi dans le channel de la progression du counter chan send. La goroutine travaille sur le counter 0x685dde284000.

  • 1 goroutine (27) bloqué sur l’envoi du channel de progression chan send
goroutine 27 [chan send]:
imagino/concurrency.(*Counter).Inc(0x685dde19a9c0)
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur.go:23 +0x70
imagino/concurrency.TestCheckProgress.func2()
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:71 +0xc0
created by imagino/concurrency.TestCheckProgress in goroutine 22
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:68 +0xe0

C’est un de nos workers qui est bloqué sur l’envoi dans le channel de la progression du counter chan send. La goroutine travaille sur le counter 0x685dde19a9c0.

  • 1 goroutine (28) bloqué sur l’envoi du channel de progression chan send
goroutine 28 [chan send]:
imagino/concurrency.(*Counter).Inc(0x685dde19a940)
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur.go:23 +0x70
imagino/concurrency.TestCheckProgress.func2()
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:71 +0xc0
created by imagino/concurrency.TestCheckProgress in goroutine 22
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:68 +0xe0

C’est un de nos workers qui est bloqué sur l’envoi dans le channel de la progression du counter chan send. La goroutine travaille sur le counter 0x685dde19a940.

Voilà le résumé de l’état de nos goroutines :

N° Goroutine Opération de blocage Type Counter
goroutine 1 chan receive (attend la fin du test) lanceur de test aucun
goroutine 22 wg.Wait() le test TestCheckProgress aucun
goroutine 23 Progression()RLock() reader 0x685dde19a940
goroutine 24 Inc()progressChannel <- c worker 0x685dde19a980
goroutine 25 Inc()progressChannel <- c worker 0x685dde19aa00
goroutine 26 Inc()progressChannel <- c worker 0x685dde284000
goroutine 27 Inc()progressChannel <- c worker 0x685dde19a9c0
goroutine 28 Inc()progressChannel <- c worker 0x685dde19a940

C’est ici que le deadlock survient clairement : la goroutine 28 essaie d’envoyer la progression du counter dans le channel (chan send), pendant que la goroutine 23 tente de lire la valeur qu’il vient justement de recevoir de ce channel.

👉 Les deux goroutines se retrouvent mutuellement coincées :

  • le lecteur sur RWMutex.RLock() (bloqué par le RWMutex.Lock() que le worker tient encore),
  • le worker sur chan send (bloqué parce que personne ne reçoit sa valeur suivante, le lecteur étant justement figé sur son RLock()).

💥Plus personne ne bouge, tout est figé et là Go le détecte intelligemment en faisant exploser le test 💥

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:                     // le lanceur de test, il attend la fin du test
testing.(*T).Run(...)

goroutine 21 [sync.WaitGroup.Wait]:             // le corps du test, sur wg.Wait()
imagino/concurrency.TestCheckProgress(...)
    concur_test.go:78

goroutine 22 [sync.RWMutex.RLock]:              // le lecteur, coincé sur RLock()
imagino/concurrency.(*Counter).Progression(...)
    concur.go:28

goroutine 23 [chan send]:                       // un worker (× 5), coincé sur l'envoi channel
imagino/concurrency.(*Counter).Inc(...)
    concur.go:23

Ni échec d’assertion, ni timeout de test : c’est le runtime Go qui détecte tout seul le deadlock. Quand toutes les goroutines sont endormies et qu’aucune ne pourra jamais réveiller les autres, il le constate et abandonne sur fatal error: all goroutines are asleep - deadlock!.
La trace, elle, ne ment pas :

GoroutineÉtatBloquée sur
Lecteur (func1)sync.RWMutex.RLockCounter.Progression()RLock()
Workers (func2)chan sendCounter.Inc()progressChannel <- c
Mainsync.WaitGroup.Waitwg.Wait()

Tout le monde s’attend, en cercle. Déroulons la mécanique, parce qu’elle est belle. 🎭

🔒 L'anatomie exacte du deadlock

  1. Un worker entre dans Inc() : il prend le verrou en écriture (Lock()), incrémente, puis tente progressChannel <- c. Le channel est non bufferisé : l'envoi bloque tant que le lecteur n'a pas reçu. Le worker attend donc, le verrou toujours en main.
  2. Le lecteur reçoit le pointeur c. Pour l'exploiter, il appelle p.Progression(), qui fait… RLock() sur ce même compteur.
  3. Or le verrou en lecture ne peut être pris que si personne ne tient le verrou en écriture. Après le premier rendez-vous, le worker a repris la main, rebouclé, et il est déjà reparti dans Inc() suivant : Lock() repris, bloqué sur le prochain envoi.
  4. Cercle fermé : le lecteur attend que le worker relâche le verrou pour lire la valeur ; le worker attend que le lecteur reçoive du channel pour relâcher le verrou. Aucun des deux ne cédera. 💀

🧭 « Go détecte donc les deadlocks tout seul ? » Oui, mais seulement les globaux

Le runtime Go embarque un détecteur de deadlock. Dès que toutes les goroutines sont bloquées et qu'aucun timer ni I/O réseau ne pourra en réveiller une, il tue le programme avec fatal error: all goroutines are asleep - deadlock!.
C'est lui qu'on vient de voir à l'œuvre. Côté source, c'est la fonction checkdead() du scheduler qui, ne trouvant plus aucune goroutine à réveiller, appelle fatal("all goroutines are asleep - deadlock!").

Deux limites à garder en tête :

  • Il ne voit que le deadlock total.
    Si une seule goroutine tourne encore pendant que les autres sont bloquées (deadlock partiel, livelock), le runtime se tait. Là, c'est -timeout qui te sauve.
  • Sous go test, le timeout par défaut le cache.
    Ce timeout (10 min) est un timer en attente : le runtime ne se déclenche donc pas, et tu patientes jusqu'au panic: test timed out.

Remarque: dans mon cas, j’ai lancé mes tests au sein de mon IDE, qui désarme le watchdog pour ne pas tuer les breakpoints.
Si on lance directement dans un terminal:

go test -run TestCheckProgress .          # timeout = 10m par défaut
go test -timeout 5s -run TestCheckProgress .
go test -timeout 0 -run TestCheckProgress . # désactivation, le runtime prends la main pour détecter le problème.

Le coupable, ce n’est pas une ligne, c’est la conjonction de deux mauvaises idées :

  1. Passer le *Counter partagé dans le channel.
    En publiant un pointeur vers de l’état mutable et verrouillé, on force le consommateur à reprendre le verrou du producteur.
    Le channel, censé découpler producteur et consommateur, les recouple par le verrou.
  2. Tenir le mutex pendant l’envoi sur le channel.
    Un envoi sur channel non bufferisé est un point de blocage arbitrairement long.
    Tenir un verrou pendant ce temps, c’est offrir sa clé à un inconnu et attendre qu’il vous la rende.

📐 Envoyer un pointeur mutable dans un channel : le bug de la « valeur qui bouge »

Même sans deadlock, progressChannel <- c est fautif.
Le lecteur ne lit pas la valeur au moment de l'envoi, mais au moment où il consomme, potentiellement bien plus tard.
Entre les deux, le worker a continué d'incrémenter. Le lecteur pourrait donc voir value = 7 là où on avait publié 3, rater des paliers et en compter d'autres deux fois. Le channel doit transporter un instantané immuable, jamais une référence vers un état vivant. 📸

Le fix « old fashion » : envoyer une copie, pas le pointeur

Pas besoin d’un nouveau type.
Le réflexe qu’on attend d’un candidat en entretien est plus direct : déréférencer le compteur et envoyer une copie de sa valeur dans le channel. Un caractère change, c devient *c :

func (c *Counter) Inc() {
    c.lck.Lock()
    defer c.lck.Unlock()
    c.value++
    c.progressChannel <- *c   // ⬅️ *c : une COPIE du Counter, pas le pointeur
}

*c copie le Counter champ par champ à l’instant de l’envoi.
Le lecteur reçoit un instantané figé : il lit ce value-là autant qu’il veut, pendant que le worker d’origine continue d’incrémenter le sien dans son coin. Plus d’état partagé entre producteur et consommateur, donc plus de reprise de verrou, donc plus de deadlock. 🧼

Sauf que… ça ne compile pas encore. 🤔

🔌 Le rappel de l'exo Interfaces : receiver valeur vs pointeur

Avec les getters en receiver pointeur (func (c *Counter) JobId()), une valeur Counter n'implémente pas JobProgress, seul *Counter le fait. Le compilateur refuse net :

cannot use *c (variable of struct type Counter) as JobProgress value in send:
    Counter does not implement JobProgress (method JobId has pointer receiver)

C'est très exactement le piège du method set de l'exo Interfaces, pris par l'autre bout : pour qu'une copie valeur satisfasse l'interface, il faut basculer les getters en receiver valeur.

On passe donc JobId et Progression sur un receiver valeur. Inc, lui, doit rester sur pointeur puisqu’il mute le compteur :

func (c *Counter) Inc() { /* ... */ c.progressChannel <- *c }

func (c Counter) Progression() int { return c.value }      // ⬅️ receiver VALEUR
func (c Counter) JobId() string    { return c.identifier } // ⬅️ receiver VALEUR

Là, ça compile, et le deadlock a disparu. Le Counter sert de DTO à lui-même : on le publie en copie, le lecteur en fait ce qu’il veut, sans une seule ligne de type en plus. C’est le « truc » idiomatique en entretien. 🎯

Et le mutex, maintenant ? go vet tranche

Le deadlock est parti, mais un go vet refroidit aussitôt l’enthousiasme :

concur.go: Progression passes lock by value: Counter contains sync.RWMutex
concur.go: JobId passes lock by value: Counter contains sync.RWMutex

*c copie tout le Counter… y compris son sync.RWMutex.
Or copier un verrou est un bug : la copie et l’original ont chacun leur propre état de verrouillage, et le check copylocks de vet a parfaitement raison de hurler.
Chaque envoi sur le channel, chaque appel de getter sur la copie duplique un mutex. 🙈

💡 Cette version intermédiaire, *c avec le mutex encore là, est visible à ce commit : lance un go vet ./... dessus pour voir le copylocks tomber.

La bonne nouvelle : ce mutex ne sert déjà plus à rien. Inventaire des accès à c.value :

  • Inc() (écriture) : uniquement la goroutine du worker qui possède ce compteur. Un seul écrivain.
  • Progression() (lecture) : dans le code d’origine, le lecteur l’appelait via le pointeur reçu. C’était le seul accès venu d’ailleurs, et on vient de le supprimer en passant à la copie.

Chaque Counter n’est donc plus manipulé que par sa goroutine : plus aucun accès concurrent à protéger.
Le mutex est du code mort, et en prime il fâche vet. On le retire :

// Plus de mutex : un Counter n'est touché que par sa propre goroutine
type Counter struct {
    identifier      string
    value           int
    progressChannel chan<- JobProgress
}

func (c *Counter) Inc() {
    c.value++
    c.progressChannel <- *c
}
Deadlock ✓ Lecteur non attendu Variable de boucle -race + parallel

🐛 Bug 2 : La goroutine lecteur qu’on n’attend jamais

Le deadlock levé, on relance. Et là, ce n’est plus un timeout, c’est un échec… déroutant :

--- FAIL: TestCheckProgress (0.00s)
    concur_test.go: Reader tests not performed

Reader tests not performed, alors même que les workers ont tourné et que le lecteur a bien reçu les valeurs.
Le test échoue en 0.00s, sans jamais entrer dans les vraies vérifications.

Que s’est-il passé ? 🤔

Le lecteur est lancé en go func() { … }(), une goroutine détachée que personne n’attend :

go func() { /* lecteur : ... readerTestSuccess = true */ }()
// ... workers ...
wg.Wait()              // attend les WORKERS, pas le lecteur
close(progress)        // le lecteur va pouvoir finir sa boucle for range...
if !readerTestSuccess { // ...mais on lit la variable AVANT qu'il l'ait mise à true
    t.Errorf("Reader tests not performed")
}

wg.Wait() n’attend que les workers. Une fois close(progress) appelé, le lecteur sort de son for range, exécute ses assertions, puis met readerTestSuccess = true. Mais tout ça prend un peu de temps, et le main, lui, fonce lire readerTestSuccess dans la foulée. La course est perdue d’avance : le main lit false avant que le lecteur ait fini. Le test se déclare raté sans avoir rien vérifié.

⚠️ Deux bombes, pas une

1. La lecture prématurée.
Rien ne synchronise le main avec la fin du lecteur. On lit un résultat qui n'est pas encore écrit.

2. Le t.Errorf depuis une goroutine survivant au test.
Bien pire, et sournois : appeler t.Errorf (ou t.Fatal) depuis une goroutine après le retour de la fonction de test est un usage interdit par testing.
Au mieux le message est perdu, au pire ça panique dans un autre test. Un vrai échec du lecteur passerait donc totalement inaperçu.

Le fix : donner au lecteur son propre WaitGroup

Il faut attendre que le lecteur ait fini avant de conclure.
La façon classique, celle qu’on écrit d’instinct, lui donne son propre sync.WaitGroup, distinct de celui des workers :

readerTestSuccess := false
readerWg := sync.WaitGroup{}
readerWg.Add(1)
go func() {
    defer readerWg.Done()
    result := JobTracker{}
    for p := range progress {
        result.RegisterProgression(p.JobId(), p.Progression())
    }
    // ... les assertions ...
    readerTestSuccess = true
}()

// ... workers (avec leur propre workerWg) ...
wg.Wait()   // tous les producteurs ont fini
close(progress)   // le lecteur sort de son for range
readerWg.Wait()   // ⬅️ on attend VRAIMENT le lecteur
if !readerTestSuccess {
    t.Errorf("Reader tests not performed")
}

Deux WaitGroup, deux rôles :

  • wg synchronise les producteurs,
  • readerWg le consommateur.

L’ordre devient déterministe :
wg.Wait()close(progress) → le lecteur draine et pose son drapeau → readerWg.Wait() débloque → puis seulement le main lit readerTestSuccess. Un ordre garanti par une barrière, plus par la chance. 🚧

🆕 En Go 1.25, WaitGroup.Go raccourcit le rituel

Le duo wg.Add(1) + go func(){ defer wg.Done(); … }() est le grand classique, mais il laisse deux occasions de se tromper : un Add oublié, un Done qui saute sur un chemin de retour. Go 1.25 propose wg.Go(f), qui fait les trois d'un coup (Add, lancement, et Done même en cas de panic) :

readerWg.Go(func() {
    // ... le lecteur ...
})

Rien d'obligatoire : la version Add/Done reste parfaitement idiomatique, et c'est celle qu'on écrit encore partout. Mais quand elle est disponible, Go supprime une classe entière d'erreurs de compteur.

[Voir source code]

Optionnel, mais recommandé pour la lisibilité : wg.Add(1) dans la boucle plutôt que wg.Add(worker) en amont

Ce point n’est pas un bug (le test passe déjà) : c’est un réflexe de lisibilité et de robustesse, à prendre ou à laisser. Le squelette annonçait les cinq workers d’un coup, avant la boucle :

wg := sync.WaitGroup{}
wg.Add(worker)          // ⬅️ les 5 d'un bloc, loin des goroutines
// ...
for i < worker {
    i++
    go func() {
        // ...
        wg.Done()
    }()
}

Soyons honnête : ici, ça marche. La boucle lance exactement worker goroutines, chacune fait un wg.Done(), le compteur retombe à zéro, wg.Wait() débloque. Rien à voir avec les trois vrais bugs de l’exercice. C’est un confort de lecture doublé d’un filet de robustesse, et il vaut le détour.

L’idiome Go, c’est wg.Add(1) dans la boucle, juste avant le go :

wg := sync.WaitGroup{}
// ...
for i < worker {
    i++
    wg.Add(1)           // ⬅️ un +1 collé à la goroutine qu'il comptabilise
    go func() {
        // ...
        wg.Done()
    }()
}

Pourquoi ce déplacement d’une ligne compte :

  • La localité. Le Add(1) est collé au go qu’il comptabilise. Une seule ligne à lire pour savoir « cette goroutine est bien attendue », au lieu de vérifier qu’un Add(worker) lointain colle toujours au nombre réel de go.
  • La résistance au refactoring. Le jour où la boucle gagne un continue (on saute un worker), une condition, ou un break, le wg.Add(worker) du haut ment aussitôt. S’il sur-compte, le compteur ne retombe jamais à zéro et wg.Wait() bloque pour toujours (un deadlock, tiens donc). S’il sous-compte, Wait() rend la main trop tôt, pendant que des goroutines tournent encore : course de données garantie. Avec Add(1) au plus près du go, le compteur suit exactement les goroutines réellement lancées, quoi qu’on trafique dans la boucle.

📐 La règle, et sa seule contrainte

La doc de sync.WaitGroup impose une seule chose : un Add avec un delta positif doit s'exécuter avant le Wait correspondant. Un Add(1) juste avant le go, dans la goroutine parent (jamais dans la goroutine lancée, où il courrait avec le Wait), respecte ça naturellement. Et avec Go 1.25, wg.Go(func(){ … }) fait le Add pour toi : la question ne se pose même plus.

Deadlock ✓ Lecteur non attendu ✓ Variable de boucle -race + parallel

🐛 Bug 3 : La variable de boucle capturée

Le lecteur est maintenant attendu, mais le test reste rouge de façon intermittente :

=== RUN   TestCheckProgress
    concur_test.go:59: Invalid count for progression value 1 step (expected 5 - got 1)
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
testing.(*T).Run(0x6f5c44f2008, {0x1004a23da?, 0x23c30f500045b1f0?}, 0x1005f8ac0)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2109 +0x3bc
testing.runTests.func1(0x6f5c44f2008)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2585 +0x40
testing.tRunner(0x6f5c44f2008, 0x6f5c44e5c48)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2036 +0xc4
testing.runTests({0x1004a2db9, 0x13}, {0x1004a2db9, 0x13}, 0x6f5c4494060, {0x100619150, 0x1, 0x1}, {0x8080808030284852?, 0x1004a1b6c?, ...})
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2583 +0x3f0
testing.(*M).Run(0x6f5c44921e0)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2443 +0x578
main.main()
	_testmain.go:46 +0x80

goroutine 19 [sync.WaitGroup.Wait]:
sync.runtime_SemacquireWaitGroup(0x6f5c4484298?, 0xa0?)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/runtime/sema.go:114 +0x38
sync.(*WaitGroup).Wait(0x6f5c4486240)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/sync/waitgroup.go:206 +0xa8
imagino/concurrency.TestCheckProgress(0x6f5c44f2248)
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:83 +0x180
testing.tRunner(0x6f5c44f2248, 0x1005f8ac0)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2036 +0xc4
created by testing.(*T).Run in goroutine 1
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2101 +0x3a8
	
Process finished with the exit code 1

La sortie est clair : le test reste bloqué sur le reader (goroutine 19 [sync.WaitGroup.Wait]) car on s’attend à 5 workers mais on n’en compte qu’ un seul.

Traduction : au lieu de 5 identifiants distincts (Worker 1Worker 5), le tracker n’en voit qu’un.
Les workers se marchent dessus sur leur propre nom. 🙈

Regarde la boucle de création :

i := 0
for i < worker {
    i++
    go func() {
        c := NewProgressCounter(fmt.Sprintf(JOBID_FORMAT, i), progress) // ⬅️ quel i ?
        for i := 0; i < countTo; i++ {
            c.Inc()
        }
        wg.Done()
    }()
}

La closure capture i par référence, et i est une variable unique, partagée par les 5 goroutines et mutée par la boucle.
Quand les goroutines démarrent réellement (un peu plus tard), la boucle a souvent déjà avancé, voire fini.
Plusieurs workers lisent alors la même valeur de i et se baptisent tous Worker 5.
Des jobId en double, donc moins de 5 entrées distinctes par palier.

C’est aussi une course de données en bonne et due forme, que -race pointe sans hésiter :

WARNING: DATA RACE
Read at 0x... by goroutine 9:
    concur_test.go:69   // lecture de i dans la closure
Previous write at 0x... by goroutine 7:
    concur_test.go:67   // i++ dans la boucle du main

🕳️ « Depuis Go 1.22, la variable de boucle est corrigée, non ? » (pas ici)

Go 1.22 a bien réglé le piège historique, mais uniquement pour les boucles for … range et for i := 0; …, où la variable est redéclarée à chaque itération. Ici, la boucle est un for i < worker avec i déclaré au-dessus de la boucle : c'est une variable de portée fonction, tout ce qu'il y a de plus partagé. Le correctif de 1.22 ne s'y applique pas. Le tell d'ancienneté du Winery (« tt := tt ne sert plus ») ne vaut que pour la variable de boucle, pas pour une variable capturée qui vit à côté. 😉

Vérifions cela en rajoutant une petite sortie dans la fonction de progression pour voir ce que le consommateur reçoit:


func (jt JobTracker) RegisterProgression(jobId string, progress int) {
	track, exists := jt[progress]
	if !exists {
		jt[progress] = make(map[string]struct{})
		track = jt[progress]
	}
	track[jobId] = struct{}{}
	fmt.Println("Job", jobId, "reached progress", progress)
}

On obtient bien ce que l’on pensait:

GOROOT=/opt/homebrew/Cellar/go/1.26.5/libexec #gosetup
GOPATH=/Users/pcavezzan/go #gosetup
/opt/homebrew/Cellar/go/1.26.5/libexec/bin/go test -c -o /Users/pcavezzan/Library/Caches/JetBrains/GoLand2026.1/tmp/GoLand/___TestCheckProgress_in_imagino_concurrency.test imagino/concurrency #gosetup
/opt/homebrew/Cellar/go/1.26.5/libexec/bin/go tool test2json -t /Users/pcavezzan/Library/Caches/JetBrains/GoLand2026.1/tmp/GoLand/___TestCheckProgress_in_imagino_concurrency.test -test.v=test2json -test.paniconexit0 -test.run ^\QTestCheckProgress\E$ #gosetup
=== RUN   TestCheckProgress
Job Worker 5 reached progress 1
Job Worker 5 reached progress 1
Job Worker 5 reached progress 1
...
Job Worker 5 reached progress 5
Job Worker 5 reached progress 2
Job Worker 5 reached progress 2
Job Worker 5 reached progress 2
...
Job Worker 5 reached progress 98
Job Worker 5 reached progress 99
Job Worker 5 reached progress 100
Job Worker 5 reached progress 98
Job Worker 5 reached progress 99
Job Worker 5 reached progress 100
    concur_test.go:60: Invalid count for progression value 1 step (expected 5 - got 1)
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
testing.(*T).Run(0x2a720acb2008, {0x1003024cd?, 0xcdb13a0002bb1f0?}, 0x100458ac0)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2109 +0x3bc
testing.runTests.func1(0x2a720acb2008)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2585 +0x40
testing.tRunner(0x2a720acb2008, 0x2a720aca7c48)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2036 +0xc4
testing.runTests({0x100302eac, 0x13}, {0x100302eac, 0x13}, 0x2a720ab50078, {0x100479150, 0x1, 0x1}, {0x808080450f291205?, 0x100301c4f?, ...})
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2583 +0x3f0
testing.(*M).Run(0x2a720abea1e0)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2443 +0x578
main.main()
	_testmain.go:46 +0x80

goroutine 4 [sync.WaitGroup.Wait]:
sync.runtime_SemacquireWaitGroup(0x2a720abe0298?, 0xa0?)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/runtime/sema.go:114 +0x38
sync.(*WaitGroup).Wait(0x2a720ab523b0)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/sync/waitgroup.go:206 +0xa8
imagino/concurrency.TestCheckProgress(0x2a720acb2248)
	/Users/pcavezzan/Sources/ctw/2026-july-technical-interview-exercices/go/concurrency/concur_test.go:84 +0x180
testing.tRunner(0x2a720acb2248, 0x100458ac0)
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2036 +0xc4
created by testing.(*T).Run in goroutine 1
	/opt/homebrew/Cellar/go/1.26.5/libexec/src/testing/testing.go:2101 +0x3a8


Process finished with the exit code 1

Le fix : passer i en paramètre

On rompt le partage en passant i comme argument de la goroutine. Chaque worker reçoit alors sa propre copie, figée à l’instant du lancement :

i := 0
for i < worker {
    workerWg.Add(1)
    go func(i int) {   // ⬅️ i devient un paramètre : une copie par goroutine
        c := NewProgressCounter(fmt.Sprintf(JOBID_FORMAT, i), progress)
        for j := 0; j < countTo; j++ {
            c.Inc()
        }
        workerWg.Done()
    }(i)
    i++
}

✅ Cinq copies indépendantes, cinq identifiants distincts, et le compte par palier retombe sur 5.

=== RUN   TestCheckProgress
Job Worker 5 reached progress 1
Job Worker 2 reached progress 1
Job Worker 4 reached progress 1
Job Worker 5 reached progress 2
...
Job Worker 1 reached progress 13
Job Worker 1 reached progress 14
Job Worker 3 reached progress 7
Job Worker 5 reached progress 17
Job Worker 4 reached progress 8
...
Job Worker 4 reached progress 98
Job Worker 4 reached progress 99
Job Worker 4 reached progress 100
--- PASS: TestCheckProgress (0.00s)
PASS

Process finished with the exit code 0
Deadlock ✓ Lecteur non attendu ✓ Variable de boucle ✓ -race + parallel

🔬 On ne devine pas : on mesure sous -race

Affirmer « il n'y a plus de course » sans preuve serait exactement le raisonnement qui a produit le bug. Le juge de paix, c'est le détecteur de course, en boucle pour lisser l'aléa d'ordonnancement :

go test -race -count=20 -run TestCheckProgress .
ok      imagino/concurrency     1.275s
# ok  imagino/concurrency  (20 exécutions, zéro course)

Vingt exécutions sous -race, aucune alerte : le compteur est bien confiné à sa goroutine.
« Ça a l'air sûr » ne vaut rien ; « -race reste vert sur 20 runs » 👉 c'est un argument.

🥊 Ça marche, mais la garderait-on en prod ?

Soyons honnête sur ce qu’on vient de faire.

Le test passe, -race est vert, vet est content, et c’est très probablement la solution qu’on attend d’un candidat en entretien : directe, sans sur-ingénierie, elle prouve qu’on a vu le vrai bug (partage d’état mutable) et qu’on sait le tuer avec une copie valeur.
Mais elle traîne deux réserves qu’un relecteur expérimenté relèvera. 🧐

1. On copie une struct comportementale comme si c’était un DTO. Un Counter, ce n’est pas un sac de données : c’est une stratégie, avec un comportement (Inc) et un canal de sortie. Le faire transiter en copie dans un channel, c’est traiter un objet-comportement comme une donnée inerte. Ça marche parce que le lecteur ne se sert que de deux getters, mais on transporte au passage un progressChannel qui n’a rien à faire dans la charge utile. On envoie l’usine pour livrer un colis. 📦

2. On mixe receiver pointeur et receiver valeur. Inc est sur pointeur, les getters sur valeur. Or la convention Go, celle-là même qu’on rappelait en clôture de l’exo Interfaces, dit de ne pas mélanger les deux sur un même type. Ici, on y est contraint : Inc doit muter (pointeur), la copie doit satisfaire l’interface (valeur).
Ce n’est pas un choix de design, c’est une contrainte qu’on subit, le signe qu’on force un type dans un rôle qui n’est pas le sien.

✨ La version la plus découplée : un DTO dédié

Le remède répond aux deux réserves d'un coup : un petit type immuable, dédié au transport, distinct du Counter. Le compteur garde ses receivers pointeur et son comportement ; la progression, elle, voyage sous forme de donnée pure.

type Progression struct {
    identifier string
    value      int
}

func (p Progression) JobId() string    { return p.identifier }
func (p Progression) Progression() int  { return p.value }

func (c *Counter) Inc() {
    c.value++
    c.progressChannel <- Progression{identifier: c.identifier, value: c.value}
}

Plus de receivers mixés (les getters vivent sur Progression, pas sur Counter), plus de channel parasite dans la charge utile, et l'intention devient explicite : ce qui circule, c'est une progression, pas un compteur. Le prix à payer : un type de quatre lignes. Pour un découplage franc, c'est donné.

⚖️ Le vrai verdict

Les deux solutions passent, -race compris.
En entretien, la copie *c est un excellent réflexe : elle va droit au but et montre qu'on maîtrise copies de valeur et method sets.
En code de prod qui vit, le DTO découplé vieillit mieux : le jour où le Counter gagne un champ (une horloge, un pool de connexions), on ne veut surtout pas qu'il parte en copie dans un channel.
Le contexte décide, mais savoir ce que la version rapide coûte vaut mieux que de la croire parfaite.

🧪 Une meilleure façon d’écrire ce test

On a réparé le test à l’identique de l’original. Mais un test de concurrence qui déraille de façon intermittente (vert 9 fois sur 10) n’inspire pas confiance. La bonne question à se poser : comment forcer les bugs de concurrence à se montrer ? Trois leviers, tous natifs.

1. -race n’est pas optionnel, c’est l’oracle

Sur du code concurrent, -race mérite mieux qu’un lancement occasionnel : c’est l’outil qui transforme un bug silencieux en échec reproductible. Les bugs 1 et 3 étaient invisibles à la relecture ; -race en a démasqué un directement, et le second est un deadlock que -timeout capture. La règle d’or : tout test qui lance une goroutine tourne sous -race en CI.

go test -race ./...

📐 -race ne prouve pas l'absence de bug, il en trouve

-race n'instrumente que les accès réellement exécutés pendant le run. Une course sur un chemin non emprunté passe inaperçue. D'où l'intérêt de le coupler à -count et à des données variées : plus on explore d'entrelacements, plus on a de chances de tomber sur le mauvais. Zéro alerte sur 1 run ne prouve rien ; zéro alerte sur 50 runs avec des tailles variables, c'est déjà un vrai filet.

2. -count et -timeout : débusquer l’intermittence

Un bug de concurrence dépend de l’ordonnancement. Une exécution unique est un coup de dé. -count rejoue le test un grand nombre de fois pour multiplier les entrelacements ; -timeout borne l’attente pour qu’un blocage échoue vite au lieu de figer la CI. Le détecteur natif du runtime ne rattrape que les deadlocks globaux (voir Bug 1) : pour tout ce qui bloque partiellement, c’est -timeout qui tranche.

# rejoue 100 fois, échoue vite en cas de blocage
go test -race -count=100 -timeout 30s -run TestCheckProgress .

C’est exactement ce protocole qui a rendu le bug 3 (variable de boucle) systématiquement visible : en une exécution il apparaissait une fois sur cinq ; en -count=100, il tombait à tous les coups.

3. t.Parallel() + table-driven : plusieurs configurations, sous pression

Enfin, on peut faire ce que le Winery faisait déjà, mais au service de la concurrence : décliner le test en plusieurs tailles (peu de workers, beaucoup ; compteur court, long) et lancer ces variantes en parallèle avec t.Parallel(). Le parallélisme met le planificateur sous tension et augmente mécaniquement le nombre d’entrelacements explorés à chaque go test.

func TestCheckProgress(t *testing.T) {
    t.Parallel()

    cases := []struct {
        name    string
        worker  int
        countTo int
    }{
        {name: "5 workers / 100 pas", worker: 5, countTo: 100},
        {name: "1 worker / 1000 pas", worker: 1, countTo: 1000},
        {name: "50 workers / 20 pas", worker: 50, countTo: 20},
    }

    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()

            progress := make(chan JobProgress)
            wg := sync.WaitGroup{}

            var readerErr error
            tracker := sync.WaitGroup{}
            tracker.Go(func() {
                result := JobTracker{}
                for p := range progress {
                    result.RegisterProgression(p.JobId(), p.Progression())
                }
                readerErr = checkResult(result, tc.worker, tc.countTo)
            })

            for i := 1; i <= tc.worker; i++ {
                wg.Go(func() {
                    c := NewProgressCounter(fmt.Sprintf(JOBID_FORMAT, i), progress)
                    for j := 0; j < tc.countTo; j++ {
                        c.Inc()
                    }
                })
            }

            wg.Wait()
            close(progress)
            tracker.Wait()

            require.NoError(t, readerErr)
        })
    }
}

Exemple sur gihthub

🎯 Ce que cette réécriture change

  • Le lecteur renvoie une error, il n'appelle plus t.Errorf. Fini l'usage interdit de *testing.T depuis une goroutine détachée : la vérification remonte une valeur, et c'est le corps du sous-test (la bonne goroutine) qui tranche via require.NoError. C'est la correction propre du bug 2, généralisée.
  • wg.Go(func(){…}) partout supprime les Add/Done manuels, donc une classe entière d'erreurs de compteur. Et le i de la boucle for i := 1; i <= … est propre à chaque itération (Go 1.22+), plus besoin de le passer en paramètre.
  • Trois configurations, en parallèle. La variante « 50 workers / 20 pas » stresse la contention bien plus que l'originale ; « 1 worker / 1000 pas » vérifie qu'un seul producteur remplit tout. Chaque go test -race couvre désormais trois régimes d'un coup.
Deadlock ✓ Lecteur non attendu ✓ Variable de boucle ✓ -race + parallel ✓

✅ Résultat

Quatre pièges, et un fil rouge : en concurrence, le compilateur ne t’aide plus, et le runtime échoue en silence ou se fige.
Aucun de ces bugs n’est signalé par go build, la plupart passent même un go vet.

  • ⚙️ Bug 1, le deadlock : tenir un mutex pendant un envoi sur channel, et publier un pointeur mutable partagé. Le channel recouple ce qu’il devait découpler. Fix « old fashion » : envoyer *c, une copie valeur du compteur, avec des getters en receiver valeur, puis retirer le mutex, que go vet dénonce (copylocks) et qui ne protégeait plus rien.
  • 🕰️ Bug 2, le lecteur non attendu : une goroutine détachée dont on lit le résultat avant qu’elle ait fini, et qui appelle t.Errorf hors délai. Fix : lui donner son propre WaitGroup (readerWg.Add(1) / Done / Wait), attendu avant de conclure.
  • 🔁 Bug 3, la variable de boucle : un i de portée fonction capturé par référence, hors du champ du correctif Go 1.22. Fix : le passer en paramètre.
  • 🥊 Le débat : la copie *c est le réflexe attendu en entretien, mais elle copie une struct comportementale et mixe les receivers. La version propre en prod, c’est un DTO découplé.

Mais la vraie leçon de cet exercice est méthodologique 👇 .
Sur du séquentiel, on relit et on raisonne. Sur du concurrent, l’œil ne suffit pas : le runtime hurle tout seul sur un deadlock global (fatal error: all goroutines are asleep), et pour le reste ce sont -race, -count, -timeout et t.Parallel() qui transforment un bug fuyant en échec reproductible, sans oublier go vet qui attrape la copie de verrou au passage.

Le meilleur test de concurrence n’est pas celui qui passe, c’est celui qui force les bugs à se montrer. 🍇

Merci de m’avoir suivi sur cette journée d’entretien Go.

À bientôt ! 🔧