Concurrency in Go: Goroutines en Channels - Complete Gids

Beheers concurrency in Go met goroutines en channels. Geavanceerde patronen, synchronisatie, select-statements en best practices met gedetailleerde codevoorbeelden.

Go Concurrency - Goroutines en channels in actie

Concurrency vormt een van de grootste sterke punten van Go. In tegenstelling tot andere talen waar multithreading complex blijft, biedt Go een elegant model gebaseerd op goroutines en channels dat de ontwikkeling van concurrent applicaties aanzienlijk vereenvoudigt.

Go Filosofie

"Communiceer niet door geheugen te delen; deel geheugen door te communiceren." Dit fundamentele principe stuurt het gehele concurrency-ontwerp in Go.

Goroutines Begrijpen

Goroutines zijn lichtgewicht threads beheerd door de Go-runtime. Ze verbruiken ongeveer 2 KB stackruimte (vergeleken met enkele MB voor OS-threads) en stellen duizenden concurrent taken in staat zonder systeemoverhead.

Het starten van een goroutine vereist simpelweg het plaatsen van het sleutelwoord go voor een functie-aanroep. De runtime regelt scheduling en distributie over de beschikbare threads.

goroutines_basic.gogo
package main

import (
    "fmt"
    "time"
)

// fetchData simulates a network request
func fetchData(id int) {
    // Simulates network delay
    time.Sleep(100 * time.Millisecond)
    fmt.Printf("Data %d fetched\n", id)
}

func main() {
    // Sequential execution - 500ms total
    start := time.Now()
    for i := 1; i <= 5; i++ {
        fetchData(i)
    }
    fmt.Printf("Sequential: %v\n", time.Since(start))

    // Concurrent execution - ~100ms total
    start = time.Now()
    for i := 1; i <= 5; i++ {
        go fetchData(i) // Execute as goroutine
    }
    time.Sleep(150 * time.Millisecond) // Wait for completion
    fmt.Printf("Concurrent: %v\n", time.Since(start))
}

Concurrent uitvoering vermindert de totale tijd van 500ms naar ongeveer 100ms. Het gebruik van time.Sleep voor synchronisatie van goroutines is echter geen best practice. Channels bieden een elegante oplossing.

Channels: Communicatie tussen Goroutines

Een channel is een getypeerd kanaal voor het verzenden en ontvangen van waarden tussen goroutines. Channels garanderen synchronisatie: een verzendende goroutine wacht totdat een andere ontvangt, en omgekeerd.

Het aanmaken van een channel gebeurt met de functie make. De <- operator verzendt en ontvangt data afhankelijk van de positie ten opzichte van het channel.

channels_basic.gogo
package main

import "fmt"

// worker performs computation and returns result via channel
func worker(id int, jobs <-chan int, results chan<- int) {
    // Receives jobs until channel closes
    for job := range jobs {
        result := job * 2 // Processing
        results <- result // Send result
    }
}

func main() {
    // Create channels
    jobs := make(chan int, 10)    // Buffered channel
    results := make(chan int, 10)

    // Start 3 workers
    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    // Send 5 jobs
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs) // Signal end of jobs

    // Collect results
    for r := 1; r <= 5; r++ {
        result := <-results
        fmt.Printf("Result: %d\n", result)
    }
}

Directionele channels (<-chan voor ontvangst, chan<- voor verzending) versterken de codeveiligheid door de mogelijke bewerkingen te beperken.

Buffered vs Unbuffered Channels

Het onderscheid tussen deze channeltypes beïnvloedt direct het synchronisatiegedrag tussen goroutines.

Unbuffered channels blokkeren de zender totdat een ontvanger klaar is. Buffered channels staan toe om tot N waarden te verzenden zonder te blokkeren, waarbij N de buffercapaciteit voorstelt.

buffered_channels.gogo
package main

import "fmt"

func main() {
    // Unbuffered channel - strict synchronization
    unbuffered := make(chan string)

    go func() {
        unbuffered <- "message" // Blocks until received
    }()

    msg := <-unbuffered // Unblocks the send
    fmt.Println(msg)

    // Buffered channel - capacity of 2
    buffered := make(chan int, 2)

    // These sends don't block
    buffered <- 1
    buffered <- 2
    // buffered <- 3 // Would block because buffer is full

    fmt.Println(<-buffered) // 1
    fmt.Println(<-buffered) // 2

    // Check capacity
    fmt.Printf("Length: %d, Capacity: %d\n",
        len(buffered), cap(buffered))
}

Buffered channels ontkoppelen producenten en consumenten, terwijl unbuffered een punt-naar-punt synchronisatie garanderen.

Pas op voor Deadlocks

