Goroutines bloquées pour toujours : le ramasse-miettes de Go 1.27 les repère
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, goroutineleak1. 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 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
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.
| |
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 boucle2.
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.272. 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 :
| |
| |
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 production3.
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 radar1. 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, 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 cycle4. 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ées5 :
| |
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 trois3.
La correction, elle, tient en un caractère :
| |
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.
Les notes de version de Go 1.27, section « Goroutine leak profile » : le profil, « previously available as an experiment in Go 1.26, is now generally available », est fourni par
runtime/pprofet parnet/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 date Go 1.27.0 du 19 août 2026 et Go 1.27.1 du 1er septembre 2026. ↩︎ ↩︎ ↩︎Les notes de version de Go 1.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, 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 (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 ». ↩︎ ↩︎Le billet « 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
goleaketsynctestdans les tests. ↩︎ ↩︎ ↩︎Le commentaire de l’allocateur dans
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 fonctiongcMarkTinyAllocsdemgc.gomarque les blocs en cours d’utilisation. Unsync.Mutexfait 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. ↩︎goleak se branche avec
defer goleak.VerifyNone(t)en tête d’un test, ou avecgoleak.VerifyTestMain(m)pour tout un paquet. Poursynctest, la documentation du paquet précise : « Time stops advancing when the root goroutine of the bubble exits. » Le paquet est stable depuis Go 1.25, après une expérience dans Go 1.24. ↩︎