Go dla początkujących - Część 6: Współbierzność i goroutines

2/2/2025 Kurs Go

Mateusz Kędziora

image

Witaj w kolejnej części naszego kursu Go! Dzisiaj zajmiemy się jednym z najbardziej ekscytujących i potężnych aspektów języka Go – współbieżnością. Współbieżność pozwala nam na wykonywanie wielu zadań “jednocześnie”, co może znacząco przyspieszyć działanie naszych programów i sprawić, że będą bardziej responsywne.

W Go, kluczowymi elementami współbieżności są goroutines i kanały. W tym artykule wyjaśnimy, czym są te pojęcia, jak ich używać i jak mogą one pomóc w tworzeniu efektywnych aplikacji. Zaczynajmy!

Czym jest Współbieżność?

Zacznijmy od podstaw. Współbieżność (ang. concurrency) to zdolność programu do wykonywania wielu zadań w tym samym czasie. Warto zauważyć, że nie jest to to samo co równoległość (ang. parallelism). Równoległość oznacza faktyczne wykonywanie zadań jednocześnie na różnych rdzeniach procesora, natomiast współbieżność to sposób strukturyzacji programu, który pozwala na przełączanie się między zadaniami, dając iluzję równoczesności.

Go świetnie radzi sobie z współbieżnością, dzięki czemu programy napisane w tym języku mogą efektywnie wykorzystywać zasoby procesora.

Goroutines: Lekkie Wątki Go

Goroutine to funkcja, która jest uruchamiana współbieżnie z innymi funkcjami. Możesz myśleć o goroutines jako o lekkich wątkach. W przeciwieństwie do tradycyjnych wątków, goroutines są znacznie tańsze w utworzeniu i zarządzaniu, co pozwala na uruchomienie tysięcy, a nawet milionów goroutines w jednym programie.

Aby uruchomić funkcję jako goroutine, wystarczy dodać słowo kluczowe go przed wywołaniem funkcji. Zobaczmy przykład:

package main

import (
	"fmt"
	"time"
)

func wypiszLiczby(max int) {
	for i := 0; i < max; i++ {
		fmt.Println("Goroutine:", i)
		time.Sleep(100 * time.Millisecond) // Dodajemy małe opóźnienie, aby było widoczne przełączanie
	}
}

func main() {
	go wypiszLiczby(5) // Uruchomienie funkcji wypiszLiczby jako goroutine
	fmt.Println("Main Goroutine: Start")
	time.Sleep(1 * time.Second) // Czekamy, aż goroutine wykona swoje zadanie
	fmt.Println("Main Goroutine: Koniec")
}

Co robi ten kod?

  1. package main: Definiuje, że kod znajduje się w głównym pakiecie (czyli jest wykonywalnym programem).
  2. import: Importuje pakiety fmt (do drukowania) i time (do zarządzania czasem).
  3. func wypiszLiczby(max int): Definiuje funkcję wypiszLiczby, która przyjmuje argument max (maksymalna liczba) i wypisuje kolejne liczby, zatrzymując się na chwilę pomiędzy każdym wypisaniem.
  4. func main(): Główna funkcja programu.
  5. go wypiszLiczby(5): Uruchamia funkcję wypiszLiczby jako goroutine. Oznacza to, że funkcja ta będzie wykonywana w tle, niezależnie od głównego wątku programu.
  6. fmt.Println("Main Goroutine: Start"): Wyświetla informację o rozpoczęciu głównej goroutine.
  7. time.Sleep(1 * time.Second): Wstrzymuje wykonanie głównej goroutine na 1 sekundę.
  8. fmt.Println("Main Goroutine: Koniec"): Wyświetla informację o zakończeniu głównej goroutine.

Wynik działania:

Uruchomienie tego programu da wynik podobny do poniższego:

Main Goroutine: Start
Goroutine: 0
Goroutine: 1
Goroutine: 2
Goroutine: 3
Goroutine: 4
Main Goroutine: Koniec

Widzimy, że main goroutine najpierw rozpoczyna wykonywanie, po czym wypisują się wartości w goroutine, a na końcu main goroutine kończy swoje działanie. Należy jednak pamiętać, że kolejność wypisywania wartości z goroutine nie jest deterministyczna. Oznacza to, że za każdym uruchomieniem programu kolejność wykonywania może być inna, ponieważ goroutines wykonują się współbieżnie.

