GoSuda

Principiul de funcționare al goroutinelor Go: stiva variabilă de 2KB și planificatorul M:N GMP

By hamori views ...

Goroutine

Atunci când le cereți Gopher-ilor să vorbească despre avantajele limbajului golang, apare frecvent un subiect legat de concurenţă (Concurrency). Baza acestui subiect este goroutine (goroutine), care permite procesarea ușoară și simplă. Am redactat, pe scurt, un material despre acest subiect.

Concurenţă (Concurrency) vs Paralelism (Parallelism)

Înainte de a înțelege goroutine-urile, dorim să clarificăm mai întâi două concepte care sunt adesea confundate.

  • Concurenţă: Concurenţa se referă la gestionarea a multe sarcini deodată. Nu înseamnă neapărat execuţie simultană efectivă, ci reprezintă un concept structural și logic prin care mai multe sarcini sunt împărţite în unităţi mici și executate alternativ, astfel încât utilizatorului să i se pară că mai multe sarcini sunt procesate simultan. Concurenţa este posibilă și pe un singur nucleu.
  • Paralelism: Paralelismul reprezintă „procesarea simultană a mai multor sarcini pe mai multe nuclee”. Aceasta înseamnă, literal, desfășurarea sarcinilor în paralel, iar alte sarcini sunt executate simultan.

Goroutine-urile permit implementarea cu ușurinţă a concurenţei prin intermediul planificatorului de runtime al Go, iar prin configurarea GOMAXPROCS exploatează în mod natural și paralelismul.

Multithreading-ul din Java (Multi thread), utilizat pe scară largă, este un concept reprezentativ al paralelismului.

De ce sunt bune goroutine-urile?

Ușoare (lightweight)

Costul de creare este semnificativ mai mic decât în alte limbaje. Aici apare întrebarea de ce golang utilizează puţine resurse? Motivul este că locaţia de creare este gestionată în interiorul runtime-ului Go. Acest lucru se datorează faptului că sunt fire logice de execuţie ușoare; sunt mai mici decât unitatea unui thread OS, stiva inițială necesită aproximativ 2KB, iar prin implementarea utilizatorului, stiva poate fi extinsă dinamic prin adăugarea de spaţiu suplimentar.

Deoarece sunt gestionate la nivel de stivă, crearea și eliminarea sunt foarte rapide și ieftine, astfel încât rularea a câteva milioane de goroutine nu reprezintă o povară. Datorită planificatorului de runtime, Goroutine-urile pot minimiza intervenţia nucleului OS.

Performanţă ridicată (performance)

În primul rând, Goroutine-urile, după cum s-a explicat mai sus, implică o intervenţie redusă a nucleului OS, deci comutarea contextului la nivel de utilizator (User-Level) are un cost mai mic decât la nivelul thread-urilor OS, ceea ce permite trecerea rapidă între sarcini.

În plus, se utilizează modelul M:N pentru a aloca și gestiona goroutine-urile pe thread-urile OS. Prin crearea unui pool de thread-uri OS, sarcinile pot fi procesate cu un număr redus de thread-uri, fără a fi nevoie de multe thread-uri. De exemplu, atunci când apare o stare de așteptare, cum ar fi un apel de sistem, runtime-ul Go poate executa alte goroutine pe thread-ul OS, astfel încât thread-ul OS nu rămâne inactiv și utilizează eficient CPU-ul, permiţând procesare rapidă.

Datorită acestui lucru, Golang poate atinge performanţe ridicate, în special în operaţiile I/O, comparativ cu alte limbaje.

Concis (concise)

Posibilitatea de a executa o funcţie în mod concurent cu un singur cuvânt cheie go este, de asemenea, un avantaj major.

Utilizarea Mutex, Semaphore și altor mecanisme complexe de Lock este necesară, iar atunci când se folosesc Lock-uri, trebuie luată în considerare în mod obligatoriu starea de DeadLock, ceea ce face ca faza de proiectare, încă de dinaintea dezvoltării, să devină una complexă.

Goroutine-urile urmează filozofia „nu comunica prin memorie partajată, ci partajează memoria prin comunicare”, încurajând transferul de date prin Channel (Channel), iar SELECT se combină cu canalele (Channel) și oferă chiar și funcţionalitatea de a procesa primul canal pentru care datele sunt pregătite. De asemenea, prin utilizarea sync.WaitGroup se poate aștepta simplu până când toate goroutine-urile s-au încheiat, ceea ce facilitează gestionarea fluxului de lucru. Datorită acestor instrumente, se previn problemele de data race între thread-uri, iar procesarea concurentă poate fi realizată în mod mai sigur.

