Goroutine Leak Profielen

De concurrency-functies van Go zijn krachtig en eenvoudig in gebruik, maar diezelfde eenvoud kan er soms voor zorgen dat zelfs ervaren ontwikkelaars fouten maken. Gelukkig is het Go-ecosysteem uitgerust met nuttige tools voor debugging, zoals de race detector, maar zelfs bestaande tools kunnen sommige concurrency-bugs over het hoofd zien, zoals het onderwerp van dit artikel: de goroutine leak.

Goroutines synchroniseren of wisselen informatie uit via gedeelde concurrency-primitieven, zoals channels, locks en wait groups. Tijdens deze communicatie blokkeren goroutines vaak op deze primitieven; ze wachten tot aan een bepaalde voorwaarde is voldaan. Veelvoorkomende voorbeelden zijn het wachten op een bezette mutex of het ontvangen van een bericht via een channel. Goroutines kunnen ook blokkeren op operaties van het besturingssysteem, zoals het lezen uit een netwerksocket of een bestand.

We kunnen spreken van een leaked goroutine als deze geblokkeerd is, maar de voorwaarden om deze te ontblokkeren nooit meer kunnen worden behaald. Na verloop van tijd verslechtert een ophoping van gelekte goroutines de prestaties door overmatig geheugengebruik (door de gelekte goroutines zelf of het geheugen waarnaar ze verwijzen), evenals CPU-gebruik door de garbage collector, vooral als GOMEMLIMIT wordt gebruikt.

Goroutine leaks kunnen berucht moeilijk te detecteren zijn. In unit-testing zijn de belangrijkste doorbraken onder andere de open-source bibliotheek goleak, die individuele tests kan instrumenteren om eventuele niet-beëindigde goroutines na afloop van de test als verdacht te markeren. Evenzo introduceerde Go 1.25 het synctest-pakket in de standaardbibliotheek; dit kan de kwaliteit van unit-tests in concurrente code aanzienlijk verbeteren door Go-ontwikkelaars meer controle te geven over de volgorde van concurrente gebeurtenissen om scenario's die moeilijk te reproduceren zijn, betrouwbaar te kunnen testen.

Helaas kunnen beide benaderingen geen goroutine leaks controleren in productiesystemen, vooral niet op grotere schaal, die zich op manieren kunnen gedragen die niet zijn voorzien in tests. Goroutine-profielen zijn een rudimentaire manier om te controleren op operaties die te veel goroutines blokkeren of om groeitrends te analyseren. Echter, goroutine-profielen kunnen geen onderscheid maken tussen goroutines die zijn gelekt en goroutines die bij ontwerp in grote aantallen tijdelijk geblokkeerd zijn, bijvoorbeeld door toegenomen verkeer in een microservice. Evenzo kunnen leaks in kleine aantallen jarenlang onopgemerkt blijven.

Go 1.27 introduceert de goroutine leak profiler, een flexibel en lichtgewicht mechanisme voor het vinden van goroutine leaks in draaiende Go-programma's, inclusief productiesystemen. In tegenstelling tot eerdere benaderingen, die menselijke analyse vereisen, is dit mechanisme precies en genereert het nauwelijks tot geen fout-positieven. De afweging is dat het beperkt is tot een subset van goroutine leaks: goroutines die permanent geblokkeerd zijn op channels of primitieven in het sync-pakket. Gelukkig beslaat dit al een zeer groot deel van de goroutine leaks, zoals we zullen zien in onze voorbeelden.

In de volgende secties laten we zien hoe deze functie wordt gebruikt, gevolgd door aanvullende voorbeelden van detecteerbare leaks en een beschrijving van de onderliggende implementatie en afwegingen.

Voorbeeld: Concurrent workers

Beschouw een functie die werkitems gelijktijdig verwerkt:

type result struct {
    res workResult
    err error
}

