Podstawy goroutines
Goroutine
Jeśli poprosić Gopherów o wymienienie zalet języka Go, często pojawia się artykuł dotyczący równoczesności (Concurrency). Podstawą tego zagadnienia są lekkie i proste w obsłudze goroutines. Poniżej przedstawiono ich krótkie omówienie.
Równoczesność (Concurrency) vs Równoległość (Parallelism)
Zanim zrozumiemy goroutines, chciałbym najpierw wyjaśnić dwie często mylone koncepcje.
- Równoczesność: Równoczesność dotyczy przetwarzania wielu zadań jednocześnie. Nie oznacza to koniecznie, że są one wykonywane w tym samym momencie; jest to raczej koncepcyjna i strukturalna idea, w której wiele zadań jest dzielonych na małe jednostki i wykonywanych naprzemiennie, co sprawia wrażenie, że wiele zadań jest przetwarzanych jednocześnie z perspektywy użytkownika. Równoczesność jest możliwa nawet na pojedynczym rdzeniu.
- Równoległość: Równoległość to „jednoczesne przetwarzanie wielu zadań na wielu rdzeniach”. Mówiąc prościej, jest to równoległe wykonywanie zadań, co oznacza jednoczesne uruchamianie różnych operacji.
Goroutines ułatwiają implementację równoczesności poprzez Go runtime scheduler i naturalnie wykorzystują równoległość poprzez ustawienie GOMAXPROCS
.
Multi-threading w Javie, często charakteryzujący się wysokim wykorzystaniem, jest typowym przykładem koncepcji równoległości.
Dlaczego goroutines są dobre?
Lekkie (lightweight)
Koszt tworzenia jest bardzo niski w porównaniu do innych języków. Pojawia się pytanie, dlaczego Go używa ich w tak niewielkim stopniu? Odpowiedź tkwi w tym, że zarządzanie ich tworzeniem odbywa się wewnętrznie w Go runtime. Dzieje się tak, ponieważ są to lekkie wątki logiczne, mniejsze niż wątki OS, wymagające początkowo około 2 KB stosu, który może dynamicznie zmieniać rozmiar w zależności od implementacji użytkownika.
Zarządzanie na poziomie stosu sprawia, że tworzenie i usuwanie jest bardzo szybkie i tanie, co pozwala na uruchamianie milionów goroutines bez znaczącego obciążenia. Dzięki temu goroutines, dzięki runtime scheduler, mogą minimalizować interwencję jądra OS.
Wysoka wydajność (performance)
Po pierwsze, goroutines, jak wspomniano powyżej, wymagają minimalnej interwencji jądra OS, co sprawia, że przełączanie kontekstu na poziomie użytkownika (User-Level) jest tańsze niż w przypadku wątków OS, umożliwiając szybkie przełączanie zadań.
Ponadto, do zarządzania i przypisywania do wątków OS wykorzystują model M:N. Tworząc pulę wątków OS, możliwe jest przetwarzanie za pomocą mniejszej liczby wątków, bez potrzeby wielu z nich. Na przykład, jeśli goroutine przejdzie w stan oczekiwania, taki jak wywołanie systemowe, Go runtime uruchamia inną goroutine na wątku OS, co pozwala wątkowi OS na efektywne wykorzystanie CPU bez przestojów, co skutkuje szybkim przetwarzaniem.
Dzięki temu Go może osiągać wyższą wydajność w operacjach I/O w porównaniu do innych języków.
Zwięzłe (concise)
Dużą zaletą jest również łatwość obsługi funkcji za pomocą jednego słowa kluczowego go
, gdy wymagana jest współbieżność.
Złożone blokady, takie jak Mutex
i Semaphore
, muszą być używane, a przy ich użyciu nieuniknione jest rozważenie stanu DeadLock, co wymaga złożonych etapów od etapu projektowania przed deweloperem.
Goroutine, zgodnie z filozofią „nie komunikuj się przez dzielenie pamięci, ale dziel pamięć przez komunikację”, zaleca przesyłanie danych poprzez Channel
. SELECT
zaś, w połączeniu z Channel
, umożliwia przetwarzanie danych z kanału, który jest gotowy. Dodatkowo, użycie sync.WaitGroup
pozwala łatwo czekać, aż wszystkie goroutines zakończą pracę, co ułatwia zarządzanie przepływem zadań. Dzięki tym narzędziom można zapobiec problemom z rywalizacją o dane między wątkami i bezpieczniej obsługiwać współbieżność.
Ponadto, wykorzystanie kontekstu (context) do kontroli cyklu życia, anulowania, limitu czasu (timeout), terminu (deadline) i zakresu żądania na poziomie użytkownika (User-Level) zapewnia pewien stopień stabilności.
Równoległe wykonywanie zadań w Goroutine (GOMAXPROCS)
Mimo że wspomniano o zaletach współbieżności goroutines, może pojawić się pytanie, czy nie obsługują one równoległości. W ostatnich latach liczba rdzeni CPU, w przeciwieństwie do przeszłości, przekracza dwucyfrowe wartości, a komputery domowe również posiadają znaczną liczbę rdzeni.
Jednak Goroutine wykonuje również zadania równoległe, a odpowiada za to GOMAXPROCS
.
Jeśli GOMAXPROCS
nie zostanie ustawione, jego wartość będzie różna w zależności od wersji.
Przed 1.5: Wartość domyślna to 1. Jeśli potrzebna jest wartość większa niż 1, konieczne jest ustawienie jej za pomocą metody takiej jak
runtime.GOMAXPOCS(runtime.NumCPU())
.1.5 ~ 1.24: Zostało zmienione na liczbę wszystkich dostępnych rdzeni logicznych. Od tego momentu programista nie musi go ustawiać, chyba że istnieją szczególne wymagania.
1.25: Jako język popularny w środowiskach kontenerowych, sprawdza cGroup w systemie Linux, aby określić
ograniczenie CPU
ustawione dla kontenera.W takim przypadku, jeśli liczba rdzeni logicznych wynosi 10, a ograniczenie CPU wynosi 5,
GOMAXPROCS
zostanie ustawione na niższą wartość, czyli 5.
Zmiana w wersji 1.25 jest bardzo istotna. Zwiększyła ona użyteczność języka w środowiskach kontenerowych. Dzięki temu możliwe jest zmniejszenie niepotrzebnego tworzenia wątków i przełączania kontekstu, zapobiegając dławieniu CPU (throttling).
1package main
2
3import (
4 "fmt"
5 "math/rand"
6 "runtime"
7 "time"
8)
9
10func exe(name int, wg *sync.WaitGroup) {
11 defer wg.Done()
12
13 fmt.Printf("Goroutine %d: 시작\n", name) // Goroutine %d: Start
14 time.Sleep(10 * time.Millisecond) // 작업 시뮬레이션을 위한 지연 // Delay for task simulation
15 fmt.Printf("Goroutine %d: 시작\n", name) // Goroutine %d: Start
16}
17
18func main() {
19 runtime.GOMAXPROCS(2) // CPU 코어 2개만 사용 // Use only 2 CPU cores
20 wg := sync.WaitGroup();
21 goroutineCount := 10
22 wg.Add(goroutineCount)
23
24 for i := 0; i < goroutineCount; i++ {
25 go exe(i, &wg)
26 }
27
28 fmt.Println("모든 goroutine이 끝날 때까지 대기합니다...") // Waiting for all goroutines to finish...
29 wg.Wait()
30 fmt.Println("모든 작업이 완료되었습니다.") // All tasks completed.
31
32}
33
Harmonogram Goroutine (model M:N)
W odniesieniu do wcześniejszego fragmentu dotyczącego modelu M:N wykorzystywanego do przypisywania i zarządzania wątkami OS, można dokładniej omówić model goroutine GMP.
- G (Goroutine): Najmniejsza jednostka pracy wykonywana w Go.
- M (Machine): Wątek OS (rzeczywiste miejsce wykonania pracy).
- P (Processor): Procesor logiczny zarządzany przez Go runtime.
P dodatkowo posiada lokalną kolejkę wykonania (Local Run Queue) i pełni rolę harmonogramu, przydzielając przypisane G do M. W skrócie, goroutine...
Proces działania GMP jest następujący:
- Gdy G (Goroutine) zostanie utworzone, jest przypisywane do lokalnej kolejki wykonania P (Processor).
- P (Processor) przydziela G (Goroutine) z lokalnej kolejki wykonania do M (Machine).
- M (Machine) zwraca stan G (Goroutine): zablokowany (block), zakończony (complete), wywłaszczony (preempted).
- Work-Stealing (kradzież pracy): Jeśli lokalna kolejka wykonania P stanie się pusta, inne P sprawdzają globalną kolejkę. Jeśli tam również nie ma G (Goroutine), kradną pracę od innych lokalnych P (Processor), aby wszystkie M działały bez przerwy.
- Obsługa wywołań systemowych (Blocking): Jeśli G (Goroutine) napotka blokadę podczas wykonywania, M (Machine) przechodzi w stan oczekiwania. W tym momencie P (Processor) oddziela się od zablokowanego M (Machine) i łączy się z innym M (Machine), aby wykonać następne G (Goroutine). W tym czasie nie ma marnowania CPU nawet podczas oczekiwania na operacje I/O.
- Jeśli jedno G (Goroutine) zbyt długo zajmuje procesor (preempted), daje szansę na wykonanie innemu G (Goroutine).
Garbage Collector (GC) w Go również działa na goroutines, co pozwala na równoległe czyszczenie pamięci z minimalnym przerwaniem działania aplikacji (STW), efektywnie wykorzystując zasoby systemowe.
Na koniec, Go jest jednym z największych atutów języka, a poza tym ma wiele innych zalet, dlatego mam nadzieję, że wielu deweloperów będzie cieszyć się Go.
Dziękuję.