Channels in Go diep uitgelegd: zo werkt communicatie

Leer hoe channels in Go werken: buffered, unbuffered, select, close en deadlocks. Met praktische voorbeelden voor veilige concurrency.

7 augustus 20266 min leestijdDoor We Develop Communication

Channels zijn het hart van concurrency in Go. Waar andere talen vertrouwen op shared memory en locks, gebruikt Go channels om goroutines veilig met elkaar te laten communiceren. Het idioom is beroemd: "Don't communicate by sharing memory; share memory by communicating."

In dit artikel duiken we diep in channels. Je leert hoe unbuffered en buffered channels verschillen, wanneer je select gebruikt, hoe je channels correct sluit en welke valkuilen leiden tot deadlocks. Heb je de basis van goroutines nog niet onder de knie? Lees dan eerst goroutines en concurrency basics.

Wat is een channel precies?

Een channel is een typed conduit, een soort thread-safe wachtrij waar één goroutine waardes in stopt en een andere ze eruit haalt. De syntax is simpel:

ch := make(chan int)
ch <- 42        // verstuur
value := <-ch   // ontvang

De pijl <- geeft de richting aan. Links van het channel betekent schrijven, rechts betekent lezen. Channels zijn strikt getypeerd: een chan int accepteert alleen integers. Wil je complexere data sturen, dan gebruik je structs, zie ook ons artikel over structs en interfaces.

Onder de motorkap is een channel een pointer naar een runtime-struct met een buffer, een mutex, en wachtrijen voor senders en receivers. De Go runtime regelt de scheduling automatisch.

Unbuffered channels: synchroon communiceren

De meest fundamentele channel is de unbuffered variant. Deze heeft geen capaciteit: elke send blokkeert totdat een andere goroutine ontvangt, en andersom.

func main() {
    ch := make(chan string)

    go func() {
        ch <- "hallo"
    }()

    bericht := <-ch
    fmt.Println(bericht)
}

Dit patroon heet een rendez-vous: sender en receiver ontmoeten elkaar op exact hetzelfde moment. Unbuffered channels zijn daarmee niet alleen communicatie, maar ook synchronisatie. Je weet zeker dat de ontvanger het bericht heeft gekregen voordat je verder gaat.

Dit maakt ze ideaal voor het coördineren van werk tussen goroutines, of om een "klaar"-signaal te geven zonder data.

Buffered channels: asynchroon met capaciteit

Buffered channels hebben een vaste capaciteit. Senders blokkeren pas als de buffer vol is, receivers blokkeren pas als de buffer leeg is.

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
// ch <- 4  // zou blokkeren

Buffered channels zijn handig als je producer en consumer wilt ontkoppelen, of als je bursts van werk wilt opvangen zonder direct te blokkeren. Een klassiek voorbeeld is een worker pool:

jobs := make(chan Job, 100)
results := make(chan Result, 100)

for w := 1; w <= 5; w++ {
    go worker(w, jobs, results)
}

Let op: een grotere buffer is geen oplossing voor een traag systeem. Als je consumer structureel achterloopt, loopt elke buffer uiteindelijk vol. De buffer smeert alleen pieken uit.

Directionaliteit: read-only en write-only channels

Je kunt channels beperken tot één richting in functie-signatures. Dit maakt je API veiliger en leesbaarder:

func produce(out chan<- int) {
    for i := 0; i < 10; i++ {
        out <- i
    }
    close(out)
}

func consume(in <-chan int) {
    for v := range in {
        fmt.Println(v)
    }
}

chan<- int is write-only, <-chan int is read-only. De compiler voorkomt dat je per ongeluk de verkeerde kant op schrijft. Dit sluit aan bij Go's filosofie van expliciete en duidelijke functions en error handling.

Channels sluiten met close()

Als een sender klaar is, kan hij het channel sluiten met close(ch). Receivers kunnen detecteren of een channel gesloten is:

value, ok := <-ch
if !ok {
    fmt.Println("channel is gesloten")
}

Of elegant met range:

for v := range ch {
    fmt.Println(v)
}
// loop stopt automatisch als ch gesloten wordt

Regels rond close

  • Alleen de sender sluit. De ontvanger weet niet of er nog data komt.
  • Nooit tweemaal sluiten. Dit veroorzaakt een panic.
  • Nooit schrijven naar een gesloten channel. Ook een panic.
  • Sluiten is niet altijd nodig. Alleen als receivers op het einde moeten wachten via range.

Bij meerdere senders wordt sluiten lastig. Een gebruikelijk patroon is om een aparte "done"-channel of sync.WaitGroup te combineren met één dedicated closer.

Het select-statement: multiplexen

select is voor channels wat switch is voor waardes. Het wacht op meerdere operaties tegelijk en kiest de eerste die klaar is:

