Goroutine leaks en Go : détecter, comprendre, corriger

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.

Commentaires (0)