Le programme tourne depuis 3 jours. La mémoire grimpe lentement, les requêtes commencent à ralentir, et à un moment le process se fait killer par l'OOM. Pas de panic, pas de log d'erreur visible, rien dans Sentry. Juste une dégradation progressive. Neuf fois sur dix : goroutine leak.
Ce qui rend les leaks difficiles à attraper, c'est qu'ils ne font rien de bruyant. Une goroutine bloquée consomme entre 2 et 8 Ko de stack selon son état. Seule, ce n'est rien. À quelques milliers, c'est le problème.
Ce qu'est un leak
Une goroutine leak, c'est une goroutine qui a été démarrée et qui ne se termine jamais — parce qu'elle attend quelque chose qui n'arrivera pas, ou parce qu'elle tourne en boucle infinie sans mécanisme d'arrêt. Le runtime Go ne collecte pas les goroutines bloquées : elles restent en mémoire jusqu'à la fin du process.
Contrairement aux memory leaks classiques (objets non libérés), les goroutine leaks ont souvent une cause précise et reproductible. Une fois qu'on sait quoi chercher, ils se trouvent vite.
Les 4 patterns qui leakent systématiquement
1. Channel send sans receiver
Le cas le plus courant. On envoie dans un channel non bufférisé, mais le receiver s'est arrêté (timeout, erreur, return anticipé) :
func fetchResult() (string, error) {
ch := make(chan string) // non bufférisé
go func() {
result := doExpensiveWork()
ch <- result // ← bloqué si personne ne lit plus
}()
select {
case result := <-ch:
return result, nil
case <-time.After(2 * time.Second):
return "", errors.New("timeout")
// la goroutine est maintenant bloquée sur ch <- pour toujours
}
}
À chaque appel qui timeout, une goroutine reste bloquée. Fix : channel bufférisé à 1, ce qui permet à la goroutine d'écrire et de terminer même si personne ne lit :
func fetchResult(ctx context.Context) (string, error) {
ch := make(chan string, 1) // bufférisé — la goroutine peut toujours écrire
go func() {
result := doExpensiveWork()
select {
case ch <- result:
case <-ctx.Done(): // si le contexte est annulé, on sort proprement
}
}()
select {
case result := <-ch:
return result, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
2. Channel receive sur un channel jamais fermé
On range sur un channel en attendant qu'il soit fermé pour sortir, mais le producteur s'arrête sans le fermer :
func process(jobs <-chan Job) {
go func() {
for job := range jobs { // ← bloqué si jobs n'est jamais fermé
handle(job)
}
}()
}
// Côté appelant — le bug
func run() {
jobs := make(chan Job)
process(jobs)
jobs <- Job{ID: 1}
jobs <- Job{ID: 2}
// oubli de close(jobs) → la goroutine dans process() attend indéfiniment
}
Règle : celui qui crée le channel est responsable de le fermer, avec defer
dès que possible :
func run() {
jobs := make(chan Job)
defer close(jobs) // fermeture garantie, même en cas de panic
process(jobs)
jobs <- Job{ID: 1}
jobs <- Job{ID: 2}
}
3. Goroutine en boucle infinie sans sortie
Un worker lancé au démarrage de l'application, qui tourne pour toujours sans mécanisme d'arrêt propre :
func startWorker() {
go func() {
for {
processQueue()
time.Sleep(5 * time.Second)
// aucun moyen d'arrêter ce worker
// les tests qui créent ce worker vont leaker
}
}()
}
Le problème se manifeste surtout dans les tests : chaque test qui appelle
startWorker() ajoute une goroutine qui ne se termine jamais,
et goleak les détecte immédiatement. Fix avec context :
func startWorker(ctx context.Context) {
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
processQueue()
case <-ctx.Done():
return // arrêt propre
}
}
}()
}
4. HTTP handler qui lance des goroutines sans les attacher au contexte
Un handler qui lance du travail en arrière-plan, mais la requête se termine (ou le client se déconnecte) avant que le travail soit fini :
func handleUpload(w http.ResponseWriter, r *http.Request) {
data := parseBody(r)
go func() {
// si le client se déconnecte, r.Context() est annulé
// mais cette goroutine continue — et peut rester bloquée sur un I/O
processAndStore(data)
sendNotification(data.UserID)
}()
w.WriteHeader(http.StatusAccepted)
}
La goroutine n'est pas liée au contexte de la requête. Si processAndStore
attend une réponse réseau et que la connexion est coupée, elle reste bloquée.
Il faut lui passer le contexte — et gérer l'annulation :
func handleUpload(w http.ResponseWriter, r *http.Request) {
data := parseBody(r)
// Détacher du contexte HTTP (qui sera annulé dès la fin du handler)
// mais rester annulable via un contexte applicatif
ctx := context.WithoutCancel(r.Context()) // Go 1.21+
go func() {
if err := processAndStore(ctx, data); err != nil {
slog.Error("background processing failed",
"user_id", data.UserID, "error", err)
return
}
sendNotification(ctx, data.UserID)
}()
w.WriteHeader(http.StatusAccepted)
}
Détecter les leaks
En développement : goleak
goleak d'Uber est l'outil de référence pour détecter les leaks dans les tests. Il vérifie qu'aucune goroutine inattendue ne tourne après la fin du test :
func TestWorker(t *testing.T) {
defer goleak.VerifyNone(t) // échoue si une goroutine leak après le test
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // garantit l'arrêt du worker
startWorker(ctx)
// ... test
}
Ajouter goleak.VerifyNone(t) en defer sur les tests qui touchent
à de la concurrence. Ça ne ralentit pas les tests et attrape 90 % des leaks
avant la prod.
Pour l'activer sur tous les tests d'un package en une fois :
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
En production : pprof
Si le leak est déjà en prod, net/http/pprof permet de voir
toutes les goroutines actives et leur stack trace :
import _ "net/http/pprof"
func main() {
// Exposer pprof sur un port interne (ne jamais exposer publiquement)
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// ...
}
# Voir le nombre de goroutines en temps réel
curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | head -5
# Profil interactif dans le browser
go tool pprof http://localhost:6060/debug/pprof/goroutine
Dans go tool pprof, la commande top liste les fonctions
avec le plus de goroutines bloquées. Si tu vois des centaines de goroutines
sur la même stack trace, c'est le leak.
Surveillance minimale avec runtime
Sans pprof, un log périodique du nombre de goroutines suffit pour détecter une dérive :
func monitorGoroutines(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
n := runtime.NumGoroutine()
slog.Info("goroutine count", "count", n)
if n > 1000 {
slog.Warn("high goroutine count — possible leak", "count", n)
}
case <-ctx.Done():
return
}
}
}
Le seuil d'alerte dépend de l'application. L'important c'est la tendance : un nombre qui monte régulièrement sans jamais redescendre est un leak. Un nombre qui monte et redescend avec la charge, c'est normal.
Les règles qui évitent 90 % des leaks
Plutôt qu'une checklist exhaustive, trois règles qui couvrent l'essentiel :
- Toute goroutine doit avoir un chemin de sortie explicite. Si tu ne peux pas répondre à "comment cette goroutine se termine-t-elle ?", elle va leaker. Context annulé, channel fermé, signal — peu importe lequel, mais il doit en exister un.
-
Celui qui crée le channel le ferme.
Jamais le receiver. C'est la même règle que pour la mémoire en C :
celui qui alloue libère.
defer close(ch)dès la création du channel côté producteur. - Propager le context jusqu'aux goroutines de fond. Chaque goroutine lancée dans un handler HTTP, une job queue, un scheduler — doit recevoir un context et l'écouter. C'est la seule façon d'avoir un arrêt propre en cascade.
Un mot sur les goroutines "intentionnellement longues"
Tout ce qui précède concerne les leaks non intentionnels. Certaines goroutines sont censées tourner pendant toute la durée de vie de l'application (serveur HTTP, worker de queue, scheduler). Ce ne sont pas des leaks — à condition qu'elles répondent au signal d'arrêt du process.
Le pattern standard : un context racine annulé sur SIGTERM,
passé à toutes les goroutines longues, avec un WaitGroup pour
attendre qu'elles se terminent avant d'exit :
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
startWorker(ctx)
}()
<-ctx.Done() // attend SIGTERM ou Ctrl+C
slog.Info("shutting down...")
wg.Wait() // attend que toutes les goroutines se terminent
slog.Info("done")
}
Avec ce pattern, goleak dans les tests et pprof en prod,
les goroutine leaks deviennent détectables avant de causer des incidents.