select {
case msg := <-ch1:
    fmt.Println("ch1:", msg)
case msg := <-ch2:
    fmt.Println("ch2:", msg)
case ch3 <- 42:
    fmt.Println("verstuurd naar ch3")
case <-time.After(1 * time.Second):
    fmt.Println("timeout")
}

Als meerdere cases tegelijk klaar zijn, kiest Go willekeurig, dit voorkomt starvation.

Timeouts en cancellation

Select is onmisbaar voor timeouts. In productie combineer je dit vaak met context.Context:

select {
case result := <-doWork():
    return result, nil
case <-ctx.Done():
    return nil, ctx.Err()
}

Voor meer over deze aanpak raden we de officiële Go concurrency patterns blog aan.

Non-blocking operaties met default

Een default-case maakt select non-blocking:

select {
case ch <- value:
    // verstuurd
default:
    // channel vol, sla over
}

Veelvoorkomende patronen

Fan-out, fan-in

Fan-out: meerdere workers consumeren van hetzelfde channel om werk parallel af te handelen. Fan-in: meerdere producers schrijven naar één gedeeld resultaat-channel via een merge-functie met select.

Pipeline

Ketens van goroutines verbonden door channels. Elke stap transformeert data en geeft door aan de volgende. Dit patroon schaalt goed en is makkelijk te testen per stap.

Signaling met een empty struct

Als je alleen wilt signaleren zonder data, gebruik je chan struct{}. Een struct{} neemt geen geheugen in beslag:

done := make(chan struct{})
go func() {
    // werk doen
    close(done)
}()
<-done

Valkuilen en deadlocks

Channels zijn krachtig, maar geven nieuwe manieren om de mist in te gaan. De Go runtime detecteert sommige deadlocks en crasht je programma met fatal error: all goroutines are asleep - deadlock!.

Vergeten close bij range

ch := make(chan int)
go func() {
    for i := 0; i < 3; i++ {
        ch <- i
    }
    // vergeten: close(ch)
}()

for v := range ch {
    fmt.Println(v)
}
// deadlock: range wacht eeuwig

Unbuffered channel zonder ontvanger

ch := make(chan int)
ch <- 1  // deadlock in main: niemand luistert

Goroutine leaks

Als een goroutine voor altijd blokkeert op een channel dat nooit meer gebruikt wordt, blijft hij eeuwig bestaan. Gebruik context of sluit channels netjes om dit te voorkomen. Tools zoals de race detector en pprof helpen bij het opsporen.

nil channels

Een nil channel blokkeert eeuwig op elke operatie. Dit klinkt als een bug, maar is soms juist nuttig binnen select om een case tijdelijk uit te schakelen.

Wanneer gebruik je geen channels?

Channels zijn niet altijd de beste keuze. Voor eenvoudige gedeelde state, denk aan een teller of cache, is een sync.Mutex of sync.RWMutex vaak simpeler en sneller. Rob Pike's vuistregel: gebruik channels voor het overdragen van eigenaarschap van data, mutexes voor het beschermen van gedeelde state.

Voor eenmalige initialisatie heb je sync.Once, voor wachten op meerdere goroutines sync.WaitGroup. Deze primitives werken prima naast channels.

Veelgestelde vragen

Wat is een channel in Go?

Een channel is een typed pipeline waarmee goroutines veilig data naar elkaar doorgeven. Channels synchroniseren automatisch, waardoor je zelden expliciete locks nodig hebt.

Wat is het verschil tussen een buffered en unbuffered channel?

Een unbuffered channel blokkeert de sender totdat een receiver klaarstaat. Een buffered channel heeft capaciteit en blokkeert pas als de buffer vol is of, bij ontvangen, leeg.

Wanneer moet je een channel sluiten?

Sluit een channel alleen vanuit de sender-kant, en alleen als receivers moeten weten dat er geen data meer komt, bijvoorbeeld bij een range-loop. Sluit nooit tweemaal en schrijf nooit naar een gesloten channel.

Wat doet het select-statement?

Select wacht op meerdere channel-operaties tegelijk en voert de case uit die als eerste klaar is. Het is ideaal voor timeouts, cancellation en fan-in patterns.

Waarom krijg ik een deadlock bij een channel?

Een deadlock ontstaat als alle goroutines op elkaar wachten. Vaak komt dit door een unbuffered channel zonder receiver, een ontbrekende close op een range-loop, of een goroutine die blokkeert terwijl main eindigt.

Conclusie

Channels zijn elegant, maar niet triviaal. Begin simpel: stuur één waarde van A naar B met een unbuffered channel. Breid uit naar buffered channels als je ontkoppeling nodig hebt, en pak select erbij zodra je meer dan één channel tegelijk moet bewaken.

Met een goed begrip van channels, goroutines, en de juiste variabelen en types bouw je concurrent Go-code die zowel snel als veilig is. Voor diepere achtergrond raden we de Effective Go gids van het officiële Go-team aan, een must-read voor iedere Go-ontwikkelaar.

Veelgestelde vragen

Klaar om digitaal te groeien?

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