Ważne: Gdy główna funkcja (main goroutine) kończy swoje działanie, program również się kończy, nawet jeśli inne goroutines wciąż działają. Dlatego w powyższym przykładzie musieliśmy dodać time.Sleep w main, aby dać goroutine czas na dokończenie zadania.

Kanały: Bezpieczna Komunikacja Między Goroutines

Sama możliwość uruchamiania funkcji jako goroutines to tylko połowa sukcesu. Często potrzebujemy, aby goroutines komunikowały się ze sobą i przesyłały dane. W Go, do tego celu służą kanały (channels).

Kanały to mechanizm, który umożliwia bezpieczne przesyłanie danych między goroutines. Kanał może przesyłać dane określonego typu. Działają one na zasadzie kolejki FIFO (First In, First Out), czyli dane wysłane do kanału są odbierane w tej samej kolejności.

Tworzenie Kanału

Aby utworzyć kanał, używamy słowa kluczowego make wraz ze słowem kluczowym chan, oraz typem danych, jaki będzie przechowywany w kanale:

// Kanał przesyłający liczby całkowite
myChannel := make(chan int)

// Kanał przesyłający ciągi znaków
stringChannel := make(chan string)

Wysyłanie i Odbieranie Danych

Do kanału wysyłamy dane za pomocą operatora <-. Dane odbieramy również za pomocą tego samego operatora:

// Wysyłanie danych do kanału
myChannel <- 10

// Odbieranie danych z kanału
value := <-myChannel

Przykładowy kod z kanałem:

Zobaczmy przykład, który demonstruje wykorzystanie kanałów do komunikacji między goroutines:

package main

import (
	"fmt"
	"time"
)

func kwadrat(liczba int, channel chan int) {
    wynik := liczba * liczba
    channel <- wynik // Wysyłamy wynik do kanału
}

func main() {
	channel := make(chan int) // Tworzymy kanał liczb całkowitych

    liczba := 5
    go kwadrat(liczba, channel) // Uruchamiamy goroutine z obliczeniem kwadratu

    wynik := <-channel // Odbieramy wynik z kanału

    fmt.Printf("Kwadrat liczby %d to %d\n", liczba, wynik)
}

Co robi ten kod?

  1. package main, import: Tak samo jak w poprzednim przykładzie, definiuje pakiet główny i importuje niezbędne biblioteki.
  2. func kwadrat(liczba int, channel chan int): Definiuje funkcję kwadrat, która przyjmuje argument liczba (liczba całkowita) i channel (kanał do przesyłania liczb całkowitych). Oblicza kwadrat liczby i wysyła wynik do kanału.
  3. func main(): Główna funkcja programu.
  4. channel := make(chan int): Tworzy kanał channel, który będzie przesyłał dane typu int.
  5. liczba := 5: Definiuje zmienną liczba i przypisuje jej wartość 5.
  6. go kwadrat(liczba, channel): Uruchamia funkcję kwadrat jako goroutine, przekazując do niej liczbę i kanał.
  7. wynik := <-channel: Odbiera wynik z kanału i przypisuje go do zmiennej wynik. W tym momencie program czeka, aż do kanału wpłynie jakaś wartość.
  8. fmt.Printf(...): Wyświetla wynik.

Wynik działania:

Kwadrat liczby 5 to 25

W tym przykładzie, goroutine kwadrat oblicza kwadrat liczby i wysyła go do kanału, a main goroutine odbiera ten wynik z kanału. Kanały synchronizują goroutines i zapewniają, że wynik zostanie odebrany dopiero po obliczeniu.

Buforowane Kanały

W poprzednim przykładzie użyliśmy kanału bez bufora. Oznacza to, że wysłanie danych do kanału (np. channel <- wynik) zatrzymuje goroutine wysyłającą, dopóki inna goroutine nie odbierze danych z kanału.

Możemy jednak tworzyć kanały buforowane, które pozwalają na wysłanie określonej liczby danych do kanału bez natychmiastowego odbierania. Aby utworzyć buforowany kanał, musimy podać pojemność bufora jako drugi argument funkcji make:

// Buforowany kanał o pojemności 3
bufferedChannel := make(chan int, 3)

Oto przykład wykorzystania buforowanego kanału:

