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 -racedo 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.