func processWorkItems(ws []workItem) ([]workResult, error) {
    // Process work items in parallel, aggregating results in ch.
    ch := make(chan result)
    for _, w := range ws {
        go func() {
            res, err := processWorkItem(w)
            ch <- result{res, err}
        }()
    }
    // Collect the results from ch, or return an error if one is found.
    var results []workResult
    for range len(ws) {
        r := <-ch
        if r.err != nil {
            // This early return may cause goroutine leaks.
            return nil, r.err
        }
        results = append(results, r.res)
    }
    return results, nil
}

Omdat ch een ongebufferd channel is, blokkeert elke worker-goroutine bij het verzenden van het resultaat totdat de hoofd-goroutine van het channel ontvangt. Als processWorkItems voortijdig terugkeert vanwege een fout, stopt de ontvangstlus en blijven alle overgebleven verzend-goroutines voor altijd geblokkeerd. Dit voorbeeld is representatief voor een veelvoorkomende fout die is ontdekt in echte Go-programma's, waaronder productieservices van Uber.

Laten we zien hoe we deze leaks kunnen vinden met de nieuwe goroutine leak profiler.

Debuggen met de goroutine leak profiler

Het profiel is beschikbaar via het runtime/pprof-pakket, als het profieltype goroutineleak, of door de profiel-handlers te installeren die zijn gedefinieerd door het net/http/pprof-pakket.

Als u net/http/pprof al hebt ingesteld in uw service, hoeft u niets anders te doen! Het profiel wordt automatisch beschikbaar gesteld voor verzameling via het endpoint /debug/pprof/goroutineleak op de host en poort waarop de handlers zijn geïnstalleerd.

Laten we onze concurrency-bug in context plaatsen en het net/http/pprof-pakket instellen, zodat u het zelf kunt proberen:

package main

import (
    "errors"
    "log"
    "net/http"
    _ "net/http/pprof"
    "time"
)

type workItem int
type workResult int

func processWorkItem(w workItem) (workResult, error) {
    time.Sleep(10 * time.Millisecond)
    if w == 5 {
        return 0, errors.New("simulated error")
    }
    return workResult(w * 2), nil
}

type result struct {
    res workResult
    err error
}

func processWorkItems(ws []workItem) ([]workResult, error) {
    ch := make(chan result)
    for _, w := range ws {
        go func() {
            res, err := processWorkItem(w)
            ch <- result{res, err}
        }()
    }
    var results []workResult
    for range len(ws) {
        r := <-ch
        if r.err != nil {
            return nil, r.err
        }
        results = append(results, r.res)
    }
    return results, nil
}

func main() {
    // Start pprof server
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()
    // Repeatedly trigger the leak
    for {
        items := []workItem{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
        _, err := processWorkItems(items)
        if err != nil {
            log.Printf("Error processing items: %v", err)
        }
        time.Sleep(time.Second)
    }
}

Bouw het bovenstaande programma en voer het uit:

$ go build -o leaky
$ ./leaky

Het profiel verzamelen

Het duurt niet lang voordat het programma leaks begint te accumuleren, die u kunt bekijken via de web-UI op http://localhost:6060/debug/pprof.

Als alternatief kunt u het goroutine leak profiel verzamelen met curl en het vervolgens analyseren met go tool pprof:

$ curl http://localhost:6060/debug/pprof/goroutineleak > leak.prof
$ go tool pprof leak.prof

Uitvoer:

Type: goroutineleak
Time: 2026-03-01 13:19:49 UTC
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) list processWorkItems
Total: 116
ROUTINE ======================== main.processWorkItems.func1 in .../main.go
0        116 (flat, cum)   100% of Total
.          .     31:           go func() {
.          .     32:                   res, err := processWorkItem(w)
.        116     33:                   ch <- result{res, err}
.          .     34:           }()

Het profiel onthult de goroutines die zijn gelekt bij ch <- result{res, err} (regel 33), waardoor de boosdoener nauwkeurig wordt aangewezen. Opvallend is dat naarmate het programma langer draait, het aantal gelekte goroutines toeneemt.

De leak aanpakken

Deze leak kan eenvoudig worden opgelost door ch een buffer te geven:

