GoSuda

Go-goroutinien (Goroutine) toimintaperiaate: 2 KB:n muuttuva pino ja M:N GMP-ajoitin

By hamori views ...

Goroutine

Kun Gopher-yhteisön jäseniltä kysytään golangin etuja, usein esiin nouseva aihe on samanaikaisuus (Concurrency). Sen perustana on kevyt ja yksinkertainen käsiteltävä goroutine. Tästä on laadittu lyhyt yleiskatsaus.

Samanaikaisuus (Concurrency) vs rinnakkaisuus (Parallelism)

Ennen goroutinen ymmärtämistä käsitellään kahta usein sekoitettua käsitettä.

  • Samanaikaisuus: Samanaikaisuus koskee monen asian käsittelemistä kerralla. Se ei tarkoita todellista samanaikaista suorittamista, vaan rakenteellista ja loogista käsitettä, jossa useat tehtävät jaetaan pieniin osiin ja suoritetaan vuorotellen, jolloin käyttäjän silmin useat tehtävät näyttävät etenevän samanaikaisesti. Samanaikaisuus on mahdollista myös yhdessä suoritusytimessä (single core).
  • Rinnakkaisuus: Rinnakkaisuus on "useiden asioiden käsittelyä samanaikaisesti useissa ytimissä". Sanan mukaisesti työt etenevät rinnakkain, ja muita tehtäviä suoritetaan samanaikaisesti.

Goroutine mahdollistaa samanaikaisuuden helpon toteuttamisen Go-ajonaikaisen ympäristön ajastimen (scheduler) avulla, ja GOMAXPROCS-asetuksen kautta se hyödyntää luontevasti myös rinnakkaisuutta.

Javan usein käytetty monisäikeisyys (Multi thread) on tyypillinen rinnakkaisuuden edustaja.

Miksi goroutinet ovat hyviä?

Kevyt (lightweight)

Luontikustannus on huomattavasti alhaisempi kuin muilla kielillä. Herää kysymys, miksi golang käyttää niitä niin vähän? Syy on, että luonti hallitaan Go-ajonaikaisen ympäristön sisällä. Koska kyseessä on kevyt looginen säie, joka on pienempi kuin OS-säikeen yksikkö, alkupinon koko on noin 2 KB, ja pinoa voidaan kasvattaa dynaamisesti käyttäjän toteutuksesta riippuen.

Hallinta tapahtuu pinoyksiköittäin, joten luonti ja poisto ovat erittäin nopeita ja edullisia, ja satojen tuhansien tai miljoonien goroutinien käyttö on mahdollista ilman merkittävää kuormitusta. Ajonaikaisen ajastimen ansiosta Goroutine voi minimoida OS-ytimen väliintulon.

Suorituskyky (performance)

Ensinnäkin Goroutine, kuten yllä selitettiin, vaatii vain vähän OS-ytimen väliintuloa, joten kontekstinvaihto käyttäjätasolla (User-Level) on halvempaa kuin OS-säikeillä, ja tehtävien vaihtaminen on nopeaa.

Lisäksi M:N-mallia käytetään goroutinien sijoittamiseen OS-säikeille ja niiden hallintaan. OS-säiepoolin avulla pärjätään vähemmillä säikeillä ilman tarvetta suurelle määrälle säikeitä. Esimerkiksi järjestelmäkutsun kaltaisessa odotustilassa Go-ajonaikainen ympäristö suorittaa toisen goroutinen samalla OS-säikeellä, jolloin OS-säie ei jää joutilaaksi ja suoritinta hyödynnetään tehokkaasti, mikä mahdollistaa nopean käsittelyn.

Tämän ansiosta Golang voi saavuttaa erityisesti I/O-työssä korkeamman suorituskyvyn kuin muut kielet.

Tiivis (concise)

Kun tarvitaan samanaikaisuutta, yksi go-avainsana riittää funktion suorittamiseen, mikä on myös suuri etu.

Mutex, Semaphore ja muut monimutkaiset Lock-mekanismit vaativat työtä, ja lukkojen käyttö pakottaa huomioimaan väistämättä DeadLock-tilanteen, mikä tekee suunnittelusta monimutkaisemman jo ennen varsinaista kehitystä.

Goroutine noudattaa periaatetta "älä kommunikoi jakamalla muistia, vaan jaa muisti kommunikoimalla", ja se suosittelee Channel-kanavan kautta tapahtuvaa tiedonsiirtoa. SELECT yhdistyy kanaviin ja mahdollistaa sen, että käsittely aloitetaan siitä kanavasta, jossa data on valmiina. Lisäksi sync.WaitGroup-rakenteella voidaan odottaa helposti, kunnes kaikki goroutinet ovat päättyneet, mikä helpottaa työnkulun hallintaa. Näiden työkalujen ansiosta säikeiden välinen data race -ongelma voidaan estää ja samanaikaisuuden käsittely on turvallisempaa.

