GoSuda

Go-gorutinernes (Goroutine) funktionsprincip: 2KB variabel stak og M:N GMP-planlæggeren

By hamori views ...

Goroutine

Når Gophers bliver bedt om at fortælle om fordelene ved Golang, dukker ofte en artikel om samtidighed (Concurrency) op. Grundlaget for den er goroutinen (goroutine), som er let og enkel at håndtere. Her har jeg skrevet en kort gennemgang af den.

Samtidighed (Concurrency) vs. parallelisme (Parallelism)

Før vi forstår goroutiner, vil vi gennemgå to begreber, der ofte forveksles.

  • Samtidighed: Samtidighed handler om at håndtere mange opgaver på én gang. Det betyder ikke nødvendigvis, at de udføres på samme tid, men er et strukturelt og logisk koncept, hvor flere opgaver opdeles i små enheder og udføres på skift, så det for brugeren ser ud som om flere opgaver behandles samtidigt. Samtidighed er mulig selv på en enkelt kerne.
  • Parallelisme: Parallelisme er "at behandle flere opgaver samtidigt på flere kerner". Bogstaveligt talt udføres arbejdet parallelt, og andre opgaver køres samtidigt.

Goroutiner gør det let at implementere samtidighed via Go-runtimens scheduler, og ved hjælp af indstillingen GOMAXPROCS udnyttes parallelisme også naturligt.

Javas multitrådning (Multi thread), som er meget udbredt, er et typisk eksempel på parallelisme.

Hvorfor er goroutiner gode?

Let (lightweight)

Omkostningerne ved at oprette dem er meget lave sammenlignet med andre sprog. Man kan spørge, hvorfor Golang bruger så få ressourcer? Det skyldes, at oprettelsen administreres inde i Go-runtimen. Fordi de er lette logiske tråde, er de mindre end OS-tråde, deres initiale stack kræver omkring 2KB, og efter behov kan stakken udvides dynamisk, afhængigt af implementeringen.

Fordi de administreres som stacks, er oprettelse og fjernelse meget hurtig og billig, så det er muligt at køre millioner af goroutiner uden at det belaster systemet. Takket være runtime-scheduleren kan Goroutine dermed minimere indblanding fra OS-kernen.

Ydeevne (performance)

Først og fremmest har Goroutine, som forklaret ovenfor, kun lidt indblanding fra OS-kernen, og ved kontekstskift på brugerniveau (User-Level) er omkostningen lavere end for OS-tråde, så opgaver kan skiftes hurtigt.

Derudover bruger den M:N-modellen, hvor goroutiner tildeles og administreres på OS-tråde. Ved at bruge en pulje af OS-tråde er der ikke behov for mange tråde; få tråde er nok. For eksempel, når et systemkald eller anden ventetilstand opstår, kan Go-runtimen køre en anden goroutine på OS-tråden, så OS-tråden ikke står stille, og CPU'en udnyttes effektivt, hvilket giver hurtig behandling.

Det er derfor, Golang kan opnå høj ydeevne sammenlignet med andre sprog, især ved I/O-opgaver.

Kortfattet (concise)

En stor fordel er også, at man med et enkelt go-nøgleord nemt kan køre en funktion samtidigt, når det er nødvendigt.

Mutex, Semaphore og andre komplekse låse skal bruges, og når der bruges låse, må man nødvendigvis tage højde for deadlock-tilstande (DeadLock), hvilket gør designfasen mere kompleks allerede før udviklingen.

Goroutine følger filosofien "Del ikke hukommelse ved at kommunikere, men kommuniker ved at dele hukommelse", og anbefaler dataoverførsel via Channel. SELECT kombineret med kanaler gør det muligt at behandle den kanal, hvor data først er klar. Desuden gør sync.WaitGroup det let at vente, indtil alle goroutiner er færdige, hvilket gør det lettere at styre arbejdsgangen. Takket være disse værktøjer kan man undgå datakapløb mellem tråde og håndtere samtidighed mere sikkert.