ch := make(chan result, len(ws))

Dit zorgt ervoor dat alle worker-goroutines een bericht kunnen verzenden zonder te blokkeren in het geval dat processWorkItems voortijdig terugkeert.

Implementatie

Deze sectie is voor degenen die geïnteresseerd zijn in hoe leak-detectie onder de motorkap van de goroutine leak profiler werkt.

Kernconcept

Laten we beginnen met een initiële observatie: als een goroutine geblokkeerd is op een concurrency-primitief waar geen enkele andere goroutine toegang toe heeft (in dit geval, via een referentie in het geheugen), dan is deze overduidelijk gelekt.

We kunnen dit generaliseren naar een definitie voor wanneer een goroutine niet is gelekt, een eigenschap die we liveness noemen. We definiëren liveness, een inductieve eigenschap, als volgt:

Een goroutine is live als:

  1. deze niet is geblokkeerd door een concurrency-primitief, of
  2. ten minste één concurrency-primitief dat hem blokkeert, wordt gereferenced door een andere live goroutine.

In het triviale geval zijn goroutines die niet geblokkeerd zijn overduidelijk niet gelekt. In het inductieve geval is de onderliggende aanname dat elke goroutine die niet is gelekt, uiteindelijk concurrency-primitieven kan gebruiken waarnaar hij verwijst om andere goroutines die door die primitieven geblokkeerd zijn, te ontblokkeren.

Om alle live goroutines te vinden, beginnen we bij de overduidelijk live, ongeblokkeerde goroutines en volgen we alle referenties die ze hebben (bijv. via hun lokale variabelen) om de concurrency-primitieven te vinden waartoe ze toegang hebben. We voegen vervolgens incrementeel alle goroutines die geblokkeerd zijn op die primitieven toe als live, en herhalen dit proces totdat er geen extra live goroutines meer worden ontdekt.

De Go runtime berekent de bereikbaarheid van het geheugen al via de garbage collector (GC). De volgende stap is om de GC aan te passen voor onze doeleinden. De Go runtime gebruikt een concurrente tri-color mark-and-sweep garbage collector, wat goed aansluit bij onze doelen. Alleen een paar belangrijke wijzigingen zijn nodig:

  1. In de beginfases markeert de reguliere GC alle goroutines (en globale variabelen) als bereikbaar, zodat ze nooit als garbage worden beschouwd (mark roots). We wijzigen dit zodat alleen ongeblokkeerde goroutines worden opgenomen, aangezien deze gegarandeerd live zijn.
  2. Dit wordt gevolgd door de markeringsfase, waarin de GC objecten markeert die (transitief) worden gereferenced door de mark roots. De wijzigingen in stap 1 zorgen er impliciet voor dat de GC alleen geheugen markeert dat wordt gereferenced door live goroutines.
  3. De markeringsfase wordt afgerond door alle geblokkeerde goroutines te inspecteren die in stap 1 niet als mark roots zijn opgenomen. Als een goroutine geblokkeerd is door ten minste één concurrency-primitief dat in stap 2 is gemarkeerd, wordt deze toegevoegd als mark root, en wordt de markeringsfase vanuit stap 2 hervat. Dit komt overeen met de inductieve stap in de definitie van liveness.
  4. Zodra alle live goroutines zijn ontdekt, krijgt elke goroutine die niet als mark root is toegevoegd de status leaked. De markeringsfase wordt dan nog één keer hervat met alle gelekte goroutines als mark roots, zodat de GC al het geheugen markeert dat tijdens een reguliere run zou zijn gemarkeerd.

Zodra de GC-cyclus is voltooid, pakt de goroutine leak profiler het over zoals bij een regulier goroutine-profiel, en filtert deze specifiek op de gelekte goroutines.

Beperkingen

