GoSuda

Elementele Fundamentale ale Goroutine-urilor

By hamori
views ...

Goroutine

Există articole frecvente despre concurență (Concurrency), subiect care apare adesea atunci când gopher-ii sunt rugați să vorbească despre avantajele golang. Baza acestui subiect este goroutine, care poate fi gestionată ușor și simplu. Am redactat o scurtă prezentare despre aceasta.

Concurență (Concurrency) vs. Paralelism (Parallelism)

Înainte de a înțelege goroutine, aș dori să clarific două concepte care sunt adesea confundate.

  • Concurența: Concurența se referă la gestionarea multor sarcini simultan. Nu înseamnă neapărat că sarcinile sunt executate efectiv în același timp, ci este un concept structural și logic prin care, prin împărțirea mai multor sarcini în unități mici și executarea lor alternativă, utilizatorul percepe că mai multe sarcini sunt procesate simultan. Concurența este posibilă chiar și pe un singur core.
  • Paralelismul: Paralelismul înseamnă „procesarea mai multor sarcini simultan pe mai multe core-uri”. Literalmente, înseamnă executarea sarcinilor în paralel și rularea altor sarcini în același timp.

Goroutine permite implementarea ușoară a concurenței prin intermediul scheduler-ului de runtime Go și utilizează în mod natural și paralelismul prin setarea GOMAXPROCS.

Multi-threading-ul Java, care este frecvent utilizat, este un concept reprezentativ al paralelismului.

De ce este Goroutine avantajos?

Este ușor (lightweight)

Costul de creare este foarte scăzut în comparație cu alte limbaje. Apare întrebarea de ce golang folosește puțin: motivul este că locația de creare este gestionată în interiorul runtime-ului Go. Deoarece este vorba de un thread logic ușor, este mai mic decât o unitate de thread a OS-ului, stiva inițială necesită o dimensiune de aproximativ 2KB și este variabilă dinamic prin adăugarea de stive în funcție de implementarea utilizatorului.

Gestionarea pe bază de stivă face ca crearea și eliminarea să fie foarte rapide și ieftine, permițând procesarea a milioane de goroutine fără a fi împovărătoare. Datorită acestui fapt, Goroutine poate minimiza intervenția kernel-ului OS-ului grație scheduler-ului de runtime.

Are performanță bună (performance)

În primul rând, așa cum s-a menționat mai sus, Goroutine necesită puțină intervenție din partea kernel-ului OS-ului, iar la schimbarea de context la nivel de utilizator (User-Level), costul este mai mic decât cel al unei unități de thread a OS-ului, permițând comutarea rapidă a sarcinilor.

În plus, utilizează modelul M:N pentru a fi alocat și gestionat pe thread-uri ale OS-ului. Prin crearea unui pool de thread-uri OS, procesarea este posibilă cu un număr mic de thread-uri, fără a fi nevoie de multe. De exemplu, dacă o goroutine intră într-o stare de așteptare, cum ar fi un apel de sistem, runtime-ul Go execută o altă goroutine pe thread-ul OS, permițând thread-ului OS să nu stea inactiv și să utilizeze eficient CPU-ul pentru o procesare rapidă.

Din acest motiv, Golang poate obține performanțe ridicate în special în operațiunile I/O, comparativ cu alte limbaje.

Este concis (concise)

Un mare avantaj este și faptul că, atunci când este necesară concurența, o funcție poate fi gestionată cu ușurință printr-un singur cuvânt cheie go.

Este necesară utilizarea de Lock-uri complexe precum Mutex sau Semaphore, iar utilizarea Lock-urilor implică inevitabil luarea în considerare a stării de Deadlock, ceea ce face ca etapa de proiectare premergătoare dezvoltării să devină complexă.

Goroutine, urmând filosofia "Nu comunicați prin partajarea memoriei; partajați memoria prin comunicare", recomandă transmiterea datelor prin Channel-uri, iar SELECT suportă chiar și funcționalitatea de a procesa canalele de la cele care au date pregătite, în combinație cu Channel-ul. De asemenea, utilizarea sync.WaitGroup permite așteptarea simplă până la finalizarea tuturor goroutine-urilor, facilitând gestionarea fluxului de lucru. Datorită acestor instrumente, se poate preveni problema competiției de date între thread-uri și se poate realiza o procesare concurentă mai sigură.

În plus, utilizarea context-ului permite controlul ciclului de viață, anulării, timeout-ului, deadline-ului și domeniului cererii la nivel de utilizator (User-Level), asigurând un anumit grad de stabilitate.