package main

import (
	"fmt"
	"time"
)

func wysylaj(channel chan int) {
    for i := 0; i < 3; i++ {
        fmt.Println("Wysyłam:", i)
        channel <- i // Wysyłamy dane do kanału
    }
    close(channel) // Zamykamy kanał
}

func odbieraj(channel chan int) {
    for liczba := range channel { // Odbieramy dane z kanału aż do jego zamknięcia
        fmt.Println("Odebrano:", liczba)
        time.Sleep(500 * time.Millisecond) // Symulujemy prace
    }
}

func main() {
	channel := make(chan int, 3) // Tworzymy buforowany kanał o pojemności 3

    go wysylaj(channel) // Uruchamiamy goroutine wysyłającą dane
    go odbieraj(channel) // Uruchamiamy goroutine odbierającą dane

	time.Sleep(2 * time.Second) // Pozwalamy goroutines na dokończenie pracy
    fmt.Println("Koniec")
}

Co robi ten kod?

  1. func wysylaj(channel chan int): Funkcja wysyła trzy liczby do kanału.
  2. close(channel): Zamyka kanał, informując odbiorcę, że nie będą już przesyłane żadne dane.
  3. func odbieraj(channel chan int): Funkcja odbiera dane z kanału za pomocą pętli range. Pętla ta działa, dopóki kanał nie zostanie zamknięty.
  4. channel := make(chan int, 3): Tworzy buforowany kanał o pojemności 3.
  5. time.Sleep(2 * time.Second): Pozwala na dokończenie pracy obu goroutines.

Wynik działania:

Wysyłam: 0
Wysyłam: 1
Wysyłam: 2
Odebrano: 0
Odebrano: 1
Odebrano: 2
Koniec

Buforowanie kanału pozwala goroutine wysyłającej na wysłanie danych, dopóki bufor nie zostanie zapełniony, bez konieczności natychmiastowego odbioru tych danych. Jest to szczególnie przydatne, gdy procesy wysyłania i odbierania działają z różnymi prędkościami.

Praca Domowa

Aby utrwalić zdobytą wiedzę, wykonaj następujące zadanie:

  1. Napisz program, który uruchomi dwie goroutines.
  2. Pierwsza goroutine ma za zadanie generować losowe liczby i wysyłać je do kanału.
  3. Druga goroutine ma za zadanie odbierać te liczby z kanału i obliczać ich pierwiastek kwadratowy.
  4. Główna goroutine powinna na koniec wypisać, ile liczb zostało wygenerowanych, obliczonych i wyniki obliczeń.
  5. Użyj buforowanego kanału.
  6. Przetestuj program z różnymi ilościami liczb generowanych, różnymi pojemnościami kanału i sprawdź, jak to wpływa na szybkość działania.

Podsumowanie

W tym artykule omówiliśmy podstawy współbieżności w Go, a w szczególności goroutines i kanały.

  • Goroutines umożliwiają wykonywanie wielu zadań jednocześnie, co zwiększa efektywność programów.
  • Kanały umożliwiają bezpieczną komunikację i synchronizację między goroutines, co jest kluczowe w programowaniu współbieżnym.

Zachęcamy do dalszego eksperymentowania z goroutines i kanałami. Im więcej będziesz praktykować, tym lepiej zrozumiesz, jak wykorzystać te potężne narzędzia w swoich programach.

Pamiętaj, aby śledzić kolejne posty z naszego kursu Go. W kolejnych artykułach będziemy omawiać bardziej zaawansowane zagadnienia związane ze współbieżnością i innymi aspektami języka Go.

Przydatne Linki

Aby pomóc Ci w dalszej nauce, oto lista przydatnych linków:

  1. Oficjalna Dokumentacja Go:
    • A Tour of Go - Interaktywny kurs wprowadzający do Go, z sekcją o współbieżności.
    • Effective Go - Dokument zawierający wskazówki dotyczące pisania dobrego kodu Go, w tym rozdział o współbieżności.
    • Go Documentation - Pełna dokumentacja języka Go.
    • The Go Memory Model - Szczegółowe informacje o modelu pamięci w Go, przydatne przy zaawansowanej pracy ze współbieżnością.

Dziękuję za poświęcony czas i zapraszam do kolejnych artykułów!

Polecane artykuły