Ce qui se passe
Une goroutine est fuitée quand elle est bloquée et que les conditions de son
déblocage ne peuvent plus être réunies. Elle ne plante pas, elle n'apparaît nulle
part, et elle retient sa pile et la mémoire qu'elle référence — avec, à la clé,
du travail supplémentaire pour le ramasse-miettes, particulièrement sensible
sous GOMEMLIMIT.
Les outils existants s'arrêtaient à la porte de la production. goleak et le
paquet synctest (Go 1.25) instrumentent des tests. Un profil de goroutines
ordinaire, lui, ne sait pas distinguer une goroutine fuitée d'une goroutine
temporairement bloquée en grand nombre par conception — un pic de trafic sur un
microservice ressemble exactement à une fuite.
Go 1.27 ajoute le profil goroutineleak, précis et sans faux positifs, au
prix d'une couverture partielle.
Le cas d'école
ch := make(chan result)
for _, w := range ws {
go func() {
res, err := processWorkItem(w)
ch <- result{res, err}
}()
}
for range len(ws) {
r := <-ch
if r.err != nil {
return nil, r.err // les émetteurs restants bloquent pour toujours
}
results = append(results, r.res)
}Le canal n'est pas tamponné : au retour anticipé, les goroutines qui n'ont pas
encore émis restent bloquées à vie. Le correctif tient en un argument —
make(chan result, len(ws)).
Le relever
Si net/http/pprof est déjà en place, il n'y a rien à faire : le point de
terminaison /debug/pprof/goroutineleak est disponible.
curl http://localhost:6060/debug/pprof/goroutineleak > leak.prof
go tool pprof leak.profType: goroutineleak
(pprof) list processWorkItems
Total: 116
ROUTINE ======================== main.processWorkItems.func1
0 116 (flat, cum) 100% of Total
. 116 33: ch <- result{res, err}La ligne fautive est nommée, avec le nombre de goroutines coincées dessus.
Comment il le prouve
La définition retenue est inductive : une goroutine est vivante si elle n'est bloquée par aucune primitive, ou si au moins une primitive qui la bloque est référencée par une autre goroutine vivante. Le reste est fuité.
C'est exactement une question d'accessibilité mémoire, que le ramasse-miettes sait déjà résoudre. Le GC tricolore concurrent est donc détourné : seules les goroutines non bloquées servent de racines de marquage, puis les goroutines bloquées sur une primitive marquée sont ajoutées comme racines à leur tour, jusqu'à ce que plus rien ne s'ajoute. Celles qui n'ont jamais été ajoutées sont déclarées fuitées — puis remises comme racines pour que le cycle marque normalement la mémoire.
Les limites, qui comptent autant
- Portée : uniquement les canaux (y compris
nil), lesselectbloquants sansdefault, etMutex,RWMutex,WaitGroup,Cond. Une goroutine bloquée sur une entrée-sortie fichier ou réseau, ou un appel système, n'est jamais signalée — rien ne prouve que l'opération ne finira pas. - Portée mémoire trop large : une primitive accessible depuis une variable globale ou une goroutine exécutable garde ses attendants « vivants », même si personne ne s'en servira plus.
- Coût : mémoire négligeable, mais le cycle de détection peut être plus lent que le GC normal — jusqu'à O(n²) dans le cas pathologique de la chaîne de marguerites. La recommandation est de profiler périodiquement, par exemple toutes les 4 heures : une fuite observable à un instant le reste ensuite.
À retenir
- Le profil est un complément de production à
goleaketsynctest, pas leur remplaçant. - Il ne détecte que le blocage sur primitives Go de premier ordre — le reste demande toujours de l'analyse humaine.
- Travail issu d'une collaboration entre l'université d'Aarhus, Washington University in St. Louis et Uber (Saioc et al., ASPLOS 2025).
Source : Go Blog