Context package in Go diep uitgelegd

Leer hoe het context package in Go werkt. Van cancellation en timeouts tot values en best practices, met praktische voorbeelden voor productie.

21 augustus 20267 min leestijdDoor We Develop Communication

Goroutines zijn krachtig, maar zonder een manier om ze netjes te stoppen ontstaat er chaos. Het context package in Go lost dit op door een uniforme manier te bieden om cancellation signalen, deadlines en request-scoped waarden door te geven aan alle lagen van je applicatie.

Als je HTTP servers, database queries of externe API calls bouwt, kom je altijd context tegen. In deze gids leer je precies hoe het werkt, welke valkuilen je moet vermijden en hoe je context idiomatisch toepast in productiecode.

Waarom context bestaat

Stel je voor dat een gebruiker een HTTP request afbreekt terwijl jouw server nog druk bezig is met een langlopende database query. Zonder context blijft die query doorlopen, pakt resources op en vervuilt je database connections.

Het context package lost dit op door een cancellation signaal dat zich voortplant door je hele call stack. Elke functie die ctx context.Context accepteert kan controleren of de operatie nog relevant is.

Voor een dieper begrip van goroutines en hoe ze samenwerken met context, lees eerst goroutines en concurrency basics in Go en channels diep uitgelegd.

De Context interface

De context.Context interface is verrassend klein. Hij bevat slechts vier methodes:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}
  • Deadline(), geeft terug wanneer de context verloopt
  • Done(), een channel dat sluit wanneer de context gecancelled is
  • Err(), vertelt waarom de context gecancelled is
  • Value(), haalt request-scoped data op

Die simpelheid is precies de kracht. Elke functie die context accepteert weet exact welke contract het heeft.

Root contexts: Background en TODO

Elke context-keten begint bij een root context. Je hebt twee opties:

ctx := context.Background()
ctx := context.TODO()

context.Background() gebruik je bovenaan je call stack, in main, in init, of als startpunt in tests. Het is een lege, nooit-cancellende context.

context.TODO() gebruikt dezelfde implementatie, maar signaleert aan lezers (en linters) dat je nog niet weet welke context hier zou moeten komen. Handig tijdens refactoring.

Cancellation met WithCancel

De meest basale manier om een context afbreekbaar te maken is context.WithCancel:

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

go func() {
    select {
    case <-time.After(5 * time.Second):
        fmt.Println("werk klaar")
    case <-ctx.Done():
        fmt.Println("afgebroken:", ctx.Err())
    }
}()

time.Sleep(1 * time.Second)
cancel()

De cancel functie is je trigger. Zodra je hem aanroept, sluit het Done() channel en weten alle luisterende goroutines dat ze moeten stoppen.

Belangrijk: roep cancel altijd aan, ook als de context al verlopen is. Vergeet je dit, dan lekken resources. defer cancel() direct na de creatie is de veilige default.

Deadlines en timeouts

Voor netwerkoperaties wil je vaak een harde limiet. Daarvoor bestaan WithTimeout en WithDeadline:

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.example.com", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
    log.Printf("request mislukt: %v", err)
    return
}
defer resp.Body.Close()

Als de API na 2 seconden niet reageert, breekt het request automatisch af en krijg je een context deadline exceeded error. Geen blokkerende goroutines, geen opgehoopte connections.

WithDeadline werkt hetzelfde, maar accepteert een absolute tijd in plaats van een duur. Handig als meerdere operaties dezelfde einddatum delen.

Context in HTTP servers

Go's net/http package geeft je automatisch een context per request. Deze wordt gecancelled wanneer de client de verbinding verbreekt:

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    result, err := zoekInDatabase(ctx, r.URL.Query().Get("q"))
    if err != nil {
        if errors.Is(err, context.Canceled) {
            return // client is weg, geen response nodig
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(result)
}

Geef r.Context() door aan elke downstream functie, database queries, externe API calls, caches. Zo profiteert je hele stack van de cancellation.

Voor meer details over HTTP handling, zie HTTP servers bouwen in Go en middleware en routing in Go.

Context en database queries

Het database/sql package accepteert context op bijna elke methode. Dit is geen optioneel extraatje, het is essentieel voor robuuste applicaties:

func getGebruiker(ctx context.Context, db *sql.DB, id int) (*Gebruiker, error) {
    ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
    defer cancel()

    row := db.QueryRowContext(ctx, "SELECT naam, email FROM gebruikers WHERE id = $1", id)

    var g Gebruiker
    if err := row.Scan(&g.Naam, &g.Email); err != nil {
        return nil, fmt.Errorf("gebruiker ophalen: %w", err)
    }
    return &g, nil
}

De timeout van 500ms zorgt dat langzame queries je server niet vastzetten. Meer hierover in database toegang in Go.

Values doorgeven met context

context.WithValue staat je toe request-scoped data door te geven:

type ctxKey string

const userKey ctxKey = "user"

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        user := authenticate(r)
        ctx := context.WithValue(r.Context(), userKey, user)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func handler(w http.ResponseWriter, r *http.Request) {
    user, ok := r.Context().Value(userKey).(*User)
    if !ok {
        http.Error(w, "unauthorized", http.StatusUnauthorized)
        return
    }
    // ...
}

Wanneer wel, wanneer niet

Gebruik context.Value alleen voor:

  • Request IDs en trace IDs voor logging
  • Authenticated user informatie
  • Tenant IDs in multi-tenant systemen

Gebruik het niet voor:

  • Functieparameters die expliciet horen te zijn
  • Configuratie die bij startup bekend is
  • Dependencies zoals database connections (dependency injection is duidelijker)

Gebruik altijd een custom type als key om botsingen te voorkomen. string keys van verschillende packages kunnen elkaar overschrijven.

Context doorgeven: de conventies

Go heeft een paar strikte conventies rond context:

  1. Context is altijd de eerste parameter, genaamd ctx:

    func WerkUit(ctx context.Context, input string) error
    
  2. Geef nooit nil door. Gebruik context.TODO() als je geen betere optie hebt.

  3. Sla context niet op in structs. Contexts zijn request-scoped, structs leven langer. Uitzondering: structs die expliciet een request of operatie representeren.

  4. Maak altijd een nieuwe context aan met een parent. Nooit context.Background() halverwege je call stack.

Deze regels staan ook in de officiële Go context documentatie.

Patterns voor productie

Cancellation propageren in goroutines

Bij het starten van goroutines voor concurrent werk is context essentieel:

func verwerkBatch(ctx context.Context, items []Item) error {
    g, ctx := errgroup.WithContext(ctx)

    for _, item := range items {
        item := item
        g.Go(func() error {
            return verwerkItem(ctx, item)
        })
    }

    return g.Wait()
}

errgroup uit golang.org/x/sync cancelled automatisch alle andere goroutines zodra één een error retourneert. Voor complexere patronen zie worker pools en pipelines in Go.

Checken of context cancelled is

In langlopende loops moet je periodiek controleren of je moet stoppen:

for _, item := range groteLijst {
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
    }

    verwerk(item)
}