Ondanks de bruikbaarheid zijn er beperkingen van de garbage collector die ertoe kunnen leiden dat leaks worden gemist:

  • Memory overreach: als een concurrency-primitief consistent bereikbaar is via globale variabelen of draaiende goroutines, worden goroutines die daarop blokkeren nooit als gelekt gerapporteerd, zelfs als dat primitief in de toekomst nooit meer wordt gebruikt.
  • Niet-standaard blokkering: voor de correctheid is leak-detectie strikt beperkt tot Go first-class concurrency-primitieven, waaronder:
  • Channel send- en receive-operaties (inclusief via nil channels).
  • Blokkerende select-statements (zonder default case, inclusief select-statements zonder cases).
  • Leden van het sync-pakket, specifiek Mutex, RWMutex, WaitGroup en Cond.

Goroutines die om andere redenen blokkeren, zoals file- en netwerk-IO of directe systeemoproepen, worden nooit als gelekt beschouwd. Dit geldt ook voor custom, door de gebruiker gedefinieerde concurrency (bijv. spin locks), tenzij deze vertrouwen op de bovengenoemde primitieven.

  • Non-determinisme: leaks kunnen alleen worden gedetecteerd nadat ze zijn opgetreden, maar kunnen niet worden voorspeld. Het reproduceren en diagnosticeren van leaks in onvoorspelbare programma's blijft daarom een uitdaging.

Voor de beste resultaten raden we aan om verschillende benaderingen te combineren: gebruik goroutine leak profielen op diverse lagen (tot en met productie) en uitgebreide testsuites voorzien van goleak en synctest.

Prestatie-impact

Goroutine leak-detectie is zorgvuldig ontworpen om de impact op de prestaties te minimaliseren, maar er zijn kosten verbonden:

Hoewel de geheugenoverhead verwaarloosbaar is, kan goroutine leak-detectie trager zijn dan de reguliere GC. Dit wordt het best geïllustreerd door een pathologisch geval dat we de "daisy-chain" noemen: in een scenario waarin een draaiende goroutine $G0$ een referentie heeft naar primitief $P1$ die $G_1$ blokkeert, enzovoort.

Dit impliceert dat het bewijzen van liveness voor $P{i+1}$ vereist dat liveness voor $Pi$ wordt bewezen, wat twee kosten met zich meebrengt:

  1. De GC-markeringsfase wordt effectief geserialiseerd ten opzichte van de volgorde waarin goroutines gescand kunnen worden.
  2. De inspectie controleert momenteel alle geblokkeerde goroutines aan het einde van elke markeringsronde, wat in het slechtste geval leidt tot $O(n^2)$ stappen per GC-cyclus, waarbij $n$ het totaal aantal goroutines is.

Ondanks dit werkt de GC nog steeds concurrent met de gebruikerscode. Bovendien kan een leak die op een bepaald moment wordt waargenomen, ook op elk toekomstig moment tijdens dezelfde uitvoering worden waargenomen. Periodieke profiling-infrastructuren kunnen daarom de frequentie afstemmen (bijv. elke 4 uur) om de overhead te minimaliseren zonder in te leveren op detectievermogen.

Erkenningen

Goroutine leak-detectie is het resultaat van een onderzoekssamenwerking tussen de Aarhus University, Washington University in St. Louis en Uber, gepresenteerd in "Dynamic Partial Deadlock Detection and Recovery via Garbage Collection" (Saioc et al., ASPLOS 2025). De overgang van academisch prototype naar een daadwerkelijke Go-functie werd mogelijk gemaakt met begeleiding van Michael Knyszek en Michael Pratt van het Go-team bij Google, en PJ Malloy (@thepudds).

---

Aanvullende voorbeelden

Hieronder volgen coderingspatronen die leiden tot leaks, waargenomen in industriële codebases en open-source projecten, in oplopende volgorde van complexiteit.

Voorbeeld: Double send

Sommige van de eenvoudigste leaks ontstaan wanneer er meer berichten over een channel worden verzonden dan verwacht.

