Współbieżność w Go: Gorutyny i Kanały - Kompletny Przewodnik

Opanuj współbieżność w Go z gorutynami i kanałami. Zaawansowane wzorce, synchronizacja, instrukcje select i najlepsze praktyki ze szczegółowymi przykładami kodu.

Współbieżność Go - Gorutyny i kanały w działaniu

Współbieżność stanowi jedną z największych zalet Go. W przeciwieństwie do innych języków, w których wielowątkowość pozostaje skomplikowana, Go oferuje elegancki model oparty na gorutynach i kanałach, który znacząco upraszcza tworzenie aplikacji współbieżnych.

Filozofia Go

"Nie komunikuj się przez współdzielenie pamięci; współdziel pamięć przez komunikację." Ta fundamentalna zasada kieruje całym projektowaniem współbieżności w Go.

Zrozumienie Gorutyn

Gorutyny to lekkie wątki zarządzane przez runtime Go. Zużywają około 2 KB stosu (w porównaniu do kilku MB dla wątków systemowych) i umożliwiają wykonywanie tysięcy współbieżnych zadań bez obciążania systemu.

Uruchomienie gorutyny wymaga jedynie umieszczenia słowa kluczowego go przed wywołaniem funkcji. Runtime zajmuje się harmonogramowaniem i dystrybucją między dostępnymi wątkami.

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))
}

Wykonywanie współbieżne redukuje całkowity czas z 500ms do około 100ms. Jednak używanie time.Sleep do synchronizacji gorutyn nie jest dobrą praktyką. Kanały oferują eleganckie rozwiązanie.

Kanały: Komunikacja Między Gorutynami

Kanał to typowany przewód do wysyłania i odbierania wartości między gorutynami. Kanały gwarantują synchronizację: wysyłająca gorutyna czeka, aż inna odbierze, i odwrotnie.

Tworzenie kanału wykorzystuje funkcję make. Operator <- wysyła i odbiera dane w zależności od jego pozycji względem kanału.

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)
    }
}

Kanały kierunkowe (<-chan do odbioru, chan<- do wysyłania) wzmacniają bezpieczeństwo kodu, ograniczając możliwe operacje.

Kanały Buforowane vs Niebuforowane

Rozróżnienie między tymi typami kanałów bezpośrednio wpływa na zachowanie synchronizacji między gorutynami.

Kanały niebuforowane blokują nadawcę, dopóki odbiorca nie będzie gotowy. Kanały buforowane pozwalają wysłać do N wartości bez blokowania, gdzie N reprezentuje pojemność bufora.

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))
}

Kanały buforowane oddzielają producentów od konsumentów, podczas gdy niebuforowane gwarantują synchronizację punkt-do-punktu.

Uważaj na Deadlocki

Deadlock występuje, gdy wszystkie gorutyny są zablokowane w oczekiwaniu. Runtime Go wykrywa to i kończy program z wyraźnym komunikatem błędu.

Select: Multipleksowanie Kanałów

Instrukcja select czeka na jednoczesne operacje na wielu kanałach. Przypomina instrukcję switch dla komunikacji współbieżnej.

Ta konstrukcja jest niezbędna do obsługi timeoutów, anulowań i wielu komunikacji bez blokowania w nieskończoność na pojedynczym kanale.

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
        }
    }
}

select wybiera pierwszy gotowy kanał. Jeśli kilka jest gotowych, wybór jest pseudolosowy, aby zapobiec zagłodzeniu.

Gotowy na rozmowy o Go?

Ćwicz z naszymi interaktywnymi symulatorami, flashcards i testami technicznymi.

Wzorzec Worker Pool

Wzorzec worker pool rozdziela zadania między wielu workerów, ograniczając współbieżność i optymalizując wykorzystanie zasobów. Ten wzorzec okazuje się nieoceniony przy przetwarzaniu dużych ilości danych.

Implementacja opiera się na współdzielonym kanale zadań między workerami i kanale wyników do zbierania.

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)
    }
}

sync.WaitGroup koordynuje oczekiwanie na zakończenie wszystkich workerów przed zamknięciem kanału wyników.

Wzorzec Fan-Out/Fan-In

Ten wzorzec rozdziela pracę między wiele gorutyn (fan-out), a następnie agreguje wyniki (fan-in). Maksymalizuje równoległość, upraszczając jednocześnie zbieranie wyników.

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)
    }
}

Ten wzorzec doskonale sprawdza się w operacjach CPU-bound, które można rozdzielić, oraz w pipeline'ach przetwarzania danych.

Context dla Anulowania i Deadline'ów

Pakiet context standaryzuje zarządzanie anulowaniami, deadline'ami i wartościami między gorutynami. Każda długo działająca gorutyna powinna akceptować context jako pierwszy parametr.

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)
    }
}
Dobra Praktyka

Zawsze wywołuj defer cancel() natychmiast po utworzeniu contextu, aby uniknąć wycieków zasobów.

Synchronizacja z sync.Mutex

Chociaż kanały są preferowane do komunikacji, pakiet sync pozostaje niezbędny do ochrony współbieżnego dostępu do współdzielonych struktur danych.

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
}

sync.RWMutex optymalizuje współbieżne odczyty z RLock()/RUnlock() dla operacji tylko do odczytu.

Częste Błędy i Rozwiązania

Współbieżność w Go niesie ze sobą klasyczne pułapki. Oto najczęstsze błędy i jak ich unikać.

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")
    }
}

Wykrywanie race condition wykorzystuje flagę -race podczas kompilacji lub testowania: go test -race ./....

Zacznij ćwiczyć!

Sprawdź swoją wiedzę z naszymi symulatorami rozmów i testami technicznymi.

Podsumowanie

Opanowanie współbieżności w Go opiera się na kilku kluczowych koncepcjach, które dobrze zrozumiane pozwalają budować wysoce wydajne aplikacje.

Kluczowe punkty:

✅ Gorutyny są lekkie i tanie - tworzenie tysięcy pozostaje akceptowalne

✅ Kanały synchronizują i przekazują dane między gorutynami

✅ Instrukcja select obsługuje wiele komunikacji i timeoutów

✅ Wzorzec worker pool ogranicza współbieżność i optymalizuje zasoby

✅ Pakiet context standaryzuje anulowanie i deadline'y

✅ Mutexy chronią współdzielone dane, gdy kanały są niewystarczające

✅ Flaga -race wykrywa race condition podczas testów

Filozofia "Współdziel pamięć przez komunikację" prowadzi do bezpieczniejszych i łatwiejszych w utrzymaniu projektów niż tradycyjna wielowątkowość z blokadami.

Tagi

#go
#golang
#concurrency
#goroutines
#channels

Udostępnij

Powiązane artykuły