Goroutine Leak: Der stille RAM-Killer in Golang
1. Überblick
Goroutine ist das, was Go “cool” macht — leicht, einfach zu erstellen, nur wenige KB pro Stück. Aber genau weil sie so leicht sind, erstellst du sie leicht und vergisst dann… sie aufzuräumen. Goroutine Leak ist der Zustand, in dem eine Goroutine dauerhaft blockiert ist, niemals endet und sich so über die Zeit ansammelt, bis der RAM aufgebraucht ist oder das System zusammenbricht.
Nach diesem Artikel kennst du: die häufigsten Muster, die zu Leaks führen, wie du pprof und Runtime-Metriken zur Erkennung nutzt, und wie du sie mit context, gepufferten Channels und select mit Timeout behebst.
2. Was ist ein Goroutine Leak?
Anders als gewöhnliche Memory Leaks (Objekte werden nicht vom GC bereinigt, weil es noch Referenzen gibt), ist ein Goroutine Leak eine Goroutine, die dauerhaft an einer Stelle blockiert ist — normalerweise beim Warten auf Channel-Lesen/Schreiben, die nie passieren. Diese Goroutine beendet sich nicht, wird nicht vom GC bereinigt, und ihr Stack (zusammen mit allen Variablen, die sie im Closure festhält) bleibt auf ewig im Heap.
Das gefährlichste Problem: Das Programm stürzt nicht sofort ab. Es läuft normal, Requests werden korrekt beantwortet, aber RSS (Resident Memory) steigt bei jedem Request. Nach ein paar Stunden oder Tagen wird der Container mit OOMKilled beendet.
3. Häufiges Leak-Muster #1: Senden in einen Channel, den niemand empfängt
func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
resultCh := make(chan string)
go func() {
result := slowHTTPCall(url) // nehmen wir an, das braucht 5s
resultCh <- result // (1) senden in einen ungepufferten Channel
}()
select {
case res := <-resultCh:
return res, nil
case <-time.After(timeout):
return "", fmt.Errorf("timeout after %v", timeout)
}
}
Wenn timeout (z.B. 1s) kürzer ist als die Zeit, die slowHTTPCall braucht (5s), gibt die Funktion fetchWithTimeout sofort einen Timeout-Fehler zurück und verlässt den case <-time.After-Zweig. Die Goroutine bei Zeile (1) läuft aber noch. Wenn sie nach 5s fertig ist, versucht sie, result in resultCh zu senden — aber niemand liest mehr aus diesem Channel, weil die Aufrufer-Funktion bereits returniert hat. Da resultCh ungepuffert ist, wird das Senden in einen Channel ohne Leser für immer blockiert. Diese Goroutine ist dauerhaft geleakt.
Ruft man diese Funktion 1000 Mal pro Sekunde mit kurzem Timeout auf, entstehen 1000 geleakte Goroutinen pro Sekunde.
3.1. Fix: gepufferter Channel
func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
resultCh := make(chan string, 1) // (1) Puffergröße 1
go func() {
result := slowHTTPCall(url)
resultCh <- result // blockiert nie, da der Puffer vorhanden ist
}()
select {
case res := <-resultCh:
return res, nil
case <-time.After(timeout):
return "", fmt.Errorf("timeout after %v", timeout)
}
}
Zeile (1) gibt dem Channel einen Puffer der Größe 1. Wenn die Goroutine das Ergebnis sendet, schreibt es in den Puffer und beendet sich sofort — niemand muss es lesen. Die Goroutine endet normal, kein Leak. Das ist ein schneller Fix, aber nicht vollständig: slowHTTPCall läuft trotzdem die vollen 5s, auch wenn der Aufrufer das Ergebnis nicht mehr braucht — verschwendete CPU/Netzwerk.
4. Leak-Muster #2: Vergessen, Goroutinen zu beenden, wenn Context abgebrochen wird
func worker(ch <-chan int) {
go func() {
for v := range ch { // (1) wartet für immer, wenn ch nicht geschlossen wird
process(v)
}
}()
}
Wenn niemand close(ch) aufruft, wartet die Goroutine bei Zeile (1) auf ewig und liest aus dem Channel. Dieses Muster kommt oft vor, wenn der Worker innerhalb eines Request-Bereichs erstellt wird, aber der Quell-Channel an einen anderen Lifecycle gebunden ist (z.B. eine globale Queue, die sich nie schließt).
4.1. Fix mit context.Context
func worker(ctx context.Context, ch <-chan int) {
go func() {
for {
select {
case <-ctx.Done(): // (1) empfängt Cancel-Signal
return
case v, ok := <-ch:
if !ok { // (2) Channel wurde geschlossen
return
}
process(v)
}
}
}()
}
Zeile (1) überwacht ctx.Done() — wenn der Aufrufer cancel() ruft, beendet sich die Goroutine sofort, auch wenn es nichts zum Lesen aus ch gibt. Zeile (2) behandelt den Fall, dass der Channel normal geschlossen wurde. Das ist das Standard-Muster: Jede Goroutine, die lange läuft, sollte context.Context bekommen und immer einen Ausstiegsweg über ctx.Done() haben.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
worker(ctx, dataCh)
5. Leak-Muster #3: sync.WaitGroup mit Goroutinen, die Done() nie aufrufen
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 // Done() nicht vor dem Return vergessen? Nein, defer wird immer ausgeführt
}
save(it)
}(item)
}
wg.Wait() // (1) wartet, bis alle Goroutinen Done() aufrufen
}
In diesem Beispiel ist defer wg.Done() sicher, weil defer auch bei frühem Return immer ausgeführt wird. Das echte Leak tritt auf, wenn eine Goroutine irgendwo im Body blockiert bevor sie returnieren kann — z.B. wenn save(it) einen API-Aufruf ohne Timeout macht und hängen bleibt. Dann wird auch wg.Wait() bei Zeile (1) unendlich blockiert, was die Goroutine, die processAll aufgerufen hat, auch zum Leak führt.
Hinweis:
defer wg.Done()rettet dich nur vor dem Fehler,Done()zu vergessen. Es rettet dich nicht vor Goroutinen, die irgendwo im Body hängen bleiben. Setze immer ein Timeout für jedes I/O (HTTP Client, DB Query, gRPC Call).
6. Leak-Muster #4: Risse über time.Ticker ohne Stop()
func startMonitor() {
ticker := time.NewTicker(5 * time.Second)
go func() {
for range ticker.C { // (1) läuft für immer
checkHealth()
}
}()
}
time.NewTicker erstellt intern eine Goroutine in der Runtime, um Ticks in ticker.C zu pumpen. Wenn du nie ticker.Stop() aufrufst, leben sowohl die Goroutine beim Lesen bei Zeile (1) als auch die interne Goroutine des ticker für immer weiter — selbst wenn startMonitor mehrfach aufgerufen wird (z.B. bei jeder Konfigneuerung), hast du mehrere Ticker, die parallel laufen.
6.1. Fix: Lifecycle mit Context binden
func startMonitor(ctx context.Context) {
ticker := time.NewTicker(5 * time.Second)
go func() {
defer ticker.Stop() // (1) räume den Ticker immer auf
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
checkHealth()
}
}
}()
}
Zeile (1) stellt sicher, dass ticker.Stop() aufgerufen wird, wenn die Goroutine beendet wird, egal auf welchem Weg. ctx.Done() ermöglicht es dem Aufrufer, den Monitor aktiv zu stoppen, wenn er nicht mehr nötig ist.
7. Goroutine Leak mit pprof erkennen
Der beste Weg, Leak zu erkennen, ist nicht, Code mit den Augen zu lesen, sondern direkt zu messen.
import (
"net/http"
_ "net/http/pprof" // (1) registriert automatisch Debug-Routes
)
func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
// ... Rest der App
}
Zeile (1) importiert net/http/pprof mit leerem Name (_) — nur für die Nebenwirkung, die /debug/pprof/*-Routes zu registrieren, ohne Funktionen aus dem Package zu nutzen.
Starte die App und rufe auf:
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 ...
Die Zahl 14998 in der ersten Zeile ist ein klares Zeichen: Fast 15.000 Goroutinen sind an genau einer Stelle im Code blockiert (main.go:12). Das ist eindeutig ein Goroutine Leak — ein sich wiederholendes Muster, keine normale Traffic.
7.1. Anzahl der Goroutinen über die Zeit verfolgen
func logGoroutineCount() {
ticker := time.NewTicker(10 * time.Second)
for range ticker.C {
log.Printf("goroutines: %d", runtime.NumGoroutine())
}
}
runtime.NumGoroutine() gibt die Anzahl der lebenden Goroutinen zurück. Wenn diese Zahl ständig steigt, obwohl der Traffic stabil ist, ist das ein sehr zuverlässiges Indiz für ein Leak. Du solltest diese Metrik über Prometheus exponieren (go_goroutines — tatsächlich hat der Go Prometheus Client diese Metrik bereits eingebaut).
8. Tests schreiben, um Leaks früh zu fangen
Die Bibliothek go.uber.org/goleak hilft dir, Tests zu schreiben, die automatisch Leaks erkennen, statt auf Production zu warten.
<!-- nutze go.mod statt 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) prüfe nach dem Test-Ende
_, err := fetchWithTimeout("http://slow-endpoint.local", 100*time.Millisecond)
if err == nil {
t.Fatal("expected timeout error")
}
time.Sleep(200 * time.Millisecond) // (2) gib der Goroutine Zeit, fertig zu werden
}
Zeile (1) registriert eine Prüfung, die nach dem Test läuft: goleak vergleicht die Liste der lebenden Goroutinen mit der Baseline, und wenn es “fremde” Goroutinen gibt (nicht vom Runtime/Test-Framework), schlägt der Test fehl und druckt den Stack Trace der geleakten Goroutine. Zeile (2) gibt der Goroutine Zeit, sich zu beenden, bevor geprüft wird — ohne diese Zeile könnte der Test ein False Positive haben.
Wenn du die fetchWithTimeout-Version ohne gepufferten Channel von Abschnitt 3 verwendest, schlägt dieser Test fehl mit:
--- 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
]
Der Stack Trace zeigt genau auf die Zeile, die das Leak verursacht — sehr hilfreich beim Debuggen.
9. Checkliste zur Vermeidung von Goroutine Leaks
- Jede Goroutine, die lange I/O läuft, sollte
context.Contextbekommen und einen<-ctx.Done()-Zweig haben. - Ungepufferte Channels zum Senden von Ergebnissen an einen Aufrufer, der sie ignorieren könnte — überleg dir einen Puffer der Größe 1 oder nutze
context. time.Ticker,time.Timermüssen mitStop()beendet werden, wenn sie nicht mehr nötig sind.sync.WaitGroup.Wait()ist nur sicher, wenn alle Kind-Goroutinen einen garantierten Ausstiegsweg haben (Timeout für I/O darin).- Schreib Tests mit
goleak.VerifyNone(t)für Funktionen, die Goroutinen erstellen, besonders solche mit Timeout/Cancel-Logik. - Verfolge
runtime.NumGoroutine()oder die Metrikgo_goroutinesauf deinem Production Dashboard.
Hinweis: Eine Goroutine, die lange läuft, ist nicht unbedingt ein Leak. Ein Worker Pool, der die ganze Lebensdauer der Anwendung läuft, ist ein legitimes Design. Das Problem entsteht nur, wenn die Anzahl der Goroutinen unbegrenzt mit dem Traffic wächst und nicht entsprechend sinkt.
10. Fazit
Goroutine Leaks sind gefährlich, weil sie keinen Fehler verursachen — die Anwendung gibt weiterhin korrekte Ergebnisse zurück, während der RAM dahinter steigt. Die Wurzelursache ist fast immer: Senden in einen Channel, den niemand liest, oder eine Goroutine ohne Ausstiegsweg, wenn der Aufrufer aufgegeben hat (Timeout, Cancel). Die Prävention ist einfach: Immer context.Context an Goroutinen übergeben, die lange laufen, Channels für Ergebnisse puffern und Tests mit goleak schreiben, um Fehler in der Code-Review zu fangen, statt auf Production OOMKilled zu warten.