Een deadlock treedt op wanneer alle goroutines wachtend geblokkeerd zijn. De Go-runtime detecteert dit en beëindigt het programma met een expliciete foutmelding.

Select: Channel Multiplexing

Het select statement wacht op gelijktijdige operaties op meerdere channels. Het lijkt op een switch-statement voor concurrent communicaties.

Deze constructie is essentieel voor het beheren van timeouts, annuleringen en meervoudige communicaties zonder onbeperkt geblokkeerd te zijn op een enkel channel.

select_example.gogo
package main

import (
    "fmt"
    "time"
)

// fetchAPI simulates an API call with variable delay
func fetchAPI(name string, delay time.Duration, ch chan<- string) {
    time.Sleep(delay)
    ch <- fmt.Sprintf("%s: data received", name)
}

func main() {
    api1 := make(chan string)
    api2 := make(chan string)

    // Launch two API calls in parallel
    go fetchAPI("API-1", 100*time.Millisecond, api1)
    go fetchAPI("API-2", 200*time.Millisecond, api2)

    // Global timeout of 150ms
    timeout := time.After(150 * time.Millisecond)

    // Collect results with timeout
    for i := 0; i < 2; i++ {
        select {
        case result := <-api1:
            fmt.Println(result)
        case result := <-api2:
            fmt.Println(result)
        case <-timeout:
            fmt.Println("Timeout - operation cancelled")
            return
        }
    }
}

De select kiest het eerste beschikbare channel. Als meerdere klaar zijn, is de keuze pseudo-willekeurig om verhongering te voorkomen.

Klaar om je Go gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Worker Pool Patroon

Het worker pool patroon verdeelt taken over meerdere workers, beperkt concurrency en optimaliseert het gebruik van resources. Dit patroon is onmisbaar voor het verwerken van grote hoeveelheden data.

De implementatie is gebaseerd op een gedeeld taken-channel tussen de workers en een resultaten-channel voor verzameling.

worker_pool.gogo
package main

import (
    "fmt"
    "sync"
    "time"
)

// Task represents a unit of work
type Task struct {
    ID   int
    Data string
}

// Result contains the processing result
type Result struct {
    TaskID int
    Output string
}

// worker processes received tasks
func worker(id int, tasks <-chan Task, results chan<- Result, wg *sync.WaitGroup) {
    defer wg.Done()

    for task := range tasks {
        // Simulate processing
        time.Sleep(50 * time.Millisecond)

        results <- Result{
            TaskID: task.ID,
            Output: fmt.Sprintf("Worker %d processed: %s", id, task.Data),
        }
    }
}

func main() {
    const numWorkers = 3
    const numTasks = 10

    tasks := make(chan Task, numTasks)
    results := make(chan Result, numTasks)

    var wg sync.WaitGroup

    // Start workers
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, tasks, results, &wg)
    }

    // Send tasks
    for i := 1; i <= numTasks; i++ {
        tasks <- Task{ID: i, Data: fmt.Sprintf("task-%d", i)}
    }
    close(tasks)

    // Close results channel after workers finish
    go func() {
        wg.Wait()
        close(results)
    }()

    // Collect results
    for result := range results {
        fmt.Printf("Task %d: %s\n", result.TaskID, result.Output)
    }
}

De sync.WaitGroup coördineert het wachten op alle workers voordat het resultaten-channel wordt gesloten.

Fan-Out/Fan-In Patroon

Dit patroon verdeelt het werk over meerdere goroutines (fan-out) en aggregeert vervolgens de resultaten (fan-in). Het maximaliseert het parallellisme terwijl de verzameling van resultaten wordt vereenvoudigd.

fan_out_fan_in.gogo
package main

import (
    "fmt"
    "sync"
)

// generate produces numbers on a channel
func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

// square computes the square of received numbers
func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

// merge combines multiple channels into one (fan-in)
func merge(channels ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup

    // Output function for each channel
    output := func(c <-chan int) {
        defer wg.Done()
        for n := range c {
            out <- n
        }
    }

    // Launch a goroutine per channel
    wg.Add(len(channels))
    for _, c := range channels {
        go output(c)
    }

    // Close after all goroutines finish
    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}

func main() {
    // Generate data
    numbers := generate(1, 2, 3, 4, 5, 6, 7, 8)

    // Fan-out: distribute to 3 workers
    sq1 := square(numbers)
    sq2 := square(numbers)
    sq3 := square(numbers)

    // Fan-in: aggregate results
    for result := range merge(sq1, sq2, sq3) {
        fmt.Println(result)
    }
}

Dit patroon excelleert voor distribueerbare CPU-gebonden bewerkingen en dataverwerkings-pipelines.

Context voor Annulering en Deadlines