Het default case zorgt dat de select niet blokkeert als de context nog actief is.

Timeout per stap

Soms wil je verschillende timeouts voor verschillende fases:

func volledigeFlow(ctx context.Context) error {
    fetchCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
    data, err := fetchData(fetchCtx)
    cancel()
    if err != nil {
        return err
    }

    processCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
    return processData(processCtx, data)
}

Elke stap krijgt zijn eigen budget, maar alles valt nog steeds onder de parent context.

Veelgemaakte fouten

Cancel niet aanroepen. Elke WithCancel, WithTimeout of WithDeadline moet een defer cancel() krijgen. Anders lek je geheugen.

Context opslaan in een struct. Dit verbreekt de scope en maakt testen lastig. Geef context door als parameter.

Context negeren. Als je library's gebruikt die context ondersteunen, geef de context dan ook door. Anders profiteer je niet van cancellation.

Waarden gebruiken als functieparameters. context.Value is een last-resort mechanisme. Expliciete parameters zijn altijd beter.

Voor meer best practices rond productiecode, zie production deployment van Go apps en logging en observability in Go.

Context testen

Tests voor context-gebaseerde code vereisen een beetje extra aandacht:

func TestTimeout(t *testing.T) {
    ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
    defer cancel()

    err := langzameBewerking(ctx)
    if !errors.Is(err, context.DeadlineExceeded) {
        t.Errorf("verwachtte DeadlineExceeded, kreeg %v", err)
    }
}

Gebruik errors.Is om te testen op specifieke context errors. Meer over teststrategie in testing in Go.

Veelgestelde vragen

Wat is het context package in Go?

Het context package biedt een gestandaardiseerde manier om deadlines, cancellation signalen en request-scoped waarden door te geven aan goroutines en functies. Het is onmisbaar voor HTTP servers, database calls en elke operatie die kan worden afgebroken.

Wanneer gebruik je context.Background versus context.TODO?

Gebruik context.Background() als startpunt in main, init of tests, het is de lege root context. Gebruik context.TODO() als placeholder wanneer je nog niet weet welke context gebruikt moet worden, zodat linters je eraan herinneren om dit later te fixen.

Moet ik context altijd als eerste parameter doorgeven?

Ja, conventie in Go is dat context.Context altijd de eerste parameter is, meestal genaamd ctx. Geef nooit een nil context door, gebruik context.TODO() als je geen betere optie hebt. Sla context ook niet op in structs.

Wat gebeurt er als ik cancel niet aanroep?

Als je een context met WithCancel, WithTimeout of WithDeadline maakt zonder cancel aan te roepen, lek je resources. De child context blijft in het geheugen tot de parent wordt gecancelled. Gebruik altijd defer cancel() direct na het aanmaken.

Kan ik context.Value gebruiken voor optionele parameters?

Nee, context.Value is bedoeld voor request-scoped data zoals trace IDs, user tokens of request IDs. Gebruik het niet voor functieparameters of optionele configuratie, dat maakt code onleesbaar en type-unsafe.

Afronding

Het context package lijkt klein, maar het is een van de belangrijkste tools in idiomatische Go. Zodra je het ritme te pakken hebt, ctx als eerste parameter, altijd defer cancel(), Value alleen voor request-scoped data, wordt je code robuuster, testbaarder en productie-waardiger.

Begin met het toevoegen van context aan elke functie die netwerk- of databasewerk doet. Binnen een paar weken voelt het net zo natuurlijk als een error return. Voor meer diepgang over Go's concurrency primitieven, bekijk de officiële Go blog over context.

Veelgestelde vragen

Klaar om digitaal te groeien?

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