# Goroutines bloquées pour toujours : le ramasse-miettes de Go 1.27 les repère

> Le profil goroutineleak de Go 1.27 se sert du ramasse-miettes pour trouver les goroutines bloquées sans retour possible. Quand il accuse, il a raison. Mais il se tait souvent : voici où, tests à l'appui.


*Ou : Comment le ramasse-miettes s'est mis à compter les gens qui attendent un appel que plus personne ne peut passer*

Une fuite de goroutines ne fait aucun bruit. Pas de panique, pas d'erreur dans les journaux : une goroutine attend une valeur sur un canal que plus personne ne lira, et elle attendra jusqu'au prochain redémarrage, avec sa pile et tout ce qu'elle référence. Multiplie par le nombre de requêtes abandonnées dans la journée, et la mémoire du service grimpe sans raison apparente. Le profil `goroutine` classique montrait déjà ces goroutines, mais noyées parmi celles qui attendent légitimement, sans dire laquelle ne se réveillerait jamais. Go 1.27, sorti le 19 août 2026, rend disponible pour tous un profil qui fait précisément ce tri, `goroutineleak`[^1]. Il s'appuie sur le ramasse-miettes, ne se trompe pas quand il accuse, et se tait plus souvent qu'on ne le voudrait.

[L'article sur les méthodes génériques de Go 1.27](/blog/go-1-27-methodes-generiques/) promettait un article à ce profil. Le voici, avec du code exécuté sous Go 1.27.1 et des sorties copiées telles quelles.

## Profil goroutineleak : détecter une fuite de goroutines en Go 1.27
{.subtitle}

### La fuite la plus banale du monde

Voici le genre de fonction qu'on écrit un mardi après-midi : appeler un service lent, mais abandonner si le contexte expire avant la réponse.

```go
// Fetch abandonne si le contexte expire avant la réponse.
func Fetch(ctx context.Context, id int) (string, error) {
	ch := make(chan string) // non bufferisé : l'envoi attend un lecteur
	go func() {
		ch <- slowCall(id) // personne ne lira si Fetch est déjà parti
	}()
	select {
	case r := <-ch:
		return r, nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}
```

Tout est correct, sauf une chose. Le canal n'a pas de tampon : un envoi attend qu'un lecteur se présente. Quand le délai expire, `Fetch` repart par la branche `ctx.Done()`, et le seul lecteur possible s'en va avec elle. La goroutine anonyme termine son appel lent, tente de livrer son résultat, et reste plantée sur cette ligne pour l'éternité. Les notes de Go 1.26 prenaient déjà une fuite de cette famille en exemple, avec un retour anticipé dans une boucle[^2].

### Le standard téléphonique

Imagine un immeuble de bureaux où chaque employé bloqué attend un appel sur sa propre ligne. Pour savoir qui attend en vain, inutile d'interroger les employés : on demande au standard qui, parmi les gens encore actifs, a noté leur numéro. Si personne ne l'a, l'appel ne viendra jamais. Et si celui qui a le numéro attend lui-même un appel, il faut d'abord savoir si lui sera rappelé.

C'est l'algorithme, presque mot pour mot. Le ramasse-miettes part des seules goroutines exécutables, suit les pointeurs, et considère comme « finalement exécutable » toute goroutine bloquée sur un canal ou un verrou qu'il a pu atteindre. Il recommence jusqu'à ce que plus rien ne bouge. Les goroutines restantes attendent une primitive que personne ne peut plus toucher : elles sont déclarées fuyantes. Les notes de version le formulent ainsi : si la primitive P « is unreachable from any runnable goroutine or any goroutine that those could unblock », alors la goroutine « can never wake up »[^1].

L'idée vient de Vlad Saioc et de trois coauteurs, dont Milind Chabbi, d'Uber, qui l'ont publiée à la conférence ASPLOS en 2025. Proposée à l'équipe de Go en juillet 2025, elle a tourné comme expérience dans Go 1.26 derrière `GOEXPERIMENT=goroutineleakprofile`, avant d'être activée par défaut dans Go 1.27[^2]. Elle ne coûte rien tant qu'on ne demande pas le profil : c'est la demande elle-même qui déclenche un cycle spécial du ramasse-miettes.

### Ce que le profil affiche

Trois appels avec un délai de 10 millisecondes, face à un service qui en met 50, puis on demande le profil :

```go
leak := pprof.Lookup("goroutineleak")
fmt.Println("Count avant :", leak.Count())
leak.WriteTo(io.Discard, 0) // déclenche le cycle de détection
fmt.Println("Count après :", leak.Count())
leak.WriteTo(os.Stdout, 1)
```

```text
appel 0 : context deadline exceeded
appel 1 : context deadline exceeded
appel 2 : context deadline exceeded
Count avant : 0
Count après : 3
goroutineleak profile: total 3
3 @ 0x47f80a 0x41485c 0x414457 0x4e3ce9 0x485721
#	0x4e3ce8	example.com/fuites.Fetch.func1+0x48	example.com/fuites/fetch.go:18
```

Trois fuites, une seule pile, et la ligne exacte de l'envoi. Avec `debug=2`, le profil affiche toutes les goroutines au format d'une panique, et les fuyantes portent une mention explicite : `goroutine 7 [chan send (leaked)]`. Le même profil est servi par `net/http/pprof` à l'adresse `/debug/pprof/goroutineleak`, que j'ai interrogée sur un serveur de test. À garder derrière un port interne, comme tout le reste de `/debug/pprof`.

Regarde les deux lignes `Count`. La méthode ne lance pas la détection, elle rend le résultat du dernier cycle. Si tu exportes ce nombre vers ta supervision, appelle `WriteTo` d'abord, sinon ton tableau de bord affichera zéro avec une sérénité parfaite. Pas besoin de le faire souvent : une fuite ne se répare pas toute seule, elle sera encore là au passage suivant. Le billet de l'équipe de Go cite une collecte toutes les quatre heures comme exemple de fréquence en production[^3].

### Ce qu'il ne verra jamais

Le profil ne se trompe pas quand il accuse, parce qu'il se tait dès qu'il a un doute. Pour mesurer ce silence, j'ai écrit un programme avec une goroutine bloquée par cas, puis demandé le profil :

| La goroutine attend… | Signalée ? | Pourquoi |
|---|---|---|
| un envoi sur un canal local abandonné | oui | plus personne n'atteint le canal |
| une lecture sur un canal `nil` | oui | fuite par définition |
| un `sync.WaitGroup` que personne ne termine | oui | le `WaitGroup` est inaccessible |
| un verrou rangé dans une structure | oui | la structure est inaccessible |
| un canal rangé dans une variable globale | non | une globale reste toujours accessible |
| un canal gardé par une goroutine encore active | non | la goroutine active « a le numéro » |
| une lecture réseau qui ne viendra jamais | non | seuls les canaux et le paquet `sync` sont analysés |
| une minuterie d'une heure | non | à raison : elle se réveillera |
| un `sync.Mutex` seul, de 8 octets | non | son bloc mémoire est partagé |

Les deux premières absences sont documentées : une primitive accessible depuis une variable globale ou depuis une goroutine exécutable passe sous le radar[^1]. La troisième aussi : le billet du 2 septembre 2026 exclut les goroutines bloquées « for any other reason, e.g., file and network IO, or direct system calls »[^3]. Une goroutine qui attend la réponse d'un serveur muet, sans délai, fuit autant que la mienne, et le profil ne la verra pas. Comme [le détecteur de courses de `go test -race`](/blog/concurrence-race-conditions-go/), il ne trouve d'ailleurs que ce qui s'est déjà produit pendant l'exécution.

La dernière ligne m'a surpris, et je n'en ai trouvé aucune mention dans la documentation. Un `sync.Mutex` déclaré seul dans une fonction, verrouillé puis abandonné, n'a jamais été signalé dans mes essais. Le même verrou, rangé dans une structure avec une map, l'est dès la première demande. L'explication la plus probable se lit dans le code du runtime : les objets de moins de 16 octets sans pointeur partagent un bloc commun, qui reste vivant tant qu'un seul de ses occupants l'est, et les blocs en cours de remplissage sont marqués d'office à chaque cycle[^4]. Un verrou de 8 octets hérite donc de la vie de ses voisins. Dans du vrai code, un verrou vit presque toujours dans une structure, mais la leçon dépasse ce cas : un profil vide ne prouve pas l'absence de fuite.

### Dans les tests, deux outils plus sévères

Pour les tests, deux outils existaient déjà, et ils sont plus stricts que le profil. `goleak`, la bibliothèque d'Uber, liste à la fin d'un test toutes les goroutines qui ne se sont pas terminées[^5] :

```text
=== RUN   TestLeakWithGoleak
    demo_test.go:17: found unexpected goroutines:
        [Goroutine 20 in state chan send, with example.com/fuites.Fetch.func1 on top of the stack:
        example.com/fuites.Fetch.func1()
        	example.com/fuites/fetch.go:18 +0x49
        created by example.com/fuites.Fetch in goroutine 19
        	example.com/fuites/fetch.go:17 +0x8f
        ]
--- FAIL: TestLeakWithGoleak (0.45s)
```

`testing/synctest`, stable depuis Go 1.25, exécute le test dans une bulle à horloge simulée et panique si une goroutine de la bulle reste bloquée à la fin : `deadlock: main bubble goroutine has exited but blocked goroutines remain`, suivi de la pile de la goroutine fautive, en état `chan send (durable)`. Un piège au passage : l'horloge simulée s'arrête quand la fonction de test se termine. Même la version corrigée échoue si tu ne laisses pas passer le temps avant de sortir, avec un `time.Sleep` qui, dans la bulle, est instantané.

La différence avec le profil tient en un mot : le soupçon. `goleak` et `synctest` signalent toute goroutine encore vivante, perdue ou simplement en retard. Ils peuvent se le permettre, car ils travaillent dans un environnement que tu contrôles. Le profil ne signale que les cas prouvés, ce qui lui permet de tourner en production sans réveiller l'astreinte pour rien. L'équipe de Go conseille de combiner les trois[^3].

La correction, elle, tient en un caractère :

```go
ch := make(chan string, 1) // une place : l'envoi ne bloque jamais
```

Avec une place dans le tampon, la goroutine dépose son résultat et se termine, que quelqu'un le lise ou non, et le ramasse-miettes récupère ensuite le canal. Dans mon dépôt de test, le profil compte bien trois fuites sur l'ancienne version, et `goleak` comme `synctest` acceptent la nouvelle.

**DevOps Dave :** J'ai branché `/debug/pprof/goroutineleak` sur la supervision. Zéro fuite depuis un mois.

**Security Sarah :** Et le nombre total de goroutines ?

**DevOps Dave :** Il monte un peu chaque jour. Mais le profil dit zéro.

**Security Sarah :** Le profil dit qu'il n'a rien pu prouver. Tes goroutines attendent peut-être un serveur qui ne répondra jamais.

Tout le code de cet article a tourné sous Go 1.27.1 sur Linux, avec `go vet`, le détecteur de courses et goleak 1.3.0 ; les chemins des sorties sont raccourcis par `-trimpath`. Le standard téléphonique de Go 1.27 sait désormais dire qui attend un appel que plus personne ne peut passer. Il ne sait pas dire qui attend un appel que quelqu'un pourrait passer, mais ne passera jamais. Pour ceux-là, il reste un délai sur chaque appel réseau, et un test qui échoue avant la mise en production.

---

[^1]: Les [notes de version de Go 1.27](https://go.dev/doc/go1.27), section « Goroutine leak profile » : le profil, « previously available as an experiment in Go 1.26, is now generally available », est fourni par `runtime/pprof` et par `net/http/pprof` à l'adresse `/debug/pprof/goroutineleak`. Sur les limites : « the runtime may fail to identify leaks caused by blocking on concurrency primitives reachable through global variables or the local variables of runnable goroutines. » La [page des versions](https://go.dev/doc/devel/release) date Go 1.27.0 du 19 août 2026 et Go 1.27.1 du 1er septembre 2026.

[^2]: Les [notes de version de Go 1.26](https://go.dev/doc/go1.26) présentaient l'expérience avec un exemple de fuite par retour anticipé, précisaient que la fonction « is also designed to not incur any additional run-time overhead unless it is actively in-use », et annonçaient : « We aim to enable goroutine leak profiles by default in Go 1.27. » La [proposition n° 74609](https://github.com/golang/go/issues/74609), ouverte par Vlad Saioc en juillet 2025, a été close en mai 2026 avec l'activation par défaut. La théorie est exposée dans l'article de Saioc, Lee, Møller et Chabbi, [*Dynamic Partial Deadlock Detection and Recovery via Garbage Collection*](https://cs.au.dk/~amoeller/papers/golf/paper.pdf) (PDF, 16 pages), présenté à ASPLOS 2025, à Rotterdam. Les notes de Go 1.27 remercient « Vlad Saioc at Uber » pour sa contribution. Dans le code de `runtime/pprof`, la fonction qui écrit le profil « first invokes a GC cycle that performs goroutine leak detection ».

[^3]: Le billet [« Goroutine Leak Profiles »](https://go.dev/blog/goroutine-leak-profiles), signé Vlad Saioc le 2 septembre 2026, décrit un mécanisme « precise » qui « generates little-to-no false positives », rappelle que les fuites « can be detected only after they have occurred », propose une collecte périodique, « e.g., every 4 hours », et recommande d'utiliser les profils de fuite « at various layers, up to, and including production », en plus de `goleak` et `synctest` dans les tests.

[^4]: Le commentaire de l'allocateur dans [`runtime/malloc.go`](https://go.googlesource.com/go/+/refs/tags/go1.27.1/src/runtime/malloc.go) : « Tiny allocator combines several tiny allocation requests into a single memory block. The resulting memory block is freed when all subobjects are unreachable. » Le bloc fait 16 octets et n'accueille que des objets sans pointeur ; au démarrage de chaque cycle, la fonction `gcMarkTinyAllocs` de `mgc.go` marque les blocs en cours d'utilisation. Un `sync.Mutex` fait 8 octets. Le lien entre ce mécanisme et la fuite non signalée est mon explication, pas celle de la documentation : mes essais établissent seulement qu'une structure de 8 octets contenant un verrou n'est jamais signalée, et qu'une structure de 16 octets contenant le même verrou l'est.

[^5]: [goleak](https://github.com/uber-go/goleak) se branche avec `defer goleak.VerifyNone(t)` en tête d'un test, ou avec `goleak.VerifyTestMain(m)` pour tout un paquet. Pour `synctest`, la [documentation du paquet](https://pkg.go.dev/testing/synctest) précise : « Time stops advancing when the root goroutine of the bubble exits. » Le paquet est stable depuis [Go 1.25](https://go.dev/doc/go1.25), après une expérience dans Go 1.24.



