Go goroutine(goroutine)működési elve: 2 KB-os változó méretű verem és az M:N GMP ütemező

Goroutine
A Gopher-eknek a golang előnyeiről szólva gyakran előkerül a párhuzamosság (Concurrency) témája. Ennek alapja a könnyű és egyszerűen kezelhető goroutine (goroutine). Ezzel kapcsolatban röviden összefoglaltam a lényeget.
Concurrency (párhuzamosság) vs Parallelism (párhuzamos végrehajtás)
Mielőtt rátérnénk a goroutine-ra, először két gyakran összetévesztett fogalmat tisztázok.
- Concurrency: A concurrency arról szól, hogy sok feladatot kezelünk egyszerre. Nem feltétlenül jelenti azt, hogy valóban egyidejűleg futnak, hanem egy strukturális, logikai koncepció, amelyben a munkákat kis egységekre bontjuk, és váltakozva hajtjuk végre őket, így a felhasználó számára úgy tűnik, mintha több feladat egyszerre történne. Egyetlen magon is lehetséges a concurrency.
- Parallelism: A parallelism azt jelenti, hogy „több feladatot egyidejűleg, több magon végezünk”. Szó szerint a munkák párhuzamos végrehajtásáról van szó, amelyben más feladatok egyszerre futnak.
A goroutine a Go futtatókörnyezeti ütemezőn keresztül könnyen megvalósítható concurrency-t biztosít, a GOMAXPROCS beállítással pedig természetesen kihasználja a parallelismet is.
A Java többszálú (Multi thread) megoldása, amely széles körben elterjedt, a parallelism reprezentatív fogalma.
Miért jók a goroutine-ok?
Könnyűsúlyú (lightweight)
Létrehozási költsége jóval alacsonyabb, mint más nyelveké. Felmerülhet a kérdés, miért használ a golang kevesebbet, és a válasz az, hogy a létrehozást a Go futtatókörnyezete kezeli belsőleg. Ennek oka, hogy könnyűsúlyú logikai szálról van szó, amely kisebb az OS-szál egységénél; kezdeti verme körülbelül 2KB méretű, és a megvalósítástól függően a verem dinamikusan bővülhet és változhat.
Mivel verem egységekben kezelik, a létrehozás és megszüntetés nagyon gyors és olcsó, így akár több millió goroutine futtatása sem jelent terhet. Ennek köszönhetően a Goroutine a futtatókörnyezeti ütemezőnek hála minimalizálhatja az OS kernel beavatkozását.
Jó teljesítmény (performance)
Először is, mint fentebb leírtam, a Goroutine kevesebb OS kernel beavatkozást igényel, ezért felhasználói szinten (User-Level) történő kontextusváltás költsége alacsonyabb az OS-szál egységéhez képest, így a feladatok gyorsan átválthatók.
Emellett az M:N modellt alkalmazva OS-szálakhoz rendeli és kezeli őket. OS-szálkészletet hozunk létre, így nincs szükség sok szálra, kevesebb szállal is megoldható a feladat. Például rendszerhívás vagy más várakozási állapot esetén a Go futtatókörnyezete az OS-szálon másik goroutine-t futtat, így az OS-szál megállás nélkül, hatékonyan használja a CPU-t, és gyors feldolgozás válik lehetővé.
Ennek következtében a Golang különösen az I/O műveleteknél képes magasabb teljesítményt nyújtani más nyelvekhez képest.
Tömör (concise)
Nagy előny, hogy ha párhuzamosságra van szükség, egyetlen go kulcsszóval egyszerűen indíthatunk el egy függvényt.
Összetett zárakat, például Mutex-et és Semaphore-t kell használni, és zárak alkalmazásakor szükségszerűen figyelembe kell venni a holtpont (DeadLock) állapotot, ami már a tervezési fázisban összetett lépéseket igényel.
A Goroutine a „ne osszunk meg memóriát a kommunikáció érdekében, hanem kommunikáljunk a memória megosztásáért” elv szerint a csatornán (Channel) keresztüli adatátvitelt javasolja, a SELECT pedig a csatornákkal együttműködve lehetővé teszi, hogy azok a csatornák kerüljenek először feldolgozásra, amelyeken már készen áll az adat. Emellett a sync.WaitGroup használatával egyszerűen megvárhatjuk, amíg több goroutine befejeződik, ami megkönnyíti a munkafolyamatok kezelését. Ezeknek az eszközöknek köszönhetően megelőzhető a szálak közötti adatversenyhelyzet, és biztonságosabban valósítható meg a concurrency.
Továbbá a kontextus (context) segítségével felhasználói szinten (User-Level) szabályozható az élettartam, a megszakítás, az időtúllépés, a határidő és a kérés hatóköre, így bizonyos mértékű stabilitás is biztosítható.
A Goroutine párhuzamos működése (GOMAXPROCS)
Felmerülhet a kérdés, hogy ha a goroutine concurrency-je jó, vajon támogatja-e a párhuzamosságot is. A mai CPU-k magszáma a korábbiakkal ellentétben már a két számjegyű tartományt is elérheti, és a házi PC-kbe is sok mag kerül.
A Goroutine azonban a párhuzamos feldolgozást is végrehajtja, és ezt a GOMAXPROCS teszi lehetővé.
Ha nem állítjuk be a GOMAXPROCS értékét, az verziónként eltérően alakul.
1.5 előtt: az alapérték 1, 1-nél nagyobb érték szükség esetén kötelező beállítani, például
runtime.GOMAXPOCS(runtime.NumCPU())módon1.5 ~ 1.24: az összes elérhető logikai magszámra változott. Ettől kezdve a fejlesztőnek nincs szüksége beállításra, hacsak nincs jelentős korlátozás
1.25: a konténeres környezetben való elterjedtségéhez méltóan a linuxon lévő cGroup-ot ellenőrzi, és megnézi a konténerre beállított
CPU korlátot.Ebben az esetben, ha a logikai magok száma 10, a CPU-korlát értéke pedig 5, a
GOMAXPROCSaz alacsonyabb értékre, 5-re áll be.
Az 1.25-ös módosítás nagyon jelentős változtatás, mert javult a nyelv használhatósága konténeres környezetben. Ennek következtében elkerülhető a felesleges szállétrehozás és kontextusváltás, így megelőzhető a 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
A Goroutine ütemezője (M:N modell)
Az előző részben említett, OS-szálakhoz rendelést és kezelést az M:N modell valósítja meg. Ha ezt részletesebben nézzük, a goroutine GMP modelljéhez jutunk.
- G (Goroutine): A Go-ban futtatható legkisebb munkaegység
- M (Machine): OS-szál (a tényleges végrehajtási hely)
- P (Processor): A Go futtatókörnyezete által kezelt logikai processzor
Ezek a fogalmak. A P emellett helyi futtatási sorral (Local Run Queue) rendelkezik, és ütemezőként működik, amely a hozzárendelt G-ket az M-hez rendeli. Röviden: a goroutine
A GMP működése a következő lépésekből áll:
- Amikor egy G (Goroutine) létrejön, hozzárendelésre kerül a P (Processor) helyi futtatási sorához.
- A P (Processor) a helyi futtatási sorban lévő G (Goroutine)-t hozzárendeli egy M (Machine)-hez.
- Az M (Machine) visszaadja a G (Goroutine) állapotát: block, complete vagy preempted.
- Work-Stealing (munkalopás): Ha egy P helyi futtatási sora kiürül, a többi P a globális sort ellenőrzi. Ha ott sincs G (Goroutine), más P (Processor) helyi munkáját lopja el, így minden M megállás nélkül működik.
- Rendszerhívás kezelése (Blocking): Ha a G (Goroutine) futás közben blokkolódik, az M (Machine) várakozó állapotba kerül, ekkor a P (Processor) leválasztódik a blokkolt M-ről, és egy másik M-hez kapcsolódva futtatja a következő G (Goroutine)-t. Ilyenkor az I/O művelet várakozási idején sincs CPU-pazarlás.
- Ha egy G (Goroutine) hosszú ideig foglalja a processzort (preempted), a többi G (Goroutine) is kap futási lehetőséget.
A Golang GC (Garbage Collector) működése is a goroutine-okon belül történik, így a memória párhuzamosan tisztítható úgy, hogy az alkalmazás futása a lehető legkisebb mértékben szakad meg (STW), ami hatékonyan használja a rendszererőforrásokat.
Végül, a Golang a nyelv egyik erős előnye, és ezen kívül még sok más is van benne, ezért kívánom, hogy sok fejlesztő élvezze a Go használatát.
Köszönöm a figyelmet.