Goroutines en concurrency basics in Go uitgelegd

Leer hoe goroutines en concurrency werken in Go. Van go-keyword tot channels en sync.WaitGroup met praktische voorbeelden voor beginners.

6 augustus 20267 min leestijdDoor We Develop Communication

Goroutines en concurrency zijn de killer-feature van Go. Waar je in andere talen complete frameworks en threadpools nodig hebt om parallel werk te doen, gooi je in Go simpelweg go voor een functie en je hebt een concurrent taak draaiend. Deze eenvoud is precies waarom Go zo geliefd is voor backends, netwerkservices en tooling die veel werk tegelijk moet verzetten.

In dit artikel bouwen we voort op eerder behandelde onderwerpen zoals functions en error handling en structs en interfaces. We gaan dieper in op goroutines, channels, sync.WaitGroup en veelvoorkomende valkuilen. Na het lezen begrijp je hoe Go zijn beroemde concurrency-model in de praktijk werkt.

Wat is concurrency precies?

Concurrency betekent dat je programma meerdere taken zo structureert dat ze onafhankelijk voortgang kunnen maken. Het is niet per se hetzelfde als parallelism. Parallelism is het daadwerkelijk gelijktijdig uitvoeren van taken op verschillende CPU-cores.

Rob Pike, een van de ontwerpers van Go, vat het mooi samen: "Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once." Go maakt concurrency makkelijk te schrijven, en gebruikt je beschikbare cores automatisch voor parallel uitvoering.

Waarom is dit zo belangrijk? Moderne backends doen zelden één ding tegelijk. Je leest data uit een database, roept een externe API aan, schrijft een log en retourneert een response. Met concurrency kun je die taken overlappen in plaats van ze strikt achter elkaar uit te voeren.

Je eerste goroutine

Een goroutine starten is ongelooflijk simpel. Je plaatst het keyword go voor een functieaanroep:

package main

import (
    "fmt"
    "time"
)

func zegHallo(naam string) {
    fmt.Println("Hallo,", naam)
}

func main() {
    go zegHallo("Alice")
    go zegHallo("Bob")

    time.Sleep(100 * time.Millisecond)
    fmt.Println("Klaar")
}

Wat gebeurt hier? Het main-programma start twee goroutines en gaat zelf verder. Zonder de time.Sleep zou main direct eindigen en zouden de goroutines mogelijk niet eens kans krijgen om iets te printen. Als main stopt, stopt het hele programma.

Dit is meteen de eerste valkuil: je moet expliciet wachten op je goroutines. Een time.Sleep is daarvoor geen goede oplossing, want je weet nooit zeker hoe lang werk duurt. Gelukkig biedt Go betere mechanismen.

Wachten met sync.WaitGroup

De sync.WaitGroup uit de standard library is de klassieke manier om te wachten tot een groep goroutines klaar is.

package main

import (
    "fmt"
    "sync"
)

func verwerk(id int, wg *sync.WaitGroup) {
    defer wg.Done()
    fmt.Printf("Taak %d verwerkt\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 5; i++ {
        wg.Add(1)
        go verwerk(i, &wg)
    }

    wg.Wait()
    fmt.Println("Alle taken klaar")
}

Drie regels zijn cruciaal: wg.Add(1) voor elke nieuwe goroutine, defer wg.Done() binnen de goroutine, en wg.Wait() in de caller die blokkeert tot de teller op nul staat.

De volgorde van de uitvoer is niet gegarandeerd. Goroutines draaien concurrent en de scheduler bepaalt welke wanneer aan de beurt is. Als jouw programma bepaalde volgorde verwacht, heb je synchronisatie nodig.

Channels: communicatie tussen goroutines

Channels zijn typed conduits waarmee goroutines waarden naar elkaar kunnen sturen. Ze zijn het hart van idiomatische Go concurrency.

package main

import "fmt"

func kwadraat(n int, ch chan<- int) {
    ch <- n * n
}

