Podstawy Go Routines
Goroutine
Jeśli zapytamy Gopherów, aby opowiedzieli o zaletach Golang, często pojawia się artykuł dotyczący Concurrency. Podstawą jego treści jest goroutine, którą można łatwo i lekko obsłużyć. Poniżej przedstawiono jej krótkie omówienie.
Concurrency vs Parallelism
Przed zrozumieniem goroutine, chciałbym najpierw wyjaśnić dwie często mylone koncepcje.
- Concurrency: Concurrency dotyczy przetwarzania wielu zadań jednocześnie. Nie oznacza to dosłownie jednoczesnego wykonywania, lecz jest to strukturalna i logiczna koncepcja, w której wiele zadań jest dzielonych na mniejsze jednostki i wykonywanych naprzemiennie, tak aby użytkownikowi wydawało się, że wiele zadań jest przetwarzanych jednocześnie. Concurrency jest możliwa nawet na jednym rdzeniu.
- Parallelism: Parallelism to „przetwarzanie wielu zadań jednocześnie na wielu rdzeniach”. Oznacza to dosłowne równoległe wykonywanie zadań, gdzie różne operacje są uruchamiane w tym samym czasie.
Goroutine ułatwia implementację concurrency za pośrednictwem Go runtime scheduler, a dzięki konfiguracji GOMAXPROCS naturalnie wykorzystuje również parallelism.
Wielowątkowość (Multi thread) w Javie, często charakteryzująca się wysokim wykorzystaniem, jest typowym przykładem koncepcji parallelism.
Dlaczego goroutine jest dobre?
lightweight
Koszt tworzenia jest bardzo niski w porównaniu do innych języków. Pojawia się pytanie, dlaczego Golang zużywa mniej zasobów? Wynika to z faktu, że miejsce tworzenia jest zarządzane wewnętrznie przez Go runtime. Dzieje się tak, ponieważ jest to lekki wątek logiczny, mniejszy niż jednostka wątku OS, a początkowy stos wymaga około 2KB przestrzeni i może dynamicznie zmieniać rozmiar poprzez dodawanie stosu w zależności od implementacji użytkownika.
Zarządzanie jednostkami stosu sprawia, że tworzenie i usuwanie jest bardzo szybkie i tanie, co pozwala na przetwarzanie milionów goroutine bez obciążenia. Dzięki temu Goroutine, dzięki runtime scheduler, może minimalizować interwencję kernela OS.
performance
Po pierwsze, Goroutine, jak wyjaśniono powyżej, ma niewielką interwencję kernela OS, co sprawia, że przełączanie kontekstu na poziomie użytkownika (User-Level) jest tańsze niż w przypadku jednostek wątków OS, co umożliwia szybkie przełączanie zadań.
Ponadto, wykorzystuje model M:N do alokacji i zarządzania wątkami OS. Tworząc pulę wątków OS, możliwe jest przetwarzanie z mniejszą liczbą wątków, bez potrzeby wielu wątków. Na przykład, jeśli system call przejdzie w stan oczekiwania, Go runtime uruchamia inną goroutine na wątku OS, co pozwala wątkowi OS na efektywne wykorzystanie CPU bez przestojów, co z kolei umożliwia szybkie przetwarzanie.
Dzięki temu Golang może osiągnąć wysoką wydajność, zwłaszcza w operacjach I/O, w porównaniu do innych języków.
concise
Dużą zaletą jest również to, że w przypadku potrzeby concurrency, funkcja może być łatwo obsługiwana za pomocą jednego słowa kluczowego go.
Wymaga to użycia skomplikowanych blokad, takich jak Mutex, Semaphore, a użycie blokad nieuchronnie zmusza do rozważenia stanu DeadLock, co wymaga złożonych etapów już na etapie projektowania przed rozpoczęciem rozwoju.
Goroutine, zgodnie z filozofią „nie komunikuj się poprzez dzielenie pamięci, lecz dziel pamięć poprzez komunikację”, zachęca do przekazywania danych za pośrednictwem Channel, a SELECT w połączeniu z Channel obsługuje funkcję umożliwiającą przetwarzanie danych z kanału, który jest gotowy. Ponadto, sync.WaitGroup pozwala łatwo zarządzać przepływem pracy, czekając na zakończenie wszystkich goroutine, co zapobiega problemom z rywalizacją o dane między wątkami i umożliwia bezpieczniejsze przetwarzanie concurrency.
Ponadto, wykorzystanie context pozwala na kontrolę cyklu życia, anulowanie, timeout, deadline i zakres żądania na poziomie użytkownika (User-Level), co zapewnia pewien stopień stabilności.
Parallel Execution of Goroutine (GOMAXPROCS)
Mimo że wspomniano o zaletach concurrency goroutine, może pojawić się pytanie, czy nie obsługuje ona parallelism. Liczba rdzeni CPU w ostatnim czasie przekracza dwucyfrowe wartości, a nawet domowe komputery PC posiadają znaczną liczbę rdzeni.
Jednak Goroutine wykonuje również zadania równoległe, a jest to GOMAXPROCS.
Jeśli GOMAXPROCS nie zostanie ustawione, zostanie ono skonfigurowane inaczej w zależności od wersji.
Przed 1.5: Wartość domyślna 1, wymagane było ustawienie, np.
runtime.GOMAXPOCS(runtime.NumCPU()), jeśli potrzebowano więcej niż 1.1.5 ~ 1.24: Zmieniono na wszystkie dostępne rdzenie logiczne. Od tego momentu programista nie musiał jej ustawiać, chyba że wymagały tego konkretne ograniczenia.
1.25: Jako język popularny w środowiskach kontenerowych, sprawdza cGroup w systemie Linux, aby określić
CPU limitustawiony dla kontenera.Jeśli liczba rdzeni logicznych wynosi 10, a limit CPU wynosi 5,
GOMAXPROCSzostanie ustawione na niższą wartość, czyli 5.
Modyfikacja w wersji 1.25 wprowadza bardzo istotne zmiany. Zwiększyła użyteczność języka w środowiskach kontenerowych. Dzięki temu możliwe stało się zmniejszenie niepotrzebnego tworzenia wątków i przełączania kontekstu, co zapobiegało dławieniu CPU (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
Scheduler Goroutine (Model M:N)
Wracając do poprzedniego tematu, w którym wspomniano o alokacji i zarządzaniu wątkami OS za pomocą modelu M:N, można bardziej szczegółowo opisać model GMP goroutine.
- 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ę uruchomieniową (Local Run Queue) i pełni rolę schedulera, przydzielając przypisane G do M. Krótko mówiąc, goroutine.
Proces działania GMP jest następujący:
- G (Goroutine) po utworzeniu jest przypisywane do lokalnej kolejki uruchomieniowej P (Processor).
- P (Processor) przydziela G (Goroutine) z lokalnej kolejki uruchomieniowej do M (Machine).
- M (Machine) zwraca stan G (Goroutine), taki jak block, complete, preempted.
- Work-Stealing (kradzież pracy): Jeśli lokalna kolejka uruchomieniowa P stanie się pusta, inne P sprawdzają kolejkę globalną. Jeśli tam również nie ma G (Goroutine), kradną pracę z innego lokalnego P (Processor), aby wszystkie M działały bez przerwy.
- Obsługa system call (Blocking): Jeśli G (Goroutine) podczas wykonywania przejdzie w stan Block, M (Machine) przechodzi w stan oczekiwania. Wówczas 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 jedna G (Goroutine) zajmuje zasoby przez długi czas (preempted), daje szansę wykonania innym G (Goroutine).
Golangowy GC (Garbage Collector) również działa na Goroutine, co pozwala na równoległe oczyszczanie pamięci z minimalnym przerwaniem wykonywania aplikacji (STW), co efektywnie wykorzystuje zasoby systemowe.
Na koniec, Golang jest jedną z mocnych stron języka, a oprócz tego ma wiele innych. Mam nadzieję, że wielu programistów będzie czerpać przyjemność z używania Golanga.
Dziękuję.