A propos Compétences Expérience Services Blog Contact

Go 1.27 détecte les fuites de goroutines. J'ai testé sur les miennes Go 1.27 detects goroutine leaks. I tested it on mine

Mes flux SSE 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 goroutineleak de 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 pprof 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/goroutineleak sur 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 goroutine espacés pour les fuites que le détecteur ne voit pas
  • Envoie dans un canal seulement via un select avec ctx.Done()
  • Nettoie ton hub à la déconnexion : defer delete sur 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.

My SSE streams have already leaked in production. Goroutines blocked forever, piling up for days. I told those incidents in the article about timeouts and SSE.

Go 1.27 shipped in August with a new tool: a profile that lists leaked goroutines. Not the waiting ones. The ones that have no chance of ever running again.

So I replayed my real production leaks, and pointed it at them.

TL;DR: Go 1.27's goroutineleak profile uses the garbage collector to prove a goroutine can never run again. Zero false positives. Out of my four replayed leaks, it catches three, with the exact line. It misses the fourth: a global hub keeping a reference to the channel. Yet that is the most common leak in a real SSE server. Use both profiles: this one for proven leaks, the classic one for the rest.

This article is for Go developers running streaming or workers in production. No pprof knowledge needed to follow.

What a goroutine leak is

A goroutine is a lightweight execution thread managed by Go. Your server starts one per request, plus every one your code creates.

A leak is a blocked goroutine that will never run again. It waits on a channel nobody will read. Or on a context nobody will cancel.

It burns no CPU. But it keeps its stack in memory, and everything it references too. A few thousand leaks, and your memory swells slowly. No crash, no log.

Until now, we hunted these with the classic goroutine profile. It lists everyone: the legitimately blocked and the doomed alike. The sorting was manual.

What Go 1.27 brings

Go 1.27 adds a second profile, named goroutineleak. It existed as an experiment in Go 1.26. It is now on everywhere, no flag.

The idea comes from research work done at Uber. The garbage collector already knows which objects are still reachable. The profile uses that. If a goroutine is blocked on a channel that no active goroutine can reach, nobody can ever wake it up.

This is not a guess. It is a proof. Every listed goroutine is doomed, mathematically.

You access it like any other profile: the /debug/pprof/goroutineleak endpoint, or pprof.Lookup("goroutineleak") in code.

My test bench: my production leaks, replayed

I replayed four leaks in a small program, with Go 1.27.0. All four come from my real production life.

One: the timed-out worker. A goroutine computes and sends its result into an unbuffered channel. The caller gives up first. Nobody will ever read that channel.

// The timed-out worker: nobody will ever read result
result := make(chan int) // unbuffered
go func() {
    result <- compute() // blocked forever
}()
select {
case v := <-result:
    use(v)
case <-time.After(10 * time.Millisecond):
    return // caller gives up, the goroutine leaks
}

Two: the SSE producer without ctx.Done(). It pushes events into a channel. The client disconnects, the handler returns, the producer stays blocked on its send.

Three: the forgotten cancel. A goroutine waits on <-ctx.Done() of a context whose cancel nobody holds anymore.

Four: the hub keeping the reference. A global map of subscribers, like in every SSE server. The client leaves, its channel stays in the map, the producer stays blocked on it.

What it catches

Here is the real output, summarized:

=== goroutine profile (classic) ===
goroutine profile: total 5

=== goroutineleak profile (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

The classic profile counts five blocked goroutines. The new one accuses only three: the worker, the SSE producer, the forgotten context. Each stack points at the exact blocking line.

In verbose mode, the displayed state changes too: chan send (leaked). The word leaked is the signature. Not "maybe stuck". Doomed.

Three leaks, zero false positives, the code line right there. You can alert on this counter without fearing noise.

What it misses, and why it is the most common leak

My fourth leak does not show up in the profile. The global hub still references the gone client's channel. For the garbage collector, that channel is reachable. In theory, someone could still read from it, or clean the map.

In practice, nobody ever will. That goroutine is as dead as the other three. But the detector cannot prove it, so it stays silent.

And there is the problem. In a real SSE server, the leak almost always goes through the hub. It is the hub that holds the clients' channels. My production leak was exactly that one.

The docs own this limit: the profile cannot see goroutines blocked on objects still referenced. Remember the split. It proves. You still clean your hub on disconnect.

How I use it in production

Both profiles, in this order.

goroutineleak first. Everything it lists is a real leak. Fix it, no debate.

# Capture from a running service
go tool pprof http://localhost:6060/debug/pprof/goroutineleak

# Or just the first line, for an alert
curl -s localhost:6060/debug/pprof/goroutineleak?debug=1 | head -1

The goroutine profile next, comparing two captures one hour apart. A stack growing from one capture to the next is your "reachable" leak: the hub, the map, the pool.

And the code guardrails stay mandatory. Every channel send happens inside a select with ctx.Done(). Every subscriber removes itself from the hub on disconnect, with a defer delete. Every worker result goes through a buffered channel of size 1.

On Go 1.26, the profile already exists: build with GOEXPERIMENT=goroutineleakprofile. On 1.27, nothing to enable.

The checklist

The detector proves. Hygiene prevents.

  • Expose /debug/pprof/goroutineleak on an internal port, never public
  • Alert as soon as the counter exceeds zero: every entry is a real leak
  • Compare two spaced goroutine profiles for the leaks the detector cannot see
  • Send on a channel only inside a select with ctx.Done()
  • Clean your hub on disconnect: defer delete on the map
  • Buffer a throwaway worker's result channel at size 1
  • On Go 1.26, build with GOEXPERIMENT=goroutineleakprofile
  • Replay your past leaks in a small program: the profile output becomes your reference

What to remember

Go 1.27 gives you a leak detector with zero false positives. That is rare, and precious. But it only proves what the garbage collector can prove. The most common SSE server leak, the hub keeping a reference, is still your job.

Tools improve. Channel hygiene stays.

Got a Go service whose memory grows for no visible reason? Let's talk.