Het context package standaardiseert het beheer van annuleringen, deadlines en waarden tussen goroutines. Elke langlopende goroutine zou een context als eerste parameter moeten accepteren.

context_example.gogo
package main

import (
    "context"
    "fmt"
    "time"
)

// fetchWithContext simulates a cancellable request
func fetchWithContext(ctx context.Context, url string) (string, error) {
    // Simulates a long operation
    select {
    case <-time.After(2 * time.Second):
        return fmt.Sprintf("Data from %s", url), nil
    case <-ctx.Done():
        return "", ctx.Err() // context.Canceled or context.DeadlineExceeded
    }
}

func main() {
    // Context with 500ms timeout
    ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
    defer cancel() // Release resources

    result, err := fetchWithContext(ctx, "https://api.example.com")
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }
    fmt.Println(result)

    // Context with manual cancellation
    ctx2, cancel2 := context.WithCancel(context.Background())

    go func() {
        time.Sleep(100 * time.Millisecond)
        cancel2() // Explicit cancellation
    }()

    result, err = fetchWithContext(ctx2, "https://api2.example.com")
    if err != nil {
        fmt.Printf("Request cancelled: %v\n", err)
    }
}
Best Practice

Roep altijd defer cancel() aan onmiddellijk na het maken van een context om resource-leaks te vermijden.

Synchronisatie met sync.Mutex

Hoewel channels de voorkeur hebben voor communicatie, blijft het sync package noodzakelijk om concurrent toegang tot gedeelde datastructuren te beschermen.

mutex_example.gogo
package main

import (
    "fmt"
    "sync"
)

// SafeCounter is a thread-safe counter
type SafeCounter struct {
    mu    sync.Mutex
    value map[string]int
}

// Increment increments the value for a given key
func (c *SafeCounter) Increment(key string) {
    c.mu.Lock()         // Exclusive lock
    defer c.mu.Unlock() // Guaranteed unlock
    c.value[key]++
}

// Value returns the current value
func (c *SafeCounter) Value(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value[key]
}

func main() {
    counter := SafeCounter{value: make(map[string]int)}

    var wg sync.WaitGroup

    // 1000 concurrent increments
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Increment("visits")
        }()
    }

    wg.Wait()
    fmt.Printf("Total: %d\n", counter.Value("visits")) // 1000
}

De sync.RWMutex optimaliseert concurrent leesoperaties met RLock()/RUnlock() voor alleen-lezen bewerkingen.

Veelgemaakte Fouten en Oplossingen

Concurrency in Go kent klassieke valkuilen. Hier zijn de meest voorkomende fouten en hoe ze te vermijden.

common_mistakes.gogo
package main

import (
    "fmt"
    "sync"
)

func main() {
    // ERROR: Loop variable capture
    // All goroutines would print the same value
    for i := 0; i < 3; i++ {
        go func() {
            fmt.Println(i) // Capture by reference - BUG
        }()
    }

    // SOLUTION: Pass value as parameter
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            fmt.Println(n) // Local copy - CORRECT
        }(i)
    }
    wg.Wait()

    // ERROR: Send on nil channel
    var ch chan int
    // ch <- 1 // Blocks forever

    // SOLUTION: Always initialize with make
    ch = make(chan int, 1)
    ch <- 1
    fmt.Println(<-ch)

    // ERROR: Send on closed channel
    done := make(chan bool)
    close(done)
    // done <- true // Panic!

    // SOLUTION: Check before send or use sync.Once
    select {
    case done <- true:
        fmt.Println("Sent")
    default:
        fmt.Println("Channel closed or full")
    }
}

Detectie van race conditions gebruikt de -race flag tijdens compilatie of testen: go test -race ./....

Begin met oefenen!

Test je kennis met onze gespreksimulatoren en technische tests.

Conclusie

Het beheersen van concurrency in Go berust op enkele kernconcepten die, eenmaal goed begrepen, het bouwen van zeer performante applicaties mogelijk maken.

Kernpunten:

✅ Goroutines zijn lichtgewicht en goedkoop - duizenden creëren blijft acceptabel

✅ Channels synchroniseren en verzenden data tussen goroutines

✅ Het select statement beheert meerdere communicaties en timeouts

✅ Het worker pool patroon beperkt concurrency en optimaliseert resources

✅ Het context package standaardiseert annulering en deadlines

✅ Mutexen beschermen gedeelde data wanneer channels onvoldoende zijn

✅ De -race flag detecteert race conditions tijdens testen

De filosofie "Deel geheugen door te communiceren" leidt naar veiligere en beter onderhoudbare ontwerpen dan traditionele multithreading met locks.

Tags

#go
#golang
#concurrency
#goroutines
#channels

Delen

Gerelateerde artikelen