Race conditions detecteren en voorkomen in Go

Leer race conditions in Go detecteren met de race detector en voorkom ze met mutexes, channels en atomics. Praktische voorbeelden en best practices.

26 augustus 20266 min leestijdDoor We Develop Communication

Race conditions zijn berucht in concurrent programma's: ze verschijnen willekeurig, verdwijnen in de debugger en veroorzaken bugs die maanden onopgemerkt blijven. In Go krijg je gelukkig een ingebouwd wapen mee, de race detector, waarmee je race conditions betrouwbaar kunt opsporen. In dit artikel leer je wat race conditions precies zijn, hoe je ze detecteert met go test -race, en welke patronen je gebruikt om ze te voorkomen.

Heb je nog geen gevoel voor goroutines en concurrency? Lees dan eerst goroutines en concurrency basics in Go en channels diep uitgelegd voor de fundamenten.

Wat is een race condition?

Een race condition treedt op wanneer het gedrag van je programma afhangt van de volgorde waarin meerdere goroutines dezelfde geheugenlocatie benaderen. Specifiek: als minstens één goroutine schrijft naar die locatie en de toegangen niet gesynchroniseerd zijn, dan heb je een data race.

Het probleem is niet dat je code fout is, het probleem is dat hij soms fout is. Op jouw laptop werkt het, in CI werkt het, en dan crasht productie om drie uur 's nachts met corrupte state.

Een klassiek voorbeeld:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var counter int
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++ // data race!
        }()
    }

    wg.Wait()
    fmt.Println("counter:", counter)
}

Je zou verwachten dat counter op 1000 uitkomt. In de praktijk krijg je waardes als 873, 942 of zelfs 1000, onvoorspelbaar. Dat komt doordat counter++ uit drie stappen bestaat (lezen, incrementeren, schrijven) die door elkaar heen kunnen lopen.

De race detector gebruiken

Go's race detector is een van de beste tools in zijn soort. Je activeert hem met de -race vlag:

go test -race ./...
go run -race main.go
go build -race -o app main.go

Draai je het bovenstaande voorbeeld met go run -race, dan krijg je direct een rapport:

==================
WARNING: DATA RACE
Read at 0x00c0000140a8 by goroutine 8:
  main.main.func1()
      main.go:16 +0x3e

Previous write at 0x00c0000140a8 by goroutine 7:
  main.main.func1()
      main.go:16 +0x54
==================

Het rapport laat exact zien waar de race optreedt en welke goroutines betrokken zijn. Dat maakt debugging veel korter dan bij andere talen.

Wanneer draai je met -race?

  • Altijd in tests: voeg -race toe aan je CI-pipeline
  • Tijdens development: zeker wanneer je nieuwe concurrent code schrijft
  • In staging: om races onder realistisch verkeer te vangen
  • Zelden in productie: de overhead is te groot (2–20× trager)

Meer over testen vind je in testing in Go: unit tests, tabletests en coverage.

Beperkingen van de race detector

De detector vindt alleen races die daadwerkelijk voorkomen tijdens runtime. Code die onder jouw test-load nooit parallel draait, blijft ongecontroleerd. Schrijf daarom tests die concurrency expliciet uitlokken:

func TestCounterConcurrent(t *testing.T) {
    c := NewCounter()
    var wg sync.WaitGroup

    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            c.Increment()
        }()
    }

    wg.Wait()
    if c.Value() != 100 {
        t.Errorf("expected 100, got %d", c.Value())
    }
}

Race conditions voorkomen met sync.Mutex

De meest directe manier om een race te voorkomen is een mutex. Die zorgt dat slechts één goroutine tegelijk een kritieke sectie betreedt.

type SafeCounter struct {
    mu    sync.Mutex
    count int
}