func DoubleSend() {
    ch := make(chan any)
    go func(err error) {
        if err != nil {
            // In case of an error, send nil.
            ch <- nil
            // Return statement is missing.
        }
        // Otherwise, continue with normal behaviour.
        // This send is still executed, which causes a leak in the error case.
        ch <- struct{}{}
    }(fmt.Errorf("error"))
    // Receive only one message.
    <-ch
}

Bij elke fout zal de verzender proberen twee berichten te sturen, wat een leak veroorzaakt. Het profiel wijst naar de tweede verzendoperatie als de locatie van de leak. De oplossing is het toevoegen van een return na de eerste verzending in het foutgeval.

Voorbeeld: Early return

De omgekeerde situatie is net zo gebruikelijk: de ontvanger laat communicatie op sommige control flow-paden achterwege.

func EarlyReturn(err error) {
    ch := make(chan any)
    go func() {
        ch <- struct{}{}
    }()
    if err != nil {
        // The parent goroutine quits too early in case of an error.
        // Sender leaks.
        return
    }
    <-ch
}

Als de hoofd-goroutine voortijdig stopt, blijft de verzender eeuwig blokkeren. Dit kan worden opgelost door ch een buffer van grootte 1 te geven.

Voorbeeld: Timeout

Een variatie op het Early return patroon omvat contexts en non-deterministische keuzes (select statements):

func Timeout(ctx context.Context) {
    ch := make(chan any)
    go func() {
        ch <- struct{}{}
    }()
    select {
    case <-ch:
    case <-ctx.Done():
        // Sender leaks because there is no future rendezvous over the channel.
    }
}

Als de context wordt geannuleerd voordat de verzender synchroniseert met de hoofd-goroutine, lekt de verzender. Ook hier is de oplossing een buffer van grootte 1.

Voorbeeld: Range over channel zonder te sluiten

Het itereren over channels met range stopt pas als het channel gesloten is en alle gebufferde waarden zijn ontvangen. Als het channel nooit wordt gesloten, blokkeert de range loop de goroutine voor altijd.

func noCloseRange(list []any, workers int) {
    ch := make(chan any)
    for i := 0; i < workers; i++ {
        go func() {
            for item := range ch {
                _ = item
            }
        }()
    }
    for _, item := range list {
        ch <- item
    }
    // The channel is never closed, so workers leak.
}

Hier lekken alle 3 de workers omdat ze wachten op nieuwe items in een channel dat nooit wordt gesloten. De oplossing is om close(ch) aan te roepen nadat alle items zijn verzonden.

Bonus: Als workers op 0 wordt gezet, zal de hoofd-goroutine lekken bij de operatie ch <- item omdat er niemand is om te ontvangen.

Voorbeeld: Schending van methode-contracten

Wanneer functionaliteit is verspreid over functies en interfaces, wordt het handmatig detecteren van leaks moeilijker. Beschouw een Worker interface met Start() en Stop() methoden.

func MethodContractViolation() {
    items := make([]any, 10)
    w := NewWorker()
    w.Start()
    for _, item := range items {
        w.AddToQueue(item)
    }
    // Exits without calling ’Stop’.
}

type worker struct {
    ch   chan any
    done chan any
}

func (w *worker) Start() {
    go func() {
        for {
            select {
            case <-w.ch:
            case <-w.done:
                return
            }
        }
    }()
}

func (w *worker) Stop() {
    close(w.done)
}

func (w *worker) AddToQueue(item any) {
    w.ch <- item
}

Hier vormt Start en Stop een impliciet contract. Door Stop niet aan te roepen, blijft de background-goroutine in Start voor altijd draaien.

Voorbeeld (Cockroach): Ontbrekende unlock

Dit voorbeeld uit CockroachDB laat zien hoe het vergeten van een unlock vóór een break statement tot een leak leidt:

type Gossip struct {
    mu     sync.Mutex
    closed bool
}

func (g *Gossip) bootstrap() {
    for {
        g.mu.Lock()
        if g.closed {
            // Missing g.mu.Unlock
            break
        }
        g.mu.Unlock()
    }
}

