GoSuda

Princip fungování goroutine v Go: 2KB proměnlivý zásobník a M:N plánovač GMP

By hamori views ...

Goroutine

Když Gopherů požádáte, aby vyprávěli o výhodách golang, často zmíní články týkající se souběžnosti (Concurrency). Jejich základem je goroutine (goroutine), která umožňuje zpracovávat úlohy lehce a jednoduše. K tomuto tématu jsme připravili stručný přehled.

Souběžnost (Concurrency) vs paralelismus (Parallelism)

Než začneme chápat goroutine, chceme nejprve vysvětlit dva pojmy, které se často zaměňují.

  • Souběžnost: Souběžnost se týká zpracování mnoha úloh najednou. Neznamená nutně, že jsou skutečně prováděny současně, ale je to strukturální a logický koncept, při němž jsou více úloh rozděleny na menší části a střídavě prováděny, takže se uživateli jeví, jako by probíhaly současně. Souběžnost je možná i na jednom jádru.
  • Paralelismus: Paralelismus je „zpracování více úloh současně na více jádrech“. Doslova jde o paralelní provádění práce, při kterém se další úlohy spouštějí současně.

Goroutine umožňuje snadno implementovat souběžnost prostřednictvím plánovače běhového prostředí Go a díky nastavení GOMAXPROCS přirozeně využívat i paralelismus.

Vícevláknové zpracování (Multi thread) v Javě, které se hojně využívá, je reprezentativním pojmem paralelismu.

Proč jsou goroutine výhodné?

Lehkost (lightweight)

Náklady na jejich vytvoření jsou ve srovnání s jinými jazyky velmi nízké. Vyvstává otázka, proč golang používá tak málo prostředků? Je to proto, že místo jejich vzniku je spravováno uvnitř běhového prostředí Go. Jsou to totiž lehká logická vlákna, menší než jednotky OS vláken, jejich počáteční zásobník vyžaduje přibližně 2 KB a podle implementace uživatele se dynamicky zvětšuje přidáváním zásobníku.

Protože jsou spravovány po jednotlivých zásobnících, jsou jejich vytváření a odstraňování velmi rychlé a levné, takže běh i několika milionů goroutine nepředstavuje zátěž. Díky plánovači běhového prostředí tak lze minimalizovat zásah jádra OS.

Výkon (performance)

Goroutine díky malému zásahu jádra OS, jak bylo popsáno výše, přepínají kontext na uživatelské úrovni (User-Level) s nižšími náklady než jednotky OS vláken, a proto mohou rychle přepínat úlohy.

Kromě toho používají model M:N, kdy jsou přiřazeny k OS vláknům a spravovány. Vytvořením fondu OS vláken lze zpracovávat úlohy s menším počtem vláken, aniž by bylo potřeba mnoho vláken. Například při čekání, jako je systémové volání, běhové prostředí Go spustí jinou goroutine na daném OS vláknu, takže OS vlákno nezahálí a efektivně využívá CPU, což umožňuje rychlé zpracování.

Díky tomu může Golang dosahovat vysokého výkonu oproti jiným jazykům zejména u I/O operací.

Stručnost (concise)

Velkou výhodou je také to, že souběžné zpracování funkce lze snadno realizovat pouhým klíčovým slovem go.

Je však třeba používat složité zámky, jako jsou Mutex a Semaphore, a při jejich použití nevyhnutelně vzniká nutnost zohledňovat stav uváznutí (DeadLock), což vyžaduje složitější fázi již v návrhu před vývojem.

Goroutine se řídí filozofií „Nesdílejte paměť komunikací, ale komunikujte sdílením paměti“ a doporučuje předávání dat prostřednictvím kanálů (Channel). SELECT se v kombinaci s kanály používá k tomu, aby bylo možné zpracovávat kanál, na němž jsou data připravena, a podporuje tak i tuto funkci. Také pomocí sync.WaitGroup lze jednoduše počkat, až skončí všechny goroutine, což usnadňuje správu pracovního toku. Díky těmto nástrojům lze předcházet problémům závodu o data mezi vlákny a bezpečněji zpracovávat souběžnost.