De asemenea, prin utilizarea contextului (context) se pot controla, la nivel de utilizator (User-Level), ciclul de viaţă, anularea, timeout-ul, termenul-limită și domeniul cererii, ceea ce asigură un anumit nivel de stabilitate.

Paralelismul goroutine-urilor (GOMAXPROCS)

Ați menționat avantajele concurenţei goroutine-urilor, însă probabil vă întrebaţi dacă nu există și suport pentru paralelism? Numărul nucleelor CPU recente a depășit, spre deosebire de trecut, pragul de două cifre, iar computerele personale au, de asemenea, multe nuclee.

Totuși, Goroutine-urile realizează și sarcini în paralel; acest lucru se face prin GOMAXPROCS.

Dacă nu se setează GOMAXPROCS, valoarea implicită diferă în funcţie de versiune.

  1. Înainte de 1.5: valoarea implicită era 1; pentru a utiliza mai mult de 1 este obligatorie setarea printr-o metodă de tipul runtime.GOMAXPOCS(runtime.NumCPU())

  2. 1.5 ~ 1.24: valoarea a fost modificată la numărul tuturor nucleelor logice disponibile. Din acest moment, dezvoltatorul nu are, în general, nevoie să o seteze, cu excepţia cazurilor în care există restricţii importante

  3. 1.25: fiind un limbaj renumit în medii containerizate, verifică, în linux, cGroup pentru a identifica limita de CPU configurată pentru container.

    Astfel, dacă există 10 nuclee logice, iar limita CPU este 5, GOMAXPROCS se setează la valoarea mai mică, adică 5.

Modificarea din 1.25 are o importanţă foarte mare, deoarece a crescut utilitatea limbajului în medii containerizate. Prin aceasta, se pot reduce crearea inutilă de thread-uri și comutările de context, prevenind astfel throttling-ul 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: începe\n", name)
14	time.Sleep(10 * time.Millisecond) // întârziere pentru simularea sarcinii
15	fmt.Printf("Goroutine %d: începe\n", name)
16}
17
18func main() {
19	runtime.GOMAXPROCS(2) // Se utilizează doar 2 nuclee 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("Se așteaptă finalizarea tuturor goroutine-urilor...")
29	wg.Wait()
30	fmt.Println("Toate sarcinile au fost finalizate.")
31
32}
33

Planificatorul Goroutine-urilor (modelul M:N)

Din cele precizate anterior, despre modelul M:N utilizat pentru alocarea pe thread-urile OS, dacă intrăm mai detaliat, există modelul GMP al goroutine-urilor.

  • G (Goroutine): cea mai mică unitate de lucru executată în Go
  • M (Machine): thread OS (locul efectiv al lucrului)
  • P (Processor): procesul logic gestionat de runtime-ul Go

Acestea sunt componentele. P are, în plus, o coadă locală de execuţie (Local Run Queue) și îndeplinește rolul de planificator, alocând G-urile atribuite către M. Pe scurt, goroutine-ul funcţionează astfel:

Funcţionarea GMP este următoarea:

  1. Când se creează un G (Goroutine), acesta este alocat în coada locală de execuţie a unui P (Processor).
  2. P (Processor) alocă G (Goroutine) din coada locală de execuţie către M (Machine).
  3. M (Machine) returnează starea G (Goroutine), adică block, complete sau preempted.
  4. Work-Stealing (furtul de sarcini): dacă coada locală de execuţie a unui P rămâne goală, alt P verifică coada globală. Dacă nici acolo nu există G (Goroutine), acesta fură sarcini din coada locală a altui P (Processor), astfel încât toate M-urile să funcţioneze fără întrerupere.
  5. Procesarea apelurilor de sistem (Blocking): când un G (Goroutine) intră în Block în timpul execuţiei, M (Machine) trece în starea de așteptare; în acest moment, P (Processor) se desparte de M (Machine) blocat, se asociază cu un alt M (Machine) și execută următorul G (Goroutine). Astfel, în timpul așteptării operaţiilor I/O nu există risipă de CPU.
  6. Dacă un G (Goroutine) ocupă execuţia pentru o perioadă lungă (preempted), se oferă şansa de execuţie altor G (Goroutine).

Golang rulează și GC (Garbage Collector) peste goroutine-uri, astfel încât memoria poate fi curăţată în paralel, cu întreruperea minimă a aplicaţiei (STW), ceea ce utilizează eficient resursele sistemului.

În final, Golang este unul dintre punctele forte ale limbajului, iar există și multe altele; sper ca mulţi dezvoltatori să se bucure de utilizarea limbajului Go.

Vă mulţumesc.