Współbieżność jako element języka, nie biblioteki

Jednym z głównych powodów, dla których Go zdobyło tak dużą popularność w świecie backendu, jest wbudowane wsparcie dla współbieżności. W przeciwieństwie do wielu języków, gdzie współbieżność realizowana jest przez zewnętrzne biblioteki lub skomplikowane API wątków, w Go goroutines i channels to podstawowe elementy składni.

Czym jest goroutine?

Goroutine to lekka funkcja wykonywana współbieżnie z innymi goroutines. Uruchamia się ją za pomocą słowa kluczowego go przed wywołaniem funkcji:

func sayHello(name string) {
    fmt.Printf("Cześć, %s!\n", name)
}

func main() {
    go sayHello("Ania")
    go sayHello("Bartek")
    go sayHello("Celina")

    time.Sleep(time.Second) // czekamy na zakończenie
}

Goroutines nie są wątkami systemu operacyjnego. Runtime Go zarządza nimi we własnym schedulerze, multipleksując tysiące goroutines na kilku wątkach OS. Dzięki temu:

  • Stos startowy goroutine to zaledwie kilka KB (vs megabajty dla wątku OS)
  • Przełączanie kontekstu jest szybsze — scheduler Go nie wymaga przejścia do trybu jądra
  • Można uruchomić setki tysięcy goroutines na zwykłej maszynie

W naszych serwisach webowych każde przychodzące żądanie HTTP jest automatycznie obsługiwane w osobnej goroutine — bez żadnej dodatkowej konfiguracji.

Channels — bezpieczna komunikacja

Goroutines bez sposobu komunikacji byłyby mało użyteczne. Channels to mechanizm pozwalający goroutines bezpiecznie wymieniać dane. Filozofia Go brzmi: "Nie komunikuj się przez współdzielenie pamięci — współdziel pamięć przez komunikację."

func worker(id int, results chan<- int) {
    // symulacja pracy
    time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
    results <- id * 2 // wysyłamy wynik na kanał
}

func main() {
    results := make(chan int, 5) // kanał buforowany

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

    for i := 0; i < 5; i++ {
        result := <-results // odczytujemy wynik z kanału
        fmt.Println("Wynik:", result)
    }
}

Channels mogą być niebuforowane (synchroniczne — nadawca czeka na odbiorcę) lub buforowane (asynchroniczne — nadawca może wysłać dane bez blokowania, dopóki bufor nie jest pełny).

Kanały niebuforowane wymuszają synchronizację:

ch := make(chan int) // niebuforowany — synchroniczny

Kanały buforowane pozwalają na pewien stopień asynchroniczności:

ch := make(chan int, 10) // bufor na 10 elementów

Select — multipleksowanie kanałów

Instrukcja select to odpowiednik switch, ale dla operacji na kanałach. Pozwala goroutine czekać na wiele kanałów jednocześnie i reagować na ten, który jest gotowy jako pierwszy:

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() {
        time.Sleep(200 * time.Millisecond)
        ch1 <- "wynik z serwisu A"
    }()

    go func() {
        time.Sleep(500 * time.Millisecond)
        ch2 <- "wynik z serwisu B"
    }()

    for i := 0; i < 2; i++ {
        select {
        case msg := <-ch1:
            fmt.Println(msg)
        case msg := <-ch2:
            fmt.Println(msg)
        }
    }
}

select jest szczególnie przydatny przy implementacji timeoutów:

select {
case result := <-ch:
    fmt.Println("Otrzymano:", result)
case <-time.After(3 * time.Second):
    fmt.Println("Timeout — brak odpowiedzi w ciągu 3 sekund")
}

Popularne wzorce współbieżności

Worker pool

Wzorzec puli workerów pozwala ograniczyć liczbę jednocześnie wykonywanych zadań:

func worker(id int, jobs <-chan int, results chan<- int) {
    for job := range jobs {
        fmt.Printf("Worker %d przetwarza zadanie %d\n", id, job)
        time.Sleep(time.Second) // symulacja pracy
        results <- job * 2
    }
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

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

    // wyślij 9 zadań
    for j := 1; j <= 9; j++ {
        jobs <- j
    }
    close(jobs)

    // odbierz wyniki
    for r := 0; r < 9; r++ {
        <-results
    }
}

Fan-out / Fan-in

Fan-out to uruchomienie wielu goroutines czytających z tego samego kanału. Fan-in to zbieranie wyników z wielu kanałów do jednego. Ten wzorzec jest idealny, gdy mamy wiele niezależnych źródeł danych:

func merge(channels ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup

    for _, ch := range channels {
        wg.Add(1)
        go func(c <-chan int) {
            defer wg.Done()
            for v := range c {
                out <- v
            }
        }(ch)
    }

    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}

Context — kontrola anulowania

Pakiet context pozwala propagować sygnał anulowania do drzewa goroutines:

func fetchData(ctx context.Context) (string, error) {
    select {
    case <-time.After(2 * time.Second):
        return "dane", nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

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

    result, err := fetchData(ctx)
    if err != nil {
        fmt.Println("Błąd:", err) // context deadline exceeded
        return
    }
    fmt.Println(result)
}

Najczęstsze pułapki

  • Race conditions — jednoczesny dostęp do współdzielonych danych bez synchronizacji. Używaj flagi go run -race do wykrywania
  • Goroutine leak — goroutine, która nigdy się nie kończy, bo nikt nie czyta z jej kanału. Zawsze planuj, jak goroutine zostanie zamknięta
  • Deadlock — dwie goroutines czekające nawzajem na siebie. Go wykryje deadlock w runtime i zakończy program z komunikatem błędu
  • Zamykanie kanału przez odbiorcę — kanał powinien być zamykany tylko przez nadawcę. Wysyłanie na zamknięty kanał powoduje panic

Podsumowanie

Współbieżność w Go jest prosta w użyciu, ale wymaga zrozumienia fundamentów. Goroutines dają lekkość, channels zapewniają bezpieczną komunikację, select umożliwia multipleksowanie, a context kontroluje czas życia operacji.

Najważniejsza rada: zacznij od prostych wzorców i dodawaj złożoność stopniowo. Worker pool i fan-out/fan-in pokrywają większość scenariuszy, jakie spotkasz w typowych aplikacjach webowych.

Chcesz dowiedzieć się więcej o Go lub potrzebujesz pomocy z architekturą współbieżnego systemu? Skontaktuj się z nami.