Goroutine Leak : L'erreur silencieuse qui dévore votre RAM
1. Vue d’ensemble
Les goroutines c’est ce qui rend Go « cool » — légères, faciles à créer, seulement quelques KB chacune. Mais justement parce qu’elles sont légères, tu les crées facilement et puis tu oublies… de les nettoyer. Une fuite de goroutine, c’est l’état où une goroutine se bloque définitivement, ne se termine jamais, et s’accumule ainsi au fil du temps jusqu’à ce que la RAM s’épuise ou que le système s’effondre.
Après avoir lu cet article, tu sauras : les patterns qui causent les leaks les plus courants, comment utiliser pprof et les métriques runtime pour les détecter, et comment les corriger avec context, les channels en buffer, et select avec un timeout.
2. Qu’est-ce qu’une fuite de goroutine
Contrairement aux fuites mémoire ordinaires (objets qui ne sont pas collectés par le GC parce qu’il y a encore des références), une fuite de goroutine est une goroutine bloquée indéfiniment à un certain point — généralement en attente d’une lecture/écriture sur un channel qui n’arrivera jamais. Cette goroutine ne meurt pas, elle n’est pas collectée par le GC, et sa pile (ainsi que toutes les variables de closure qu’elle détient) existent indéfiniment dans le heap.
Le problème le plus dangereux : ton programme ne crash pas immédiatement. Il s’exécute normalement, les requêtes retournent toujours les bons résultats, mais la RSS (resident memory) augmente graduellement avec chaque requête. Au bout de quelques heures ou jours, le container est OOMKilled.
3. Pattern leak courant #1 : Envoyer dans un channel que personne ne lit
func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
resultCh := make(chan string)
go func() {
result := slowHTTPCall(url) // supposé prendre 5s
resultCh <- result // (1) envoyer dans un channel sans buffer
}()
select {
case res := <-resultCh:
return res, nil
case <-time.After(timeout):
return "", fmt.Errorf("timeout after %v", timeout)
}
}
Si le timeout (par exemple 1s) est plus court que le temps qu’il faut à slowHTTPCall pour s’exécuter (5s), la fonction fetchWithTimeout retourne une erreur de timeout et sort immédiatement de la branche case <-time.After. Mais la goroutine enfant à la ligne (1) est toujours en cours d’exécution. Quand elle se termine après 5s, elle essaie d’envoyer result dans resultCh — il n’y a plus personne pour lire ce channel car la fonction parente a déjà retourné. Puisque resultCh n’a pas de buffer, envoyer dans un channel que personne ne lit va bloquer indéfiniment. Cette goroutine fuit indéfiniment.
Si tu appelles cette fonction 1000 fois par seconde avec un timeout court, tu crées 1000 goroutines leaks par seconde.
3.1. Correction : utiliser un buffer
func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
resultCh := make(chan string, 1) // (1) buffer de taille 1
go func() {
result := slowHTTPCall(url)
resultCh <- result // ne bloquera jamais car il y a un buffer
}()
select {
case res := <-resultCh:
return res, nil
case <-time.After(timeout):
return "", fmt.Errorf("timeout after %v", timeout)
}
}
La ligne (1) alloue un buffer de taille 1 au channel. Quand la goroutine enfant envoie result, elle écrit dans le buffer puis sort immédiatement — pas besoin que quelqu’un le lise. La goroutine se termine normalement, sans fuite. C’est une correction rapide mais pas définitive : slowHTTPCall continue à s’exécuter pendant 5s même si l’appelant ne s’intéresse plus au résultat, gaspillant inutilement du CPU/réseau.
4. Pattern leak #2 : Oublier d’annuler la goroutine quand le context est annulé
func worker(ch <-chan int) {
go func() {
for v := range ch { // (1) attend indéfiniment si ch ne ferme jamais
process(v)
}
}()
}
Si personne n’appelle close(ch), la goroutine à la ligne (1) attend indéfiniment de lire du channel. Ce pattern est courant quand un worker est créé dans une portée de requête mais que le channel source est lié à un cycle de vie différent (par exemple une file d’attente globale qui n’est jamais fermée).
4.1. Correction avec context.Context
func worker(ctx context.Context, ch <-chan int) {
go func() {
for {
select {
case <-ctx.Done(): // (1) recevoir le signal d'annulation
return
case v, ok := <-ch:
if !ok { // (2) channel fermé
return
}
process(v)
}
}
}()
}
La ligne (1) écoute ctx.Done() — quand l’appelant appelle cancel(), la goroutine sort immédiatement même si ch n’a rien à lire. La ligne (2) gère le cas où le channel est fermé normalement. C’est le pattern standard : toute goroutine longue devrait recevoir un context.Context et toujours avoir une sortie via ctx.Done().
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
worker(ctx, dataCh)
5. Pattern leak #3 : sync.WaitGroup avec une goroutine qui n’appelle jamais Done()
func processAll(items []Item) {
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(it Item) {
defer wg.Done()
if err := validate(it); err != nil {
return // oublier d'appeler Done() avant le return ? Non, defer s'exécute toujours
}
save(it)
}(item)
}
wg.Wait() // (1) attendre que toutes les goroutines appellent Done()
}
Dans cet exemple, defer wg.Done() est sûr parce que defer s’exécute toujours même lors d’un retour prématuré. Mais la fuite réelle survient quand la goroutine se bloque à l’intérieur avant de pouvoir retourner — par exemple, save(it) appelle une API sans timeout et se gèle indéfiniment. Alors wg.Wait() à la ligne (1) se bloque aussi indéfiniment, entraînant la goroutine qui appelle processAll à fuir également.
Note :
defer wg.Done()te sauve seulement d’oublier d’appelerDone(). Ça ne te protège pas contre une goroutine qui se gèle à l’intérieur du body. Mets toujours un timeout sur tous les I/O (client HTTP, requête DB, appel gRPC).
6. Pattern leak #4 : Fuite via time.Ticker sans appeler Stop()
func startMonitor() {
ticker := time.NewTicker(5 * time.Second)
go func() {
for range ticker.C { // (1) s'exécute indéfiniment
checkHealth()
}
}()
}
time.NewTicker crée une goroutine interne dans le runtime pour envoyer des ticks dans ticker.C. Si tu n’appelles jamais ticker.Stop(), à la fois la goroutine qui lit à la ligne (1) et la goroutine interne du ticker vivent indéfiniment — d’ailleurs, si startMonitor est appelée à nouveau plusieurs fois (par exemple à chaque rechargement de configuration), tu accumules plusieurs tickers qui s’exécutent en parallèle.
6.1. Correction : attacher le cycle de vie à context
func startMonitor(ctx context.Context) {
ticker := time.NewTicker(5 * time.Second)
go func() {
defer ticker.Stop() // (1) toujours nettoyer le ticker
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
checkHealth()
}
}
}()
}
La ligne (1) s’assure que ticker.Stop() est appelé quand la goroutine sort, peu importe par où. ctx.Done() permet à l’appelant d’arrêter activement le monitoring quand il n’en a plus besoin.
7. Détecter une fuite de goroutine avec pprof
La meilleure façon de détecter une fuite n’est pas de lire le code à l’œil nu mais de le mesurer directement.
import (
"net/http"
_ "net/http/pprof" // (1) enregistre automatiquement les routes debug
)
func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
// ... reste de l'app
}
La ligne (1) importe net/http/pprof avec un nom vide (_) — juste pour son side-effect qui enregistre les routes /debug/pprof/*, sans avoir besoin d’appeler aucune fonction de ce package.
Lance l’app puis appelle :
curl http://localhost:6060/debug/pprof/goroutine?debug=1
goroutine profile: total 15234
14998 @ 0x43f1 0x454a 0x4a2b ...
# 0x4a2b main.fetchWithTimeout.func1+0x5b /app/main.go:12
236 @ 0x43f1 0x454c ...
Le nombre 14998 sur la première ligne est un indice clair : près de 15 000 goroutines sont bloquées exactement au même point dans le code (main.go:12). C’est exactement une fuite de goroutine — un pattern qui se répète massivement, pas du traffic normal.
7.1. Suivre le nombre de goroutines au fil du temps
func logGoroutineCount() {
ticker := time.NewTicker(10 * time.Second)
for range ticker.C {
log.Printf("goroutines : %d", runtime.NumGoroutine())
}
}
runtime.NumGoroutine() retourne le nombre de goroutines vivantes. Si ce nombre augmente continuellement sans jamais diminuer même avec un traffic stable, c’est un indicateur très fiable d’une fuite. Tu devrais exposer cette métrique via Prometheus (go_goroutines — en fait, le client Prometheus Go a déjà cette métrique par défaut).
8. Écrire des tests pour attraper les leaks tôt
La bibliothèque go.uber.org/goleak t’aide à écrire des tests qui détectent automatiquement les leaks au lieu d’attendre de les découvrir en production.
<!-- utiliser go.mod à la place de Maven -->
go get go.uber.org/goleak
package fetcher_test
import (
"testing"
"time"
"go.uber.org/goleak"
)
func TestFetchWithTimeout_NoLeak(t *testing.T) {
defer goleak.VerifyNone(t) // (1) vérifier après que le test est terminé
_, err := fetchWithTimeout("http://slow-endpoint.local", 100*time.Millisecond)
if err == nil {
t.Fatal("expected timeout error")
}
time.Sleep(200 * time.Millisecond) // (2) attendre que la goroutine enfant ait une chance de terminer
}
La ligne (1) enregistre une vérification à exécuter après la fin du test : goleak compare la liste des goroutines vivantes à ce moment avec une baseline, et si des goroutines « étrangères » demeurent (qui ne viennent pas du runtime/test framework), le test échoue et affiche la stack trace de la goroutine leaky. La ligne (2) donne du temps à la goroutine enfant de se terminer avant la vérification — sans cette ligne, le test risque un faux positif.
Si tu utilises la version fetchWithTimeout sans buffer channel de la section 3, ce test échouera avec une sortie comme :
--- FAIL: TestFetchWithTimeout_NoLeak (0.30s)
goleak.go:39: found unexpected goroutines:
[Goroutine 18 in state chan send, with fetcher.fetchWithTimeout.func1 on top of the stack:
goroutine 18 [chan send]:
fetcher.fetchWithTimeout.func1()
/app/fetcher.go:9 +0x65
]
La stack trace te montre l’endroit exact qui cause la fuite — très utile pour déboguer.
9. Liste de contrôle pour éviter les fuites de goroutines
- Toute goroutine qui fait du long I/O devrait recevoir un
context.Contextet avoir une branche<-ctx.Done(). - Les channels sans buffer utilisés pour renvoyer un résultat à l’appelant risquent d’être abandonnés — considère un buffer de taille 1 ou utilise
context. - Les
time.Tickerettime.Timerdoivent êtreStop()quand tu ne les utilises plus. sync.WaitGroup.Wait()n’est sûr que si toutes les goroutines enfants ont une sortie garantie (timeout pour l’I/O dedans).- Écris des tests avec
goleak.VerifyNone(t)pour les fonctions qui créent des goroutines, surtout celles avec de la logique timeout/cancel. - Surveille
runtime.NumGoroutine()ou la métriquego_goroutinessur ton dashboard de production.
Note : Toute goroutine longue n’est pas forcément une fuite. Un pool de workers qui s’exécute pendant toute la durée de vie de l’application est un design valide. Le problème survient seulement quand le nombre de goroutines augmente sans limite avec le traffic sans diminuer en conséquence.
10. Conclusion
Les fuites de goroutines sont dangereuses parce qu’elles ne causent pas d’erreur immédiate — ton application renvoie toujours les bons résultats pendant que la RAM augmente en arrière-plan. La cause racine est presque toujours : envoyer dans un channel que personne ne lit, ou une goroutine sans sortie de secours quand l’appelant renonce (timeout, cancel). La prévention est simple : passe toujours un context.Context aux goroutines longues, buffurise le channel de résultat, et écris des tests avec goleak pour attraper les erreurs dès l’étape de code review au lieu d’attendre une OOMKilled en production.