GoSuda

Zasada działania goroutine w Go: dynamiczny stos o rozmiarze 2KB oraz harmonogram M:N GMP

By hamori views ...

Goroutine

Gdy poprosi się Gopherów o opowiedzenie o zaletach języka Go, często pojawia się temat związany z współbieżnością (Concurrency). Jego podstawą jest lekka i prosta w obsłudze goroutine (goroutine). Poniżej przedstawiono krótki opis tego zagadnienia.

Współbieżność (Concurrency) kontra równoległość (Parallelism)

Zanim przejdziemy do zrozumienia goroutine, chcemy omówić dwa pojęcia, które są często mylone.

  • Współbieżność: Współbieżność dotyczy wykonywania wielu zadań jednocześnie. Nie oznacza to faktycznego równoczesnego wykonywania, lecz jest to koncepcja strukturalna i logiczna, polegająca na podziale wielu zadań na małe jednostki i naprzemiennym ich wykonywaniu, dzięki czemu użytkownikowi wydaje się, że wiele zadań jest przetwarzanych równocześnie. Współbieżność jest możliwa także na pojedynczym rdzeniu.
  • Równoległość: Równoległość oznacza „przetwarzanie wielu zadań jednocześnie na wielu rdzeniach”. Dosłownie oznacza to równoległe prowadzenie prac, czyli jednoczesne wykonywanie różnych zadań.

Goroutine umożliwia łatwą implementację współbieżności dzięki harmonogramowi (scheduler) środowiska uruchomieniowego Go, a poprzez ustawienie GOMAXPROCS w sposób naturalny wykorzystuje również równoległość.

Wielowątkowość (Multi thread) w Javie, która jest powszechnie stosowana, stanowi reprezentatywne pojęcie równoległości.

Dlaczego goroutine są dobre?

Lekkość (lightweight)

Koszt tworzenia jest znacznie niższy niż w innych językach. Pojawia się tu pytanie, dlaczego Go zużywa go mało? Wynika to z tego, że miejsce ich tworzenia jest zarządzane wewnątrz środowiska uruchomieniowego Go. Dzieje się tak, ponieważ są to lekkie wątki logiczne, mniejsze niż jednostka wątku systemu operacyjnego (OS thread), a ich początkowy stos wymaga około 2KB; dodatkowo, zgodnie z implementacją użytkownika, stos może być dynamicznie powiększany.

Ponieważ zarządzanie odbywa się na poziomie stosu, tworzenie i usuwanie jest bardzo szybkie i tanie, dzięki czemu uruchomienie nawet milionów goroutine nie stanowi dużego obciążenia. Dzięki harmonogramowi środowiska uruchomieniowego goroutine mogą w znacznym stopniu ograniczyć ingerencję jądra systemu operacyjnego (OS kernel).

Wydajność (performance)

Przede wszystkim, jak wyjaśniono powyżej, goroutine wymagają niewielkiej ingerencji jądra systemu operacyjnego, dlatego przełączanie kontekstu na poziomie użytkownika (User-Level) jest tańsze niż w przypadku wątków systemu operacyjnego, co pozwala szybko przełączać zadania.

Ponadto, wykorzystując model M:N, goroutine są przydzielane i zarządzane na wątkach systemu operacyjnego. Tworzona jest pula wątków OS, dzięki czemu nie trzeba używać wielu wątków; możliwe jest przetwarzanie przy użyciu niewielkiej liczby wątków. Na przykład, gdy dochodzi do stanu oczekiwania, takiego jak wywołanie systemowe (system call), środowisko uruchomieniowe Go wykonuje inną goroutine na wątku OS, dzięki czemu wątek OS nie pozostaje bezczynny, efektywnie wykorzystuje CPU i umożliwia szybkie przetwarzanie.

Dzięki temu Golang może osiągać wysoką wydajność w porównaniu z innymi językami, szczególnie w operacjach I/O.

Zwięzłość (concise)

Dużą zaletą jest również to, że współbieżność w razie potrzeby można łatwo zrealizować za pomocą jednego słowa kluczowego go.

Trzeba korzystać ze skomplikowanych blokad, takich jak Mutex czy Semaphore, a stosowanie blokad wymusza uwzględnienie stanu zakleszczenia (DeadLock), co sprawia, że już na etapie projektowania, przed rozpoczęciem wytwarzania, potrzebny jest złożony proces.

Goroutine, zgodnie z filozofią „nie współdziel pamięci, aby się komunikować; komunikuj się, aby współdzielić pamięć”, zaleca przekazywanie danych za pośrednictwem kanałów (Channel), a SELECT w połączeniu z kanałami (Channel) obsługuje nawet możliwość przetwarzania kanału, na którym dane są już gotowe. Ponadto, używając sync.WaitGroup, można prosto zaczekać, aż zakończą się wszystkie goroutine, co ułatwia zarządzanie przepływem pracy. Dzięki tym narzędziom można zapobiegać problemom wyścigu danych między wątkami i bezpieczniej realizować współbieżność.

