Memory management en de garbage collector in Go

Leer hoe memory management en de garbage collector in Go werken. Van stack en heap tot GC tuning met GOGC en praktische voorbeelden.

24 augustus 20268 min leestijdDoor We Develop Communication

Veel Go-ontwikkelaars schrijven jarenlang productiecode zonder ooit stil te staan bij memory management of de garbage collector. Go regelt het immers zelf. Toch loont het om te begrijpen wat er onder de motorkap gebeurt, want zodra je applicatie onder druk komt te staan, veel allocaties, hoge latency-eisen, krappe memory limits, maakt kennis van het geheugenmodel en de GC het verschil tussen een soepel draaiend systeem en een pod die om de haverklap OOM-killed wordt.

In dit artikel kijken we naar hoe Go intern omgaat met geheugen, wanneer iets op de stack of de heap belandt, hoe de garbage collector werkt, en welke knoppen je hebt om het gedrag te tunen.

Waarom memory management in Go relevant is

Go positioneert zichzelf als een taal die het gemak van garbage collection combineert met de performance van een gecompileerde taal. Dat werkt in de praktijk uitstekend, maar is geen vrijbrief om alles te negeren.

De GC kost CPU-tijd en veroorzaakt kleine pauzes. Allocaties op de heap zijn duurder dan op de stack. En hoewel Go geen handmatige memory management kent zoals C of C++, kun je wel degelijk memory leaks veroorzaken, vooral via goroutines die blijven hangen of references die onbedoeld in scope blijven.

Als je performance tuning in Go al serieus hebt genomen, ben je waarschijnlijk al een paar keer tegen GC-gedrag aangelopen in je profiles. Dit artikel geeft je de achtergrond om die observaties te duiden.

Stack versus heap: waar leeft je data?

Go heeft, net als de meeste talen, twee plekken waar geheugen kan leven: de stack en de heap. Het grote verschil met C++ is dat je in Go niet zelf bepaalt waar iets terechtkomt. De compiler beslist dat via escape analysis.

De stack

Elke goroutine heeft zijn eigen stack. In Go begint die klein (ongeveer 2 KB) en groeit dynamisch mee met wat de goroutine nodig heeft. Variabelen op de stack worden automatisch opgeruimd zodra de functie eindigt, geen GC nodig.

Dat is razendsnel. Een allocatie op de stack is in feite gratis: het is slechts een verschuiving van de stackpointer.

De heap

De heap is gedeeld geheugen dat beschikbaar is voor alle goroutines. Alles wat langer moet leven dan de functie waarin het wordt aangemaakt, belandt hier. De garbage collector houdt bij welke objecten nog gebruikt worden en ruimt de rest op.

Heap-allocaties zijn duurder dan stack-allocaties. Ze vragen om synchronisatie, brengen GC-druk met zich mee, en zijn doorgaans minder cache-friendly.

Escape analysis in actie

De Go compiler bepaalt via escape analysis of een waarde "ontsnapt" uit zijn functie. Zo niet, dan staat hij op de stack. Zo wel, dan gaat hij naar de heap.

func stackAllocated() int {
    x := 42
    return x // waarde wordt gekopieerd, x blijft op de stack
}

func heapAllocated() *int {
    x := 42
    return &x // pointer ontsnapt, x gaat naar de heap
}

Je kunt precies zien wat de compiler doet met:

go build -gcflags='-m' ./...

De output toont regels als moved to heap: x of x escapes to heap. Dat is enorm leerzaam als je probeert te begrijpen waarom een functie meer allocaties doet dan je verwacht.

Hoe de garbage collector werkt

Go gebruikt een concurrent, tri-color mark-and-sweep garbage collector. Dat klinkt ingewikkeld, maar de kern is simpel.

Mark-and-sweep in het kort

De GC loopt periodiek door je objecten heen vanaf zogeheten roots (globale variabelen, stacks van goroutines). Alles wat bereikbaar is, wordt "gemarkeerd". Wat overblijft, is onbereikbaar en wordt opgeruimd ("swept").

