Go 고루틴(Goroutine)의 동작 원리: 2KB 가변 스택과 M:N GMP 스케줄러

Goroutine
När Gophers ombeds berätta om Golangs fördelar dyker ofta upp en artikel om samtidighet (Concurrency). Grunden för innehållet är goroutinen (goroutine), som kan hantera uppgifter lätt och enkelt. Här har jag kortfattat sammanfattat detta.
Samtidighet (Concurrency) kontra parallellitet (Parallelism)
Innan vi förstår goroutinen vill vi först klargöra två begrepp som ofta blandas ihop.
- Samtidighet: Samtidighet handlar om att hantera många uppgifter samtidigt. Det innebär inte nödvändigtvis att de faktiskt körs samtidigt, utan är ett strukturellt och logiskt koncept där flera uppgifter delas upp i mindre enheter och körs växelvis, vilket ger intrycket för användaren att flera uppgifter behandlas samtidigt. Samtidighet är möjlig även på en enda kärna.
- Parallellitet: Parallellitet innebär att "flera uppgifter behandlas samtidigt på flera kärnor". Bokstavligen innebär det att arbete utförs parallellt, där andra uppgifter körs samtidigt.
Goroutinen gör det enkelt att implementera samtidighet genom Go-runtimens schemaläggare, och genom inställningen GOMAXPROCS utnyttjas även parallellitet på ett naturligt sätt.
Java:s flertrådning (Multi thread), som används i stor utsträckning, är ett typiskt exempel på parallellitet.
Varför är goroutiner bra?
Lättvikt (lightweight)
Skapandekostnaden är mycket lägre än i andra språk. Man kan då undra varför Golang använder så få, och svaret är att skapandet hanteras inom Go-runtimen. Eftersom den är en lättviktig logisk tråd är den mindre än en OS-tråd, startstacken kräver cirka 2 KB, och den kan dynamiskt växa genom att stack läggs till beroende på implementationen.
Eftersom stackar hanteras som enheter är skapande och borttagning mycket snabba och billiga, vilket gör det möjligt att köra miljontals goroutiner utan att det blir betungande. Därmed kan Goroutinen, tack vare runtime-schemaläggaren, minimera inblandningen från OS-kärnan.
Prestanda (performance)
Först och främst, eftersom Goroutinen som beskrivits ovan har lite inblandning från OS-kärnan, är kontextväxling på användarnivå (User-Level) billigare än per OS-tråd, vilket gör att uppgifter kan bytas snabbt.
Dessutom använder den M:N-modellen för att tilldela goroutiner till OS-trådar och hantera dem. Genom att skapa en OS-trådpool behövs inte många trådar, och bearbetning är möjlig med få trådar. Till exempel, när ett väntetillstånd uppstår, såsom ett systemanrop, kör Go-runtimen en annan goroutin på OS-tråden, så att OS-tråden kan använda CPU:n effektivt utan att vila och därmed bearbeta snabbt.
Detta gör att Golang kan uppnå högre prestanda än andra språk, särskilt vid I/O-arbete.
Koncis (concise)
Det är också en stor fördel att en funktion kan köras samtidigt enkelt med endast nyckelordet go när samtidighet behövs.
Man måste använda komplexa lås som Mutex och Semaphore, och när lås används är man oundvikligen tvungen att ta hänsyn till deadlock-tillståndet (DeadLock), vilket gör att komplexa steg krävs redan i designfasen före utvecklingen.
Goroutinen följer filosofin "Kommunicera genom att dela minne, dela inte minne genom att kommunicera" och rekommenderar dataöverföring via kanal (Channel). SELECT kombineras med kanaler och stödjer till och med funktionen att bearbeta den kanal där data först är redo. Dessutom kan man med sync.WaitGroup enkelt vänta tills alla goroutiner har avslutats, vilket gör att arbetsflödet kan hanteras enkelt. Tack vare dessa verktyg kan datakapplöpning mellan trådar förhindras och samtidighet hanteras säkrare.
Dessutom kan man med context kontrollera livscykel, avbrytning, timeout, deadline och begärans omfång på användarnivå (User-Level), vilket ger en viss grad av stabilitet.
Parallellt arbete i Goroutinen (GOMAXPROCS)
Efter att ha beskrivit fördelarna med goroutinens samtidighet kanske du undrar om den inte stöder parallellitet. Antalet CPU-kärnor i moderna processorer har ökat till tvåsiffriga tal jämfört med tidigare, och även hemdatorer har ofta ett stort antal kärnor.
Goroutinen utför dock även parallellt arbete, och det görs via GOMAXPROCS.
Om GOMAXPROCS inte anges sätts det olika beroende på version.
Före 1.5: standardvärdet är 1, och om fler än 1 behövs måste det ställas in på ett sätt som
runtime.GOMAXPOCS(runtime.NumCPU())1.5 ~ 1.24: Det ändrades till att använda alla tillgängliga logiska kärnor. Från och med då behöver utvecklaren vanligtvis inte ställa in det, om det inte finns särskilda begränsningar.
1.25: Passande för ett välkänt språk i containermiljöer, kontrollerar det cGroup i Linux för att identifiera den
CPU-begränsningsom satts för containern.Om antalet logiska kärnor är 10 och CPU-begränsningen är 5 sätts
GOMAXPROCStill det lägre värdet 5.
Ändringen i 1.25 är en mycket stor förändring, eftersom språkets användbarhet i containermiljöer har ökat. Därmed kan onödig trådskapande och kontextväxling minskas, vilket förhindrar CPU-trottling (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
Goroutinens schemaläggare (M:N-modellen)
Det som nämndes tidigare om att tilldela goroutiner till OS-trådar med M:N-modellen kan förtydligas med goroutinens GMP-modell.
- G (Goroutine): Den minsta arbetsenheten som körs i Go
- M (Machine): OS-tråd (den faktiska exekveringsplatsen)
- P (Processor): Den logiska processen som hanteras av Go-runtimen
P har dessutom en lokal exekveringskö (Local Run Queue) och fungerar som schemaläggare genom att tilldela tilldelade G till M. Kortfattat fungerar goroutinen enligt följande.
GMP:s arbetsflöde är enligt nedan.
- När en G (Goroutine) skapas tilldelas den till den lokala exekveringskön i P (Processor).
- P (Processor) tilldelar G (Goroutine) i den lokala exekveringskön till M (Machine).
- M (Machine) returnerar G (Goroutine)s tillstånd: block, complete eller preempted.
- Work-Stealing (arbetsstöld): Om P:s lokala exekveringskö blir tom kontrollerar en annan P den globala kön. Om det inte heller finns någon G där, stjäl den arbete från en annan lokal P, så att alla M arbetar utan att vila.
- Systemanropshantering (Blocking): Om en Block uppstår medan G (Goroutine) körs, hamnar M (Machine) i väntetillstånd. Då separerar P (Processor) sig från den blockerade M (Machine), kopplas samman med en annan M (Machine) och kör nästa G (Goroutine). På så sätt uppstår ingen CPU-förlust ens under väntetid för I/O-arbete.
- Om en G (Goroutine) monopoliserar exekveringen under lång tid (preempted) ges andra G (Goroutine) en möjlighet att köras.
Golang kör också GC (Garbage Collector) ovanpå goroutiner, vilket gör att minnet kan städas parallellt samtidigt som applikationens exekvering stoppas så lite som möjligt (STW), och därmed används systemresurser effektivt.
Slutligen är Golang ett av språkets starka fördelar, och det finns många fler. Jag hoppas att många utvecklare får njuta av Go.
Tack.