func main() {
    ch := make(chan int, 3)

    for _, n := range []int{2, 3, 4} {
        go kwadraat(n, ch)
    }

    for i := 0; i < 3; i++ {
        fmt.Println(<-ch)
    }
}

Het pijl-syntax ch <- waarde stuurt iets in het channel, <-ch haalt iets eruit. De parameter chan<- int is een send-only channel, een subtiele vorm van type safety die bugs voorkomt.

Buffered vs unbuffered channels

Er zijn twee soorten channels:

  • Unbuffered (make(chan int)): sender blokkeert tot receiver klaarstaat. Perfect voor directe synchronisatie.
  • Buffered (make(chan int, 10)): sender kan tot aan de buffercapaciteit versturen zonder te blokkeren.

Gebruik buffered channels wanneer je weet hoeveel berichten er komen, of wanneer je producers en consumers wilt ontkoppelen.

Het select-statement

Met select kun je wachten op meerdere channel-operaties tegelijk. Dit is vergelijkbaar met een switch, maar dan voor channels.

select {
case msg := <-ch1:
    fmt.Println("Ontvangen van ch1:", msg)
case msg := <-ch2:
    fmt.Println("Ontvangen van ch2:", msg)
case <-time.After(2 * time.Second):
    fmt.Println("Timeout")
}

Dit patroon is extreem krachtig. Je kunt er timeouts mee implementeren, cancellation afhandelen en meerdere datasources tegelijk monitoren zonder een enkele extra goroutine.

Context voor cancellation

In productiecode stop je vaak niet bij alleen goroutines starten. Je wilt ze ook beheersen: stoppen bij timeouts of wanneer een request wordt geannuleerd. Daarvoor bestaat het context-pakket.

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

select {
case result := <-doeSlowWerk(ctx):
    fmt.Println("Resultaat:", result)
case <-ctx.Done():
    fmt.Println("Geannuleerd of timeout:", ctx.Err())
}

Een context geef je door aan elke goroutine die lang werk doet. De goroutine controleert ctx.Done() en stopt netjes. Dit patroon vind je overal in de Go standard library, van HTTP-servers tot database-drivers.

Race conditions en de race detector

Wanneer meerdere goroutines tegelijkertijd dezelfde variabele lezen en schrijven zonder synchronisatie, krijg je een race condition. Het resultaat is onvoorspelbaar en hangt af van timing.

var teller int

for i := 0; i < 1000; i++ {
    go func() {
        teller++
    }()
}

Dit voorbeeld lijkt onschuldig, maar teller++ is geen atomaire operatie. Verschillende runs geven verschillende eindwaarden.

Gelukkig heeft Go een ingebouwde race detector. Draai je programma met:

go run -race main.go

De race detector rapporteert waar twee goroutines onveilig dezelfde geheugenlocatie raken. Gebruik -race standaard in je tests, het kost wat performance, maar vindt bugs die anders weken later pas in productie opduiken.

Oplossingen zijn ofwel een sync.Mutex, ofwel, idiomatisch Go, een channel gebruiken om updates te serialiseren.

Wanneer gebruik je welk mechanisme?

Go biedt meerdere concurrency-primitives. Een praktische richtlijn:

  • Channel: voor communicatie en ownership-transfer van data tussen goroutines.
  • sync.WaitGroup: voor wachten op een groep van N onafhankelijke taken.
  • sync.Mutex / sync.RWMutex: voor het beschermen van gedeelde state zonder communicatie.
  • sync.Once: voor initialisatie die exact één keer moet gebeuren.
  • context.Context: voor cancellation, deadlines en request-scoped waarden.

De officiële Go concurrency patterns talks laten zien hoe je deze combineert in echte systemen.

Praktijkvoorbeeld: parallelle HTTP-requests

Laten we alles samenbrengen. Stel, je wilt drie URL's parallel ophalen en de eerste succesvolle response teruggeven:

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "time"
)