Lisäksi context-paketin avulla voidaan hallita käyttäjätasolla (User-Level) elinkaarta, peruutuksia, aikakatkaisuja, deadlineja ja pyyntöjen laajuutta, mikä takaa tietyn tason vakauden.

Goroutinen rinnakkaistyö (GOMAXPROCS)

Olemme puhuneet goroutinen samanaikaisuuden eduista, mutta eikö se tue rinnakkaisuutta? Tämä kysymys varmasti nousee mieleen. Nykyisten CPU:iden ytimien määrä on kaksinumeroinen, ja kotikoneissakin on usein paljon ytimiä.

Goroutine kuitenkin tekee rinnakkaistyötä myös, ja siihen käytetään GOMAXPROCS-asetusta.

Jos GOMAXPROCS-asetusta ei määritetä, sen oletusarvo vaihtelee version mukaan.

  1. Ennen versiota 1.5: oletusarvo on 1. Jos tarvitaan enemmän, asetus on tehtävä manuaalisesti esimerkiksi tyyliin runtime.GOMAXPOCS(runtime.NumCPU())

  2. 1.5 ~ 1.24: Arvoksi asetettiin kaikki käytettävissä olevat loogiset ytimet. Tästä alkaen kehittäjän ei yleensä tarvitse asettaa arvoa, ellei tilanne vaadi erityisiä rajoituksia.

  3. 1.25: Konttiympäristössä toimivana, kuten maineikkaalle kielelle sopii, Linuxin cGroup tarkistetaan ja konttiin asetettu CPU-rajoitus tunnistetaan.

    Jos loogisia ytimiä on 10 ja CPU-rajoitus on 5, GOMAXPROCS asetetaan alemmaksi arvoksi eli 5:ksi.

Version 1.25 muutos on hyvin merkittävä, koska kielen hyödyntäminen konttiympäristöissä on parantunut. Tämän ansiosta tarpeettomien säikeiden luontia ja kontekstinvaihtoja voidaan vähentää, ja CPU-throttlingia voidaan estää.

 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) // Viive työn simuloimiseksi
15	fmt.Printf("Goroutine %d: 시작\n", name)
16}
17
18func main() {
19	runtime.GOMAXPROCS(2) // Käytetään vain 2 CPU-ydintä
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

Goroutinen ajastin (M:N-malli)

Edellä mainittu kohta, jossa M:N-mallia käytetään goroutinien sijoittamiseen OS-säikeille, voidaan avata tarkemmin goroutinen GMP-mallilla.

  • G (Goroutine): Go-ympäristössä suoritettava pienin työyksikkö
  • M (Machine): OS-säie (todellinen suorituspaikka)
  • P (Processor): Go-ajonaikaisen ympäristön hallinnoima looginen prosessori

P:llä on lisäksi paikallinen suoritusjono (Local Run Queue), ja se toimii ajastimen roolissa, joka määrää M:lle G:n. Lyhyesti sanottuna goroutine on

GMP:n toimintavaihe on seuraava:

  1. G (Goroutine) luodaan, ja se sijoitetaan P:n (Processor) paikalliseen suoritusjonoon.
  2. P (Processor) määrää paikallisessa suoritusjonossa olevan G:n M:lle (Machine).
  3. M (Machine) palauttaa G:n tilan: block, complete tai preempted.
  4. Work-Stealing (työn varastaminen): Jos P:n paikallinen suoritusjono tyhjenee, toinen P tarkistaa globaalin jonon. Jos sielläkään ei ole G:tä, se varastaa työn toisen paikallisen P:n jonosta, jotta kaikki M:t toimivat jatkuvasti.
  5. Järjestelmäkutsun käsittely (Blocking): Jos G estyy suorituksen aikana, M joutuu odotustilaan, jolloin P irrotetaan estyneestä M:stä ja liitetään toiseen M:ään suorittamaan seuraava G. Tällöin I/O-odotuksen aikanakaan ei synny CPU:n hukkaa.
  6. Jos yksi G varaa suorittimen (preempted) pitkäksi aikaa, annetaan mahdollisuus suorittaa toinen G.

Golang suorittaa myös GC:n (Garbage Collector) goroutinen päällä, mikä mahdollistaa muistin siivoamisen rinnakkain niin, että sovelluksen suoritus keskeytyy mahdollisimman vähän (STW), ja näin järjestelmäresursseja käytetään tehokkaasti.

Lopuksi Golang on yksi kielen vahvoista puolista, ja siinä on paljon muutakin. Toivon, että monet kehittäjät nauttivat Go-kielen käytöstä.

Kiitos.