De "tri-color" variant betekent dat objecten drie kleuren kunnen hebben: wit (nog niet gezien), grijs (ontdekt, maar kinderen nog niet bezocht) en zwart (klaar). Na de mark-fase blijven alleen witte objecten over, die zijn vuilnis.

Concurrent met je code

Het bijzondere aan de Go GC is dat hij grotendeels concurrent draait. Terwijl de GC aan het markeren is, draait je applicatie gewoon door. Er zijn wel korte stop-the-world momenten, maar die zijn meestal sub-milliseconde.

Dat ontwerp is bewust: Go richt zich op lage latency, niet op maximale throughput. Een Java-applicatie kan met een batch-georiënteerde GC hogere doorvoer halen, maar heeft daar soms langere pauzes bij. Go kiest consistent voor kleine, frequente GC-cycli.

De GOGC-knop

De belangrijkste tuning-parameter is GOGC. Deze environment variable bepaalt wanneer de volgende GC-cyclus start, uitgedrukt als percentage groei van de heap.

  • GOGC=100 (default): GC start als de heap is verdubbeld sinds de vorige cyclus.
  • GOGC=200: GC wacht langer, gebruikt meer geheugen, maar minder CPU voor GC.
  • GOGC=50: GC draait vaker, minder geheugen, meer CPU-overhead.
  • GOGC=off: GC volledig uit (niet aanraden behalve voor korte scripts).
GOGC=200 ./myapp

Of in code:

import "runtime/debug"

debug.SetGCPercent(200)

GOMEMLIMIT voor harde grenzen

Sinds Go 1.19 is er ook GOMEMLIMIT. Daarmee stel je een soft memory limit in. De GC zal agressiever draaien als je die grens nadert, handig in containers met een vaste memory limit.

GOMEMLIMIT=512MiB ./myapp

Zie ook de officiële Go GC guide voor een uitgebreide uitleg van deze parameters.

Allocaties meten en reduceren

Minder allocaties betekent minder GC-druk. Dat is vrijwel altijd een goede deal.

Allocaties zien met pprof en benchmarks

Go's ingebouwde benchmarking toont allocaties direct:

func BenchmarkParse(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        _ = parse("hello world")
    }
}

Run met:

go test -bench=. -benchmem

De output toont bytes per operatie en allocaties per operatie. Dat is waar je op stuurt.

Voor lopende applicaties gebruik je de heap profile:

go tool pprof http://localhost:6060/debug/pprof/heap

Zie ook onze gids over logging en observability voor het opzetten van pprof endpoints in productie.

Veelvoorkomende oorzaken van allocaties

Een paar patronen die heap-allocaties opleveren:

  • Interfaces: een waarde in een interface stoppen kan een allocatie triggeren, zeker bij niet-pointer types.
  • Closures: een closure die een variabele uit de buitenste scope vasthoudt, laat die variabele naar de heap ontsnappen.
  • Slice- en map-growth: te kleine initiële capaciteit betekent herallocaties.
  • String-concatenatie met +: elke stap maakt een nieuwe string. Gebruik strings.Builder.
  • Returnen van pointers naar lokale variabelen: forceert heap-allocatie.

sync.Pool voor hergebruik

Als je hotspot veel korte-levende objecten maakt, kan sync.Pool een uitkomst zijn:

var bufPool = sync.Pool{
    New: func() any {
        return new(bytes.Buffer)
    },
}

func handle(data []byte) {
    buf := bufPool.Get().(*bytes.Buffer)
    defer func() {
        buf.Reset()
        bufPool.Put(buf)
    }()
    // gebruik buf...
}

De pool geeft geen garanties, de GC kan items tussen cycli weggooien, maar voor buffers in hot paths werkt het uitstekend.

Veelgemaakte memory leaks in Go