func haal(ctx context.Context, url string, ch chan<- string) {
    req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return
    }
    defer resp.Body.Close()
    body, _ := io.ReadAll(resp.Body)
    ch <- fmt.Sprintf("%s: %d bytes", url, len(body))
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    ch := make(chan string, 3)
    urls := []string{
        "https://go.dev",
        "https://pkg.go.dev",
        "https://wedecom.net",
    }

    for _, u := range urls {
        go haal(ctx, u, ch)
    }

    select {
    case eerste := <-ch:
        fmt.Println("Eerste response:", eerste)
    case <-ctx.Done():
        fmt.Println("Timeout op alle requests")
    }
}

Dit combineert goroutines, een buffered channel, context voor timeouts en select voor het kiezen van de eerste response. Precies het soort patroon dat Go zo aantrekkelijk maakt voor backend-ontwikkeling.

Veelvoorkomende valkuilen

Een paar zaken waar beginners steevast tegenaan lopen:

  • Loop variables capturen in goroutines. Sinds Go 1.22 is dit veiliger, maar controleer altijd je Go-versie.
  • Nooit closeen van channels vanaf de receiver kant, alleen de sender sluit een channel.
  • Goroutine leaks: een goroutine die vastzit op een channel dat nooit waarde krijgt, blijft voor altijd draaien en lekt geheugen.
  • Mutex kopiëren: een sync.Mutex mag nooit per waarde gekopieerd worden, altijd via pointer.

Als je net begint met Go, start dan met de installatie en eerste Go-programma en zorg dat je de basis van variabelen en types stevig onder de knie hebt voordat je diep in concurrency duikt.

Veelgestelde vragen

Wat is een goroutine in Go?

Een goroutine is een lichtgewicht functie die onafhankelijk draait, beheerd door de Go runtime. Je start er een door het keyword go voor een functieaanroep te plaatsen. Goroutines gebruiken minimaal geheugen en je kunt er gemakkelijk duizenden tegelijk draaien.

Wat is het verschil tussen een goroutine en een thread?

Een OS-thread kost al snel 1 MB of meer aan geheugen, terwijl een goroutine begint met slechts 2 KB stack. De Go runtime multiplext goroutines over een kleiner aantal OS-threads, waardoor je efficiënt tienduizenden concurrent taken kunt draaien.

Wanneer gebruik je een channel in Go?

Gebruik channels wanneer goroutines moeten communiceren of synchroniseren. Ze voorkomen race conditions omdat data wordt verstuurd in plaats van gedeeld. Het Go-motto is: "Don't communicate by sharing memory, share memory by communicating."

Is concurrency hetzelfde als parallelism?

Nee. Concurrency gaat over het structureren van meerdere taken die onafhankelijk kunnen voortgang maken, parallelism gaat over het daadwerkelijk tegelijk uitvoeren op meerdere CPU-cores. Go maakt concurrency makkelijk en voert die parallel uit wanneer er meerdere cores beschikbaar zijn.

Wat is een race condition?

Een race condition ontstaat wanneer meerdere goroutines tegelijk dezelfde variabele lezen en schrijven zonder synchronisatie. Het resultaat wordt onvoorspelbaar. Je detecteert ze met de Go race-detector via go run -race of go test -race.

Samenvatting

Goroutines en channels maken concurrency in Go ongewoon toegankelijk. Je start concurrent werk met go, coördineert met sync.WaitGroup of channels, en handelt timeouts en cancellation af met context. De race detector vangt onveilige gedeelde state vroeg op.

Begin klein: bouw een script dat drie API's parallel aanroept. Voeg dan een timeout toe. Breid uit met select en foutafhandeling. Voor je het weet schrijf je productiecode die tientallen taken tegelijk verzet, en dat is precies waar Go voor gemaakt is.

Veelgestelde vragen

Klaar om digitaal te groeien?

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