Ponadto, za pomocą kontekstu (context) można na poziomie użytkownika (User-Level) kontrolować cykl życia, anulowanie, limity czasu, terminy oraz zakres żądań, co pozwala zapewnić pewien poziom stabilności.

Równoległe wykonywanie w Goroutine (GOMAXPROCS)

Być może pojawia się pytanie, czy skoro omówiono zalety współbieżności goroutine, to czy nie obsługują one także równoległości? Liczba rdzeni współczesnych procesorów CPU, w przeciwieństwie do przeszłości, przekracza już dziesiątki, a także komputery domowe mają ich całkiem sporo.

Goroutine jednak wykonują również pracę równoległą, a służy do tego GOMAXPROCS.

Jeśli nie ustawi się GOMAXPROCS, jego wartość domyślna zależy od wersji.

  1. Przed wersją 1.5: wartość domyślna to 1; jeśli potrzebna jest wartość większa niż 1, konieczne jest ustawienie jej w sposób taki jak runtime.GOMAXPOCS(runtime.NumCPU())

  2. Wersje 1.5 ~ 1.24: zmieniono na liczbę wszystkich dostępnych logicznych rdzeni. Od tego momentu, jeśli nie ma szczególnych ograniczeń, programista nie musi tego ustawiać

  3. Wersja 1.25: zgodnie z zasadami języka popularnego w środowiskach kontenerowych, sprawdza się cGroup w systemie Linux, aby ustalić limit CPU skonfigurowany dla kontenera.

    Wtedy, jeśli liczba logicznych rdzeni wynosi 10, a limit CPU to 5, GOMAXPROCS jest ustawiane na niższą wartość, czyli 5.

Modyfikacja w wersji 1.25 ma bardzo duże znaczenie. Wynika to z tego, że wzrosła użyteczność języka w środowiskach kontenerowych. Dzięki temu można ograniczyć zbędne tworzenie wątków i przełączanie kontekstu, co pozwala zapobiegać throttlingowi 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)
14	time.Sleep(10 * time.Millisecond) // 作業シミュレーションのための遅延
15	fmt.Printf("Goroutine %d: 시작\n", name)
16}
17
18func main() {
19	runtime.GOMAXPROCS(2) // CPUコアを2つだけ使用
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이 끝날 때까지 대기합니다...")
29	wg.Wait()
30	fmt.Println("모든 작업이 완료되었습니다.")
31
32}
33

Harmonogram Goroutine (model M:N)

Jeśli bardziej szczegółowo omówić wcześniej wspomniany fragment dotyczący modelu M:N, w którym goroutine są przydzielane i zarządzane na wątkach OS, to istnieje model GMP goroutine.

  • G (Goroutine): najmniejsza jednostka pracy wykonywana w Go
  • M (Machine): wątek OS (faktyczne miejsce wykonania pracy)
  • P (Processor): logiczny procesor zarządzany przez środowisko uruchomieniowe Go

Oto wszystko. P dodatkowo posiada lokalną kolejkę wykonawczą (Local Run Queue) i pełni rolę harmonogramu, który przydziela przypisane G do M. Krótko mówiąc, goroutine

Proces działania GMP przedstawia się następująco:

  1. Gdy G (Goroutine) zostanie utworzona, jest przydzielana do lokalnej kolejki wykonawczej P (Processor)
  2. P (Processor) przydziela G (Goroutine) z lokalnej kolejki wykonawczej do M (Machine)
  3. M (Machine) zwraca stan G (Goroutine): block, complete lub preempted
  4. Work-Stealing (kradzież pracy): jeśli lokalna kolejka wykonawcza P jest pusta, inny P najpierw sprawdza kolejkę globalną. Jeśli i tam nie ma G (Goroutine), kradnie pracę z lokalnego P (Processor), dzięki czemu wszystkie M działają bez przerwy.
  5. Obsługa wywołań systemowych (Blocking): jeśli podczas wykonywania G (Goroutine) wystąpi blokada, M (Machine) przechodzi w stan oczekiwania, a wtedy P (Processor) zostaje odłączony od zablokowanego M (Machine), łączy się z innym M (Machine) i wykonuje kolejną G (Goroutine). Dzięki temu nawet podczas oczekiwania na operacje I/O nie dochodzi do marnowania CPU.
  6. Jeśli jedna G (Goroutine) zajmuje procesor przez długi czas (preempted), inne G (Goroutine) otrzymują szansę na wykonanie.

Golang wykonuje również GC (Garbage Collector) na goroutine, dzięki czemu może porządkować pamięć równolegle, minimalnie wstrzymując działanie aplikacji (STW), co pozwala efektywnie wykorzystywać zasoby systemowe.

Na koniec, Golang jest jedną z mocnych stron języka i ma wiele innych zalet, dlatego mam nadzieję, że wielu programistów będzie cieszyć się pracą z Go.

Dziękuję.