Dále lze pomocí kontextu (context) na uživatelské úrovni (User-Level) řídit životní cyklus, zrušení, časové limity, termíny a rozsah požadavků, což zaručuje určitou míru stability.

Paralelní práce goroutine (GOMAXPROCS)

Mohla by vás napadnout otázka, zda goroutine podporují i paralelismus, když jsme hovořili o výhodách jejich souběžnosti. V poslední době počet jader CPU dosahuje dvouciferných hodnot, na rozdíl od minulosti, a i domácí počítače obsahují značný počet jader.

Goroutine však provádějí i paralelní práci, a to prostřednictvím GOMAXPROCS.

Pokud nenastavíte GOMAXPROCS, bude nastaven podle verze různě.

  1. Před verzí 1.5: výchozí hodnota 1; pokud je potřeba hodnota vyšší než 1, je nutné nastavit ji způsobem typu runtime.GOMAXPOCS(runtime.NumCPU())

  2. 1.5 až 1.24: změněno na počet všech dostupných logických jader. Od té doby není většinou nutné hodnotu nastavovat, pokud nejsou kladena větší omezení

  3. 1.25: Jako jazyk známý svou prací v kontejnerovém prostředí kontroluje na systému linux cGroup a zjišťuje CPU limit nastavený pro kontejner.

    Pokud má například systém 10 logických jader a hodnota CPU limitu je 5, nastaví GOMAXPROCS na nižší hodnotu 5.

Úprava ve verzi 1.25 má velmi významnou změnu, protože se zvýšila možnost využití jazyka v kontejnerovém prostředí. Díky tomu lze omezit zbytečné vytváření vláken a přepínání kontextu a zabránit CPU throttlingu (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

Plánovač goroutine (model M:N)

Pokud se vrátíme k předchozímu bodu, kde jsme zmínili přiřazení k OS vláknům a správu pomocí modelu M:N, lze jej blíže popsat modelem GMP.

  • G (Goroutine): Nejmenší jednotka práce prováděná v Go
  • M (Machine): OS vlákno (skutečné místo práce)
  • P (Processor): Logický procesor spravovaný běhovým prostředím Go

Jsou to tyto tři prvky. P má navíc lokální frontu spuštění (Local Run Queue) a plní roli plánovače, který přiřazuje G k M. Stručně řečeno, goroutine probíhá následovně.

Postup GMP je následující:

  1. Když je vytvořena G (Goroutine), je přidělena do lokální fronty spuštění P (Processor).
  2. P (Processor) přiřazuje G (Goroutine) z lokální fronty spuštění k M (Machine).
  3. M (Machine) vrací stav G (Goroutine): block, complete nebo preempted.
  4. Krádež práce (Work-Stealing): Pokud se lokální fronta spuštění P vyprázdní, další P nejprve zkontroluje globální frontu. Pokud se ani tam nenachází žádná G (Goroutine), ukradne práci jinému lokálnímu P (Processor), takže všechny M pracují bez přestání.
  5. Zpracování systémového volání (Blocking): Pokud při běhu G (Goroutine) dojde k blokování, M (Machine) přejde do stavu čekání. V tu chvíli se P (Processor) oddělí od blokovaného M (Machine), spojí se s jiným M (Machine) a spustí další G (Goroutine). Díky tomu nedochází k plýtvání CPU ani během čekání na I/O operace.
  6. Pokud jedna G (Goroutine) dlouho zabírá prostředky (preempted), dá prostor pro spuštění jiné G (Goroutine).

Golang provádí GC (Garbage Collector) také na goroutine, takže může paralelně uvolňovat paměť s minimálním přerušením aplikace (STW), a tím efektivně využívat systémové prostředky.

Nakonec, Golang je jedním z výrazných silných stránek jazyka a má mnoho dalších výhod, takže bych si přál, aby si mnoho vývojářů Go užilo.

Děkuji za pozornost.