Wanneer de loop doorbreekt zonder te unlocken, zullen toekomstige pogingen om de lock te verkrijgen (bijv. bij een tweede aanroep van bootstrap) eeuwig blokkeren.

Voorbeeld (etcd): Onverwachte volgorde van channel-operaties

In etcd kan een onverwachte volgorde tussen operaties een leak veroorzaken:

type node struct {
    status chan chan struct{}
    stop   chan struct{}
    done   chan struct{}
}

func (n *node) Status() struct{} {
    c := make(chan struct{})
    n.status <- c
    return <-c
}

func (n *node) run() {
    for {
        select {
        case c := <-n.status:
            c <- struct{}{}
        case <-n.stop:
            close(n.done)
            return
        }
    }
}

func (n *node) Stop() {
    select {
    case n.stop <- struct{}{}:
    case <-n.done:
        return
    }
    <-n.done
}

Als run, Status en Stop gelijktijdig draaien, kunnen Stop en run synchroniseren en afsluiten voordat het bericht van Status is ontvangen, waardoor Status voor altijd blokkeert. De oplossing is om de verzending naar status in een select statement te plaatsen die ook luistert naar done.

Voorbeeld (Kubernetes): Wederzijdse blokkering tussen channels en mutexes

In Kubernetes kan het mengen van channels en locks leiden tot een leak:

func (i *idleAwareFramer) monitor() {
    var resetChan = i.resetChan
    for range i.conn.closeChan {
        i.writeLock.Lock()
        close(resetChan)
        i.resetChan = nil
        i.writeLock.Unlock()
        break
    }
}

func (i *idleAwareFramer) WriteFrame() {
    i.writeLock.Lock()
    defer i.writeLock.Unlock()
    if i.resetChan == nil {
        return
    }
    i.resetChan <- true
}

WriteFrame kan de lock verkrijgen en vervolgens blokkeren op resetChan <- true. Tegelijkertijd wacht de monitor goroutine op een bericht via closeChan. Zodra dat bericht komt, probeert monitor dezelfde lock te verkrijgen. Omdat WriteFrame de lock vasthoudt maar blokkeert op het channel, blokkeren beide goroutines voor altijd. De oplossing is een aparte goroutine te starten die resetChan leegmaakt voordat de lock wordt aangevraagd.

Voorbeeld (Moby): Misbruik van sync.WaitGroup

In Moby kan een WaitGroup verkeerd worden gebruikt:

func (pm *Manager) init() {
    var group sync.WaitGroup
    group.Add(len(pm.plugins))
    for _, p := range pm.plugins {
        go func(p int) {
            defer group.Done()
        }(p)
        group.Wait() // Block here
    }
}

De fout is dat group.Wait() binnen de loop wordt aangeroepen in plaats of erna. Hierdoor blokkeert de init methode direct bij de eerste iteratie als er meer dan één plugin is, terwijl de overige goroutines nog moeten starten.

Voorbeeld (Moby): Wederzijdse blokkering (channel vs mutex)

Nog een voorbeeld uit Moby met een mixed channel-lock leak:

func (d *Daemon) StateChanged() {
    c := d.containers.ctr
    c.Lock()
    d.updateHealthMonitorElseBranch(c)
    defer c.Unlock()
}

func (s *Health) CloseMonitorChannel() {
    if s.stop != nil {
        s.stop <- struct{}{}
    }
}

func monitor(c *Container, stop chan struct{}) {
    for {
        select {
        case <-stop:
            return
        default:
            handleProbeResult(c)
        }
    }
}

func handleProbeResult(c *Container) {
    c.Lock()
    defer c.Unlock()
}

StateChanged verkrijgt de lock van de container en probeert vervolgens een bericht te sturen over het stop channel. De monitor goroutine kan echter de default case kiezen en proberen dezelfde lock te verkrijgen via handleProbeResult. Beiden blokkeren elkaar. De oplossing is om het stop channel te sluiten (close) in plaats van er een bericht in te sturen, aangezien het sluiten van een channel geen blokkerende operatie is.