Mes flux SSEServer-Sent Events : un flux HTTP à sens unique. Le serveur pousse des messages, le navigateur écoute. Wikipédia ont déjà fui en production. Des goroutines bloquées pour toujours, accumulées pendant des jours. J'ai raconté ces incidents dans l'article sur les timeouts et SSE.
Go 1.27 est sorti en août avec un nouvel outil : un profil qui liste les goroutines fuitées. Pas celles qui attendent. Celles qui n'ont plus aucune chance de repartir.
Alors j'ai rejoué mes vraies fuites de prod, et je l'ai pointé dessus.
TL;DR : le profil
goroutineleakde Go 1.27 utilise le garbage collector pour prouver qu'une goroutine ne repartira jamais. Zéro faux positif. Sur mes quatre fuites rejouées, il en attrape trois, avec la ligne exacte. Il rate la quatrième : un hub global qui garde la référence du canal. C'est pourtant la fuite la plus courante dans un vrai serveur SSE. Utilise les deux profils : celui-ci pour les fuites certaines, le classique pour le reste.
Cet article est pour les devs Go qui ont du streaming ou des workers en production. Pas besoin de connaître pprofL'outillage de profilage intégré à Go : il photographie l'état du programme (goroutines, mémoire, CPU) pendant qu'il tourne. Docs Go pour suivre.
Une fuite de goroutine, c'est quoi
Une goroutine, c'est un fil d'exécution léger géré par Go. Ton serveur en lance une par requête, plus toutes celles que ton code crée.
Une fuite, c'est une goroutine bloquée qui ne repartira jamais. Elle attend sur un canal que personne ne lira. Ou sur un context que personne n'annulera.
Elle ne consomme pas de CPU. Mais elle garde sa pile en mémoire, et tout ce qu'elle référence avec. Quelques milliers de fuites, et ta mémoire gonfle doucement. Sans crash, sans log.
Jusqu'ici, on chassait ça avec le profil goroutine classique. Il liste tout le monde : les bloqués légitimes comme les condamnés. Le tri restait à la main.
Ce que Go 1.27 apporte
Go 1.27 ajoute un deuxième profil, nommé goroutineleak. Il existait en expérimental dans Go 1.26. Il est maintenant actif partout, sans flag.
L'idée vient d'un travail de recherche mené chez Uber. Le garbage collector sait déjà quels objets restent accessibles. Le profil s'en sert. Si une goroutine est bloquée sur un canal que plus aucune goroutine active ne peut atteindre, personne ne pourra jamais la réveiller.
Ce n'est pas une devinette. C'est une preuve. Chaque goroutine listée est condamnée, mathématiquement.
Tu y accèdes comme aux autres profils : l'endpoint /debug/pprof/goroutineleak, ou pprof.Lookup("goroutineleak") dans le code.
Mon banc d'essai : mes fuites de prod, rejouées
J'ai rejoué quatre fuites dans un petit programme, avec Go 1.27.0. Les quatre viennent de ma vraie vie en production.
Un : le worker au timeout. Une goroutine calcule et envoie son résultat dans un canal non bufferisé. L'appelant abandonne avant. Personne ne lira jamais ce canal.
// Le worker au timeout : personne ne lira jamais result
result := make(chan int) // non bufferisé
go func() {
result <- compute() // bloqué pour toujours
}()
select {
case v := <-result:
use(v)
case <-time.After(10 * time.Millisecond):
return // l'appelant abandonne, la goroutine fuit
}
Deux : le producteur SSE sans ctx.Done(). Il pousse des événements dans un canal. Le client se déconnecte, le handler retourne, le producteur reste bloqué sur son envoi.
Trois : le cancel oublié. Une goroutine attend <-ctx.Done() d'un context dont plus personne ne tient le cancel.
Quatre : le hub qui garde la référence. Une map globale de subscribers, comme dans tout serveur SSE. Le client part, son canal reste dans la map, le producteur reste bloqué dessus.
Ce qu'il attrape
Voici la sortie réelle, résumée :
=== profil goroutine (classique) ===
goroutine profile: total 5
=== profil goroutineleak (Go 1.27) ===
goroutineleak profile: total 3
main.workerWithTimeout.func1 main.go:18
main.sseProducerNoCtx.func1 main.go:34
main.forgottenCancel.func1 main.go:47
Le profil classique compte cinq goroutines bloquées. Le nouveau n'en accuse que trois : le worker, le producteur SSE, le context oublié. Chaque pile pointe la ligne exacte du blocage.
En mode détaillé, l'état affiché change aussi : chan send (leaked). Le mot leaked est la signature. Pas « peut-être bloquée ». Condamnée.
Trois fuites, zéro faux positif, la ligne de code en face. Tu peux alerter sur ce compteur sans craindre le bruit.
Ce qu'il rate, et pourquoi c'est la fuite la plus courante
Ma quatrième fuite n'apparaît pas dans le profil. Le hub global référence encore le canal du client parti. Pour le garbage collector, ce canal reste accessible. En théorie, quelqu'un pourrait encore lire dedans, ou nettoyer la map.
En pratique, personne ne le fera jamais. Cette goroutine est aussi morte que les trois autres. Mais le détecteur ne peut pas le prouver, alors il se tait.
Et voilà le problème. Dans un vrai serveur SSE, la fuite passe presque toujours par le hub. C'est lui qui garde les canaux des clients. Ma fuite de prod, c'était exactement elle.
La doc l'assume : le profil ne voit pas les goroutines bloquées sur des objets encore référencés. Retiens le partage des rôles. Lui, il prouve. Toi, tu nettoies quand même ton hub à la déconnexion.
Comment je m'en sers en prod
Les deux profils, dans cet ordre.
goroutineleak d'abord. Tout ce qu'il liste est une vraie fuite. Corrige, sans débat.
# Capture sur un service qui tourne
go tool pprof http://localhost:6060/debug/pprof/goroutineleak
# Ou juste la première ligne, pour une alerte
curl -s localhost:6060/debug/pprof/goroutineleak?debug=1 | head -1
Le profil goroutine ensuite, en comparant deux captures à une heure d'écart. Une pile qui grossit d'une capture à l'autre, c'est ta fuite « accessible » : le hub, la map, le pool.
Et les garde-fous dans le code restent obligatoires. Chaque envoi dans un canal se fait dans un select avec ctx.Done(). Chaque subscriber se retire du hub à la déconnexion, avec un defer delete. Chaque résultat de worker passe par un canal bufferisé de taille 1.
Sur Go 1.26, le profil existe déjà : build avec GOEXPERIMENT=goroutineleakprofile. Sur 1.27, rien à activer.
La checklist
Le détecteur prouve. L'hygiène prévient.
- Expose
/debug/pprof/goroutineleaksur un port interne, jamais public - Alerte dès que le compteur dépasse zéro : chaque entrée est une vraie fuite
- Compare deux profils
goroutineespacés pour les fuites que le détecteur ne voit pas - Envoie dans un canal seulement via un
selectavecctx.Done() - Nettoie ton hub à la déconnexion :
defer deletesur la map - Bufferise à 1 le canal de résultat d'un worker jetable
- Sur Go 1.26, build avec
GOEXPERIMENT=goroutineleakprofile - Rejoue tes fuites passées dans un petit programme : la sortie du profil devient ta référence
Ce qu'il faut retenir
Go 1.27 te donne un détecteur de fuites sans faux positif. C'est rare, et précieux. Mais il ne prouve que ce que le garbage collector peut prouver. La fuite la plus courante des serveurs SSE, le hub qui garde la référence, reste ton travail.
Les outils progressent. L'hygiène des canaux reste.
Tu as un service Go qui gonfle en mémoire sans raison visible ? Parlons-en.