Ja, ook in een garbage-collected taal kun je lekken. De klassiekers:

Gelekte goroutines

Een goroutine die op een channel wacht dat nooit gesloten wordt, blijft voor altijd in leven, inclusief alle variabelen die hij vasthoudt. Dit is de nummer één oorzaak van geheugenlekken in Go.

Gebruik altijd cancellation via het context package of een explicite "done" channel. Zie ook onze gids over goroutines en concurrency basics voor de fundamenten.

Slices die te veel vasthouden

Als je een grote slice subset-et en de originele backing array wegleggen, houdt de sub-slice die hele array in leven:

func bad() []byte {
    big := make([]byte, 10_000_000)
    // ...
    return big[:10] // houdt 10 MB vast voor 10 bytes
}

func good() []byte {
    big := make([]byte, 10_000_000)
    // ...
    small := make([]byte, 10)
    copy(small, big[:10])
    return small
}

Maps die nooit krimpen

Maps in Go krimpen niet automatisch als je keys verwijdert. Voor long-running maps met veel turnover: her-creëer de map periodiek, of gebruik een alternatieve datastructuur.

Globale caches zonder eviction

Een map[string]*LargeStruct als cache zonder maximale grootte of TTL groeit tot je proces crasht. Gebruik een cache-library met eviction (bijvoorbeeld groupcache of ristretto).

Praktische tuning-strategie

Je hoeft niet aan elke knop te draaien. Een pragmatische aanpak:

  1. Meet eerst. Draai pprof en benchmarks. Zonder cijfers tune je in het wild.
  2. Fix allocaties in hot paths. De goedkoopste GC-cyclus is degene die niet plaatsvindt.
  3. Stel GOMEMLIMIT in op containers, ingesteld op ~80% van de container-limit.
  4. Verhoog GOGC als je CPU over hebt en geheugen niet het probleem is. GOGC=200 is vaak een veilige stap.
  5. Verlaag GOGC als geheugen krap is. Dat kost CPU, maar houdt de heap kleiner.

Voor high-throughput services met strakke latency-budgetten kun je via runtime/debug.SetGCPercent zelfs runtime-tuning doen op basis van load.

Veelgestelde vragen

Wat is de garbage collector in Go?

De garbage collector (GC) in Go is een automatisch systeem dat geheugen vrijmaakt dat niet meer gebruikt wordt. Hij draait concurrent met je programma en gebruikt een tri-color mark-and-sweep algoritme om ongebruikte objecten op te sporen en op te ruimen.

Wat is het verschil tussen stack en heap in Go?

De stack is geheugen per goroutine voor lokale variabelen en wordt automatisch vrijgegeven als een functie eindigt. De heap is gedeeld geheugen voor objecten met een langere levensduur, die door de garbage collector worden beheerd.

Wat doet GOGC en hoe stel je het in?

GOGC bepaalt hoe agressief de garbage collector draait. De standaardwaarde is 100, wat betekent dat de GC start als de heap is verdubbeld sinds de vorige cyclus. Een hogere waarde zoals 200 verlaagt de CPU-overhead, een lagere waarde zoals 50 verlaagt het geheugengebruik.

Wat is escape analysis in Go?

Escape analysis is een techniek van de Go compiler die bepaalt of een variabele op de stack of heap terechtkomt. Als een waarde buiten de scope van de functie blijft leven, escape die naar de heap. Je kunt dit inspecteren met go build -gcflags='-m'.

Hoe voorkom je memory leaks in Go?

Memory leaks in Go ontstaan meestal door goroutines die blijven draaien, niet-gesloten channels, of grote slices die kleine sub-slices vasthouden. Gebruik context voor cancellation, sluit resources netjes af en profile regelmatig met pprof om lekken op te sporen.

Veelgestelde vragen

Klaar om digitaal te groeien?

Wij helpen Nederlandse bedrijven met webtechnologie en SEO-strategieën die écht werken. Neem vrijblijvend contact op.