1.1 Podstawowe pojęcia współbieżności i równoległości
Współbieżność i równoległość to dwa powszechne pojęcia w programowaniu wielowątkowym. Służą one do opisu zdarzeń lub wykonania programu, które mogą występować jednocześnie.
- Współbieżność odnosi się do wielu zadań przetwarzanych w tym samym przedziale czasu, ale tylko jedno zadanie jest wykonywane w danym momencie. Zadania szybko przełączają się między sobą, dając użytkownikowi iluzję równoczesnego wykonania. Współbieżność nadaje się do procesorów jedno rdzeniowych.
- Równoległość odnosi się do rzeczywistego jednoczesnego wykonywania wielu zadań w tym samym czasie, co wymaga wsparcia wielordzeniowych procesorów.
Język Go został zaprojektowany z myślą o współbieżności jako jednym z jego głównych celów. Osiąga efektywne modele programowania współbieżnego poprzez Goroutine i Kanały. Środowisko wykonawcze Go zarządza Goroutine i może planować te Goroutine na wielu wątkach systemowych w celu osiągnięcia przetwarzania równoległego.
1.2 Goroutines w języku Go
Goroutines stanowią podstawowe pojęcie osiągania programowania współbieżnego w języku Go. Są to lekkie wątki zarządzane przez środowisko wykonawcze Go. Z perspektywy użytkownika są podobne do wątków, ale zużywają mniej zasobów i szybciej się uruchamiają.
Cechy Goroutines obejmują:
- Lekkość: Goroutines zajmują mniej pamięci stosu w porównaniu do tradycyjnych wątków, a ich rozmiar stosu może dynamicznie się zwiększać lub zmniejszać w miarę potrzeb.
- Niska nadmiarowość: Nakład przy tworzeniu i usuwaniu Goroutines jest znacznie mniejszy niż ten przy tradycyjnych wątkach.
- Prosty mechanizm komunikacji: Kanały zapewniają prosty i skuteczny mechanizm komunikacji między Goroutines.
- Projekt bez blokowania: Goroutines nie blokują innych Goroutines przed wykonaniem w określonych operacjach. Na przykład, podczas gdy jedna Goroutine czeka na operacje wejścia/wyjścia, inne Goroutines mogą kontynuować wykonanie.
2 Tworzenie i zarządzanie Goroutines
2.1 Jak utworzyć Goroutine
W języku Go możesz łatwo utworzyć Goroutine, używając słowa kluczowego go
. Gdy poprzedzisz wywołanie funkcji słowem kluczowym go
, funkcja będzie wykonywana asynchronicznie w nowej Goroutine.
Przyjrzyjmy się prostemu przykładowi:
package main
import (
"fmt"
"time"
)
// Zdefiniuj funkcję do wydrukowania "Hello"
func sayHello() {
fmt.Println("Cześć")
}
func main() {
// Uruchom nową Goroutine, używając słowa kluczowego go
go sayHello()
// Główna Goroutine czeka przez pewien okres, aby umożliwić wykonanie sayHello
time.Sleep(1 * time.Second)
fmt.Println("Funkcja główna")
}
W powyższym kodzie funkcja sayHello()
będzie wykonywana asynchronicznie w nowej Goroutine. Oznacza to, że funkcja main()
nie będzie czekać na zakończenie sayHello()
przed kontynuacją. Dlatego używamy time.Sleep
, aby wstrzymać wykonywanie głównej Goroutine, umożliwiając wykonanie polecenia drukowania w sayHello
. Jest to tylko w celach demonstracyjnych. W rzeczywistym rozwoju zazwyczaj stosujemy kanały lub inne metody synchronizacji w celu koordynacji wykonania różnych Goroutines.
Uwaga: W praktycznych zastosowaniach,
time.Sleep()
nie powinno być używane do oczekiwania na zakończenie Goroutine, ponieważ nie jest to niezawodny mechanizm synchronizacji.
2.2 Mechanizm harmonogramowania Goroutine
W Go, harmonogramowanie Goroutine jest obsługiwane przez harmonogram środowiska wykonawczego Go, który jest odpowiedzialny za przydział czasu wykonania na dostępnych procesorach logicznych. Harmonogram Go wykorzystuje technologię harmonogramowania M:N
(wiele Goroutine mapowanych na wiele wątków systemowych) w celu uzyskania lepszej wydajności na wielordzeniowych procesorach.
GOMAXPROCS i procesory logiczne
GOMAXPROCS
to zmienna środowiskowa określająca maksymalną liczbę procesorów dostępnych harmonogramowi środowiska wykonawczego, z domyślną wartością będącą liczbą rdzeni CPU na maszynie. Środowisko wykonawcze Go przypisuje jeden wątek systemowy do każdego procesora logicznego. Ustawiając GOMAXPROCS
, możemy ograniczyć liczbę rdzeni wykorzystywanych przez środowisko wykonawcze.
import "runtime"
func init() {
runtime.GOMAXPROCS(2)
}
Powyższy kod ustawia maksymalnie dwa rdzenie do harmonogramowania Goroutine, nawet gdy program jest uruchomiony na maszynie z większą liczbą rdzeni.
Działanie Harmonogramu
Harmonogram działa przy użyciu trzech ważnych jednostek: M (maszyna), P (procesor) i G (Goroutine). M reprezentuje maszynę lub wątek, służącą jako abstrakcja wątków jądra systemu operacyjnego. P reprezentuje zasoby wymagane do wykonania Goroutine. Każde P posiada lokalną kolejkę Goroutine. G reprezentuje konkretną Goroutine, włącznie z jej stosami wykonawczymi, zestawami poleceń i innymi informacjami.
Zasady działania harmonogramu Go to:
- M musi mieć P do wykonania G. Jeśli P nie istnieje, M zostanie zwrócona do pamięci podręcznej wątków.
- Gdy G nie jest zablokowany przez inny G (np. w wywołaniach systemowych), to jak najczęściej działa na tym samym M, pomagając zachować dane lokalne G "gorące" dla bardziej wydajnego wykorzystania pamięci podręcznej procesora.
- Gdy G jest zablokowany, M i P się rozdzielają, a P szuka nowego M lub budzi nowe M, by obsłużyć inne G.
go func() {
fmt.Println("Cześć z Goroutine")
}()
Powyzszy kod demonstruje rozpoczęcie nowej Goroutine, co spowoduje dodanie nowego G do kolejki wykonywania przez harmonogram.
Zezwalanie na Przechwytywanie Goroutine
W początkowych etapach Go używał współpracy harmonogramu, co oznacza, że Goroutines mogły głodzić inne Goroutines, jeśli wykonywały się przez długi czas bez dobrowolnego przekazania kontroli. Teraz harmonogram Go wdraża przechwytujące harmonogramowanie, umożliwiając wstrzymywanie długotrwałych G, aby dać szansę do wykonania innych G.
2.3 Zarządzanie Cyklem Życia Goroutine
Aby zapewnić wytrzymałość i wydajność aplikacji Go, kluczowe jest zrozumienie i właściwe zarządzanie cyklem życia Goroutines. Rozpoczęcie Goroutines jest proste, ale bez właściwego zarządzania może prowadzić do problemów, takich jak wycieki pamięci i wyścigi (race conditions).
Bezpieczne Rozpoczynanie Goroutine
Przed rozpoczęciem Goroutine, upewnij się, że rozumiesz jej obciążenie robocze i charakterystyki czasu wykonywania. Goroutine powinna mieć jasne rozpoczęcie i zakończenie, aby uniknąć tworzenia "osieroconych Goroutines" bez warunków zakończenia.
func worker(done chan bool) {
fmt.Println("Pracuję...")
time.Sleep(time.Second) // symulowanie kosztownego zadania
fmt.Println("Koniec pracy.")
done <- true
}
func main() {
// Tutaj używany jest mechanizm kanału w Go. Kanał można po prostu postrzegać jako podstawową kolejkę wiadomości i używać operatora "<-" do czytania i zapisywania danych w kolejce.
done := make(chan bool, 1)
go worker(done)
// Poczekaj na zakończenie Goroutine
<-done
}
Powyzszy kod pokazuje sposób oczekiwania na zakończenie Goroutine przy użyciu kanału done
.
Uwaga: Ten przykład używa mechanizmu kanału w Go, który zostanie szczegółowo omówiony w późniejszych rozdziałach.
Zatrzymywanie Goroutines
Generalnie, zakończenie całego programu implikuje wyłączenie wszystkich Goroutine. Jednakże, w długotrwałych usługach, możemy potrzebować aktywnie zatrzymać Goroutines.
- Użyj kanałów do wysyłania sygnałów zatrzymywania: Goroutines mogą odpytywać kanały, aby sprawdzić sygnały zatrzymania.
stop := make(chan struct{})
go func() {
for {
select {
case <-stop:
fmt.Println("Otrzymano sygnał zatrzymania. Wyłączanie...")
return
default:
// wykonaj normalną operację
}
}
}()
// Wyślij sygnał zatrzymania
stop <- struct{}{}
-
Użyj pakietu
context
do zarządzania cyklem życia:
ctx, cancel := context.WithCancel(context.Background())
go func(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("Otrzymano sygnał zatrzymania. Wyłączanie...")
return
default:
// wykonaj normalną operację
}
}
}(ctx)
// gdy chcesz zatrzymać Goroutine
cancel()
Korzystanie z pakietu context
pozwala na bardziej elastyczną kontrolę cykli życiowych Goroutines, zapewniając możliwość czasu oczekiwania i anulowania. W dużych aplikacjach lub mikrousługach, context
jest zalecanym sposobem kontroli cykli życiowych Goroutines.