Princíp fungovania goroutín v jazyku Go: 2KB dynamický zásobník a plánovač M:N GMP

Goroutine
Keď Gopherov požiadate, aby hovorili o výhodách jazyka Golang, často sa objaví článok o súbežnosti (Concurrency). Jeho základom je goroutine (goroutine), ktorú je možné spracovať ľahko a jednoducho. Stručne sme o nej vypracovali tento článok.
Súbežnosť (Concurrency) vs. paralelizmus (Parallelism)
Pred pochopením goroutine si najprv objasníme dva pojmy, ktoré sa často zamieňajú.
- Súbežnosť: Súbežnosť sa týka spracovania mnohých úloh naraz. Nejde o to, že by sa skutočne vykonávali súčasne, ale ide o štrukturálny a logický koncept, pri ktorom sa viaceré úlohy rozdelia na menšie časti a striedavo vykonávajú, takže sa pre používateľa javí, akoby sa spracovávalo viac úloh súčasne. Súbežnosť je možná aj na jednojadrovom procesore.
- Paralelizmus: Paralelizmus je „spracovanie viacerých úloh súčasne na viacerých jadrách“. Doslova ide o paralelný postup prác, pri ktorom sa iné úlohy vykonávajú súčasne.
Goroutine umožňuje jednoducho implementovať súbežnosť prostredníctvom plánovača runtime jazyka Go a prirodzene využíva aj paralelizmus prostredníctvom nastavenia GOMAXPROCS.
Multithreading (Multi thread) v Jave, ktorý má vysoké využitie, je reprezentatívnym pojmom paralelizmu.
Prečo sú goroutine výhodné?
Ľahkosť (lightweight)
Náklady na vytvorenie sú oproti iným jazykom veľmi nízke. Vzniká otázka, prečo Golang používa menej prostriedkov? Dôvodom je, že miesto vytvorenia spravuje runtime jazyka Go. Je to preto, lebo ide o ľahké logické vlákno, ktoré je menšie než jednotka OS vlákna, počiatočný zásobník potrebuje približne 2 KB a podľa implementácie používateľa sa zásobník pridáva a dynamicky mení svoju veľkosť.
Keďže sa správa na úrovni zásobníka, vytváranie a odstraňovanie je veľmi rýchle a lacné, a preto je možné spúšťať aj milióny goroutine bez zbytočnej záťaže. Vďaka plánovaču runtime je tak možné minimalizovať zásah jadra OS.
Výkon (performance)
Goroutine má, ako bolo vysvetlené vyššie, malý zásah jadra OS, a preto je pri prepínaní kontextu na úrovni používateľa (User-Level) náklad nižší než na úrovni OS vlákna, vďaka čomu sa dá práca rýchlo prepínať.
Okrem toho sa využíva model M:N, ktorým sa goroutine priraďujú k OS vláknam a spravujú. Vytvorením fondu OS vlákien je možné spracovávať úlohy aj s malým počtom vlákien bez potreby mnohých vlákien. Napríklad pri čakacom stave, ako je systémové volanie, runtime Go spustí na tomto OS vlákne inú goroutine, takže OS vlákno nezostáva nečinné a efektívne využíva CPU, čo umožňuje rýchle spracovanie.
Vďaka tomu môže Golang dosahovať vysoký výkon najmä pri I/O operáciách v porovnaní s inými jazykmi.
Stručnosť (concise)
Veľkou výhodou je aj to, že pri potrebe súbežnosti možno jednoducho spracovať funkciu jediným kľúčovým slovom go.
Je však potrebné používať zložité zámky ako Mutex a Semaphore, a pri použití zámkov nevyhnutne vzniká potreba zohľadniť stav uviaznutia (DeadLock), čo si vyžaduje zložitejšiu fázu návrhu ešte pred samotným vývojom.
Goroutine sa riadi filozofiou „nezdieľaj pamäť komunikáciou, ale komunikuj zdieľaním pamäte“ („Don't communicate by sharing memory, share memory by communicating“), preto odporúča prenos dát prostredníctvom kanála (Channel), a SELECT spolu s kanálmi podporuje aj funkciu, ktorá umožňuje spracovať kanál, na ktorom sú dáta pripravené. Ďalej, pomocou sync.WaitGroup možno jednoducho čakať, kým skončia všetky goroutine, čo umožňuje jednoducho riadiť pracovný tok. Vďaka týmto nástrojom je možné predchádzať problémom pretekania dát medzi vláknami a bezpečnejšie spracovávať súbežnosť.
Okrem toho možno pomocou kontextu (context) na úrovni používateľa (User-Level) riadiť životný cyklus, zrušenie, časové limity, termíny a rozsah požiadaviek, čo do určitej miery zaručuje stabilitu.
Paralelné spracovanie v Goroutine (GOMAXPROCS)
Hovorili sme o výhodách súbežnosti goroutine, no mnohí sa určite opýtajú, či nepodporuje aj paralelizmus. Počet jadier moderných CPU presahuje dvojciferné čísla a aj domáce počítače obsahujú značný počet jadier.
Goroutine však vykonáva aj paralelnú prácu, a to prostredníctvom GOMAXPROCS.
Ak nenastavíte GOMAXPROCS, nastaví sa podľa verzie rôzne.
Pred verziou 1.5: predvolená hodnota je 1; ak je potrebná hodnota vyššia ako 1, nastavenie je nevyhnutné spôsobom ako
runtime.GOMAXPOCS(runtime.NumCPU())1.5 až 1.24: zmenilo sa na počet všetkých dostupných logických jadier. Od tohto obdobia vývojár nemusí nastavovať hodnotu, pokiaľ nemá výrazné obmedzenia.
1.25: Ako jazyk známy práci v kontajnerových prostrediach kontroluje cGroup v Linuxe a zisťuje limit
CPU, ktorý je nastavený pre kontajner.Ak je počet logických jadier 10 a limit CPU je 5,
GOMAXPROCSsa nastaví na nižšiu hodnotu, t. j. na 5.
Zmena vo verzii 1.25 má veľmi významný dosah, pretože sa zlepšila využiteľnosť jazyka v kontajnerových prostrediach. Vďaka tomu sa dá zabrániť zbytočnému vytváraniu vlákien a prepínaniu kontextu a predchádzať obmedzovaniu 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: začiatok
14 time.Sleep(10 * time.Millisecond) // oneskorenie na simuláciu úlohy
15 fmt.Printf("Goroutine %d: 시작\n", name) // Goroutine %d: začiatok
16}
17
18func main() {
19 runtime.GOMAXPROCS(2) // použitie iba 2 jadier CPU
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("Čakám, kým sa skončia všetky goroutine...")
29 wg.Wait()
30 fmt.Println("Všetky úlohy boli dokončené.")
31
32}
33
Plánovač Goroutine (model M:N)
Ďalej, v časti o priraďovaní k OS vláknam pomocou modelu M:N, ak ideme podrobnejšie, existuje model GMP goroutine.
- G (Goroutine): najmenšia jednotka práce vykonávaná v Go
- M (Machine): OS vlákno (skutočné miesto vykonávania práce)
- P (Processor): logický procesor spravovaný runtime jazyka Go
P má navyše lokálny frontu spúšťania (Local Run Queue) a plní úlohu plánovača, ktorý priraďuje G k M. Stručne povedané, goroutine
Postup činnosti GMP je nasledovný:
- Keď sa vytvorí G (Goroutine), priradí sa do lokálnej fronty spúšťania P (Processor).
- P (Processor) priraďuje G (Goroutine) z lokálnej fronty spúšťania k M (Machine).
- M (Machine) vracia stav G (Goroutine), ktorým môže byť block, complete alebo preempted.
- Work-Stealing (krádež práce): Ak sa lokálna fronta spúšťania P vyprázdni, iné P najprv skontrolujú globálnu frontu. Ak ani tam nie je žiadna G (Goroutine), ukradnú prácu z lokálnej fronty iného P (Processor), aby všetky M neustále pracovali.
- Spracovanie systémového volania (Blocking): Ak dôjde k blokovaniu počas vykonávania G (Goroutine), M (Machine) prejde do stavu čakania, pričom P (Processor) sa oddelí od blokovaného M (Machine) a spojí sa s iným M (Machine), aby vykonal ďalšiu G (Goroutine). Ani počas čakania na I/O operáciu tak nedochádza k plytvaniu CPU.
- Ak jedna G (Goroutine) dlhodobo zaberá výpočtový čas (preempted), dá šancu vykonať sa aj iným G (Goroutine).
Golang vykonáva aj GC (Garbage Collector) nad goroutine, čo umožňuje paralelne uvoľňovať pamäť pri minimálnom prerušení aplikácie (STW), a tak efektívne využívať systémové zdroje.
Na záver, Golang je jedným z výrazných silných stránok jazyka a má ich aj veľa ďalších, preto si želám, aby si mnohí vývojári Go užívali.
Ďakujem.