Operațiunile paralele ale Goroutine (GOMAXPROCS)

Deși s-a menționat că goroutine are avantaje în concurență, s-ar putea să vă întrebați dacă nu suportă și paralelismul. Numărul de core-uri ale CPU-urilor moderne depășește două cifre, spre deosebire de trecut, iar chiar și PC-urile de acasă au un număr considerabil de core-uri.

Totuși, Goroutine realizează și operațiuni paralele, iar aceasta se face prin GOMAXPROCS.

Dacă GOMAXPROCS nu este setat, acesta este configurat diferit în funcție de versiune.

  1. Înainte de 1.5: Valoarea implicită este 1; setarea este obligatorie cu runtime.GOMAXPOCS(runtime.NumCPU()) sau similar, dacă este necesară o valoare mai mare de 1.

  2. 1.5 ~ 1.24: S-a schimbat la numărul de core-uri logice disponibile. De la această versiune, dezvoltatorii nu trebuie să o seteze, cu excepția cazurilor în care sunt necesare restricții majore.

  3. 1.25: Ca un limbaj renumit în mediile de containere, verifică cGroup-ul pe Linux pentru a determina restricția de CPU setată pe container.

    Astfel, dacă numărul de core-uri logice este 10 și restricția de CPU este 5, GOMAXPROCS este setat la numărul mai mic, adică 5.

Modificarea din 1.25 aduce o schimbare majoră. Aceasta se datorează faptului că utilizarea limbajului în mediile de containere a crescut. Astfel, se poate preveni throttling-ul CPU-ului prin reducerea creării inutile de thread-uri și a comutării de context.

 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) // începe
14	time.Sleep(10 * time.Millisecond) // Întârziere pentru simularea sarcinii
15	fmt.Printf("Goroutine %d: se termină\n", name) // se termină
16}
17
18func main() {
19	runtime.GOMAXPROCS(2) // Utilizează doar 2 core-uri 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("Așteaptă până la finalizarea tuturor goroutine-urilor...")
29	wg.Wait()
30	fmt.Println("Toate sarcinile au fost finalizate.")
31
32}
33

Scheduler-ul Goroutine (Modelul M:N)

Revenind la secțiunea anterioară despre modelul M:N utilizat pentru alocarea și gestionarea pe thread-uri OS, există modelul goroutine GMP, care intră în mai multe detalii.

  • G (Goroutine): Cea mai mică unitate de lucru executată în Go.
  • M (Machine): Thread-ul OS (locația efectivă a lucrului).
  • P (Processor): Procesul logic gestionat de runtime-ul Go.

P deține suplimentar o coadă de execuție locală (Local Run Queue) și acționează ca un scheduler care alocă G-urile atribuite către M. Pe scurt, goroutine:

Procesul de funcționare al GMP este următorul:

  1. Când este creat un G (Goroutine), acesta este alocat la coada de execuție locală a P (Processor).
  2. P (Processor) alocă G (Goroutine) din coada de execuție locală către M (Machine).
  3. M (Machine) returnează starea G (Goroutine): block, complete, preempted.
  4. Work-Stealing (Furtul de muncă): Dacă coada de execuție locală a unui P devine goală, un alt P verifică coada globală. Dacă G (Goroutine) nu este nici acolo, acesta "fură" sarcinile de la un alt P local (Processor), asigurând că toate M-urile funcționează fără întrerupere.
  5. Gestionarea apelurilor de sistem (Blocking): Dacă apare un Block în timpul execuției unui G (Goroutine), M (Machine) intră în stare de așteptare. În acest moment, P (Processor) se separă de M (Machine) care este blocat și se combină cu un alt M (Machine) pentru a executa următorul G (Goroutine). Astfel, nu există risipă de CPU chiar și în timpul timpului de așteptare al operațiunilor I/O.
  6. Dacă un G (Goroutine) monopolizează (preempted) mult timp, se acordă șansa de execuție altor G (Goroutine).

De asemenea, GC (Garbage Collector) din Golang rulează deasupra Goroutine, permițând curățarea memoriei în paralel cu întreruperea minimă a execuției aplicației (STW), utilizând eficient resursele sistemului.

În concluzie, Golang are unul dintre cele mai mari avantaje lingvistice, iar pe lângă acestea, există multe altele, așa că sper că mulți dezvoltatori se vor bucura de golang.

Mulțumesc.