Derudover kan man med context styre livscyklus, annullering, timeout, deadline og anmodningsomfang på brugerniveau (User-Level), hvilket giver en vis grad af stabilitet.

Parallelt arbejde i Goroutine (GOMAXPROCS)

Du har sikkert spørgsmålet: Understøtter goroutiner ikke parallelisme, når de er så gode til samtidighed? Antallet af CPU-kerner er i dag tocifret, og selv almindelige hjemmecomputere har ofte mange kerner.

Goroutine udfører dog også parallelt arbejde, og det styres via GOMAXPROCS.

Hvis GOMAXPROCS ikke indstilles, sættes den forskelligt afhængigt af versionen.

  1. Før 1.5: standardværdi 1; for at bruge flere kerner kræves indstilling på samme måde som runtime.GOMAXPOCS(runtime.NumCPU())

  2. 1.5 til 1.24: Ændret til at bruge alle tilgængelige logiske kerner. Fra da af er det ikke nødvendigt for udvikleren at indstille den, medmindre der er særlige begrænsninger.

  3. 1.25: Passende til et sprog, der er kendt for containermiljøer, kontrolleres cGroup på Linux for at finde den CPU-begrænsning, der er sat for containeren.

    Hvis der for eksempel er 10 logiske kerner og CPU-begrænsningen er 5, sættes GOMAXPROCS til det lavere tal, 5.

Ændringen i 1.25 er meget betydningsfuld, fordi sprogets anvendelighed i containermiljøer er forbedret. Dermed kan unødvendig oprettelse af tråde og kontekstskift reduceres, og CPU-throttling kan forhindres.

 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) // Forsinkelse for at simulere arbejde
15	fmt.Printf("Goroutine %d: 시작\n", name)
16}
17
18func main() {
19	runtime.GOMAXPROCS(2) // Brug kun 2 CPU-kerner
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

Goroutine-scheduleren (M:N-modellen)

Det M:N-modellen, der tidligere blev nævnt, hvor goroutiner tildeles OS-tråde, kan forklares mere konkret med GMP-modellen for goroutiner.

  • G (Goroutine): Den mindste arbejdsenhed, der køres i Go
  • M (Machine): OS-tråd (den faktiske udførelsesplacering)
  • P (Processor): Den logiske processor, som Go-runtimen administrerer

P har desuden en lokal kørselskø (Local Run Queue) og fungerer som scheduler, der tildeler G'er til M'er. Kort sagt fungerer goroutinen således:

GMP's arbejdsgang er som følger:

  1. Når en G (Goroutine) oprettes, tildeles den til P'ens (Processor) lokale kørselskø.
  2. P (Processor) tildeler G (Goroutine) fra den lokale kørselskø til M (Machine).
  3. M (Machine) returnerer G'ens (Goroutine) tilstand: block, complete eller preempted.
  4. Work-Stealing (arbejdstyveri): Hvis P's lokale kørselskø bliver tom, tjekker en anden P den globale kø. Hvis der heller ikke er nogen G (Goroutine) der, stjæler den arbejde fra en anden lokal P (Processor), så alle M'er kan arbejde uden pause.
  5. Systemkaldsbehandling (Blocking): Hvis en G (Goroutine) bliver blokeret under udførelsen, går M (Machine) i venstilstand. P (Processor) adskilles da fra den blokerede M (Machine), forbindes med en anden M (Machine) og udfører den næste G (Goroutine). Dermed spildes ingen CPU-tid i ventetiden under I/O-arbejde.
  6. Hvis en G (Goroutine) forbliver aktiv i længere tid (preempted), gives en anden G (Goroutine) mulighed for at køre.

Golang's GC (Garbage Collector) køres også oven på Goroutine, og den kan rydde op i hukommelsen parallelt, mens afbrydelsen af applikationens udførelse holdes til et absolut minimum (STW). Det gør, at systemressourcerne udnyttes effektivt.

Afslutningsvis er Golang et af sprogets stærke sider, og der er mange flere, så jeg håber, at mange udviklere vil nyde at arbejde med Go.

Tak.