func (c *SafeCounter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

Let op: ook lezen moet beschermd worden. Een read zonder lock is net zo goed een race als een write zonder lock.

sync.RWMutex voor veel-lezen, weinig-schrijven

Als je workload vooral uit reads bestaat, gebruik dan sync.RWMutex. Meerdere lezers kunnen tegelijk de lock houden, maar schrijvers krijgen exclusieve toegang:

type Config struct {
    mu   sync.RWMutex
    data map[string]string
}

func (c *Config) Get(key string) string {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.data[key]
}

func (c *Config) Set(key, value string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = value
}

Communicatie via channels

Het bekende Go-motto luidt: "Don't communicate by sharing memory; share memory by communicating." In plaats van gedeelde state te beschermen met locks, stuur je data door channels.

type Request struct {
    Value    int
    Response chan int
}

func worker(requests <-chan Request) {
    total := 0
    for req := range requests {
        total += req.Value
        req.Response <- total
    }
}

Hier is total alleen toegankelijk binnen één goroutine. Er is geen race, want er is geen gedeelde state. Deze aanpak werkt vooral goed voor pipelines, zie worker pools en pipelines in Go voor uitgebreide voorbeelden.

sync/atomic voor primitives

Voor simpele operaties op integers of pointers is sync/atomic vaak sneller dan een mutex. Het package biedt lock-free operaties die op CPU-niveau atomair zijn.

import "sync/atomic"

type AtomicCounter struct {
    count atomic.Int64
}

func (c *AtomicCounter) Increment() {
    c.count.Add(1)
}

func (c *AtomicCounter) Value() int64 {
    return c.count.Load()
}

Vanaf Go 1.19 heb je typed atomics zoals atomic.Int64 en atomic.Pointer[T]. Die zijn veiliger dan de oude losse functies (atomic.AddInt64 op een int64 variabele).

Gebruik atomics alleen voor enkele waardes. Zodra je meerdere gerelateerde velden hebt, heb je een mutex nodig om invarianten te bewaren.

sync.Once voor eenmalige initialisatie

Voor lazy initialisatie is sync.Once ideaal:

var (
    instance *Service
    once     sync.Once
)

func GetService() *Service {
    once.Do(func() {
        instance = &Service{Connection: connect()}
    })
    return instance
}

once.Do garandeert dat de functie precies één keer uitgevoerd wordt, zelfs als duizenden goroutines er tegelijk langs komen.

Veelgemaakte fouten

Pointers in maps delen

Maps zijn in Go niet concurrency-safe. Lezen en schrijven vanuit meerdere goroutines veroorzaakt direct een panic:

fatal error: concurrent map read and map write

Bescherm de map met een mutex of gebruik sync.Map voor caches waar keys zelden veranderen.

Loop variables in goroutines

Vóór Go 1.22 was dit een notoire valkuil:

for _, item := range items {
    go func() {
        process(item) // race op item!
    }()
}

Sinds Go 1.22 krijgt elke iteratie een eigen item, dus dit is veilig. Draai je op een oudere versie? Geef item als parameter mee: go func(item Item) { ... }(item).

Defer binnen een lock

Dit lijkt onschuldig maar kan je programma laten vastlopen:

func (s *Store) BadUpdate(key string) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.notify(key) // als notify ook s.mu probeert te pakken: deadlock
}

Houd kritieke secties kort en roep geen callbacks of externe functies aan terwijl je een lock vasthoudt.

Een praktisch voorbeeld: thread-safe cache

Laten we alles samenbrengen in een eenvoudige cache met TTL:

type Cache struct {
    mu      sync.RWMutex
    items   map[string]cacheItem
    hits    atomic.Int64
    misses  atomic.Int64
}

type cacheItem struct {
    value     string
    expiresAt time.Time
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()
    item, ok := c.items[key]
    c.mu.RUnlock()

    if !ok || time.Now().After(item.expiresAt) {
        c.misses.Add(1)
        return "", false
    }
    c.hits.Add(1)
    return item.value, true
}

func (c *Cache) Set(key, value string, ttl time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = cacheItem{
        value:     value,
        expiresAt: time.Now().Add(ttl),
    }
}

func (c *Cache) Stats() (hits, misses int64) {
    return c.hits.Load(), c.misses.Load()
}

Merk op hoe we RWMutex gebruiken voor de map (veel reads) en atomics voor de counters (simpele increments). Dat is de juiste mix per use case.

Race conditions vinden in bestaande code

Wanneer je een bestaand project overneemt, loont het om de race detector breed in te zetten:

  1. Draai de volledige testsuite met -race, noteer elke warning
  2. Verhoog test-concurrency, voeg tests toe die bewust parallel draaien
  3. Gebruik testing.T.Parallel(), laat subtests parallel draaien
  4. Profileer onder load, combineer met profiling met pprof om bottlenecks in locks te vinden

De officiële Go Memory Model beschrijft precies welke garanties Go geeft over geheugen-synchronisatie. En het Data Race Detector artikel op go.dev legt de interne werking uit.

Veelgestelde vragen

Wat is een race condition in Go?

Een race condition ontstaat wanneer twee of meer goroutines tegelijkertijd dezelfde variabele benaderen en minstens één daarvan schrijft, zonder synchronisatie. Het resultaat hangt dan af van de volgorde van uitvoering, wat leidt tot onvoorspelbaar gedrag en subtiele bugs.

Hoe gebruik ik de Go race detector?

Voeg de vlag -race toe aan go test, go run of go build. De race detector instrumenteert je code en rapporteert tijdens runtime als twee goroutines onveilig dezelfde geheugenlocatie benaderen. Gebruik het in tests en in staging-omgevingen.

Wat is het verschil tussen een mutex en een channel?

Een mutex beschermt gedeelde state door exclusieve toegang af te dwingen. Een channel communiceert waardes tussen goroutines, waarbij ownership van data verschuift. Gebruik mutexes voor korte kritieke secties en channels voor data-stromen tussen goroutines.

Vindt de race detector alle race conditions?

Nee. De race detector vindt alleen races die daadwerkelijk optreden tijdens de uitvoering. Code die niet getest wordt onder concurrent load kan races bevatten die ongedetecteerd blijven. Daarom zijn goede tests met realistische concurrency cruciaal.

Veroorzaakt -race veel overhead?

Ja, de race detector maakt je programma ongeveer 2 tot 20 keer trager en gebruikt 5 tot 10 keer meer geheugen. Gebruik -race daarom in CI en tijdens development, niet standaard in productie.

Veelgestelde vragen

Klaar om digitaal te groeien?

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