Noțiunile fundamentale ale gorutinei
Goroutine
Dacă le ceri Gopherilor să vorbească despre avantajele golang, vei găsi adesea articole legate de concurență, care este un subiect frecvent menționat. Baza acestor discuții o constituie goroutine-urile, care pot fi gestionate ușor și simplu. Am încercat să scriu o scurtă prezentare despre acestea.
Concurență vs. Paralelism
Înainte de a înțelege goroutine-urile, 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ă acestea rulează 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ă, utilizatorului i se pare că multiple sarcini sunt procesate simultan. Concurența este posibilă chiar și pe un singur nucleu.
- Paralelismul: Paralelismul înseamnă „procesarea mai multor sarcini simultan pe mai multe nuclee”. Aceasta implică, literalmente, executarea sarcinilor în paralel și rularea altor sarcini în același timp.
Goroutine-urile permit implementarea facilă a concurenței prin intermediul scheduler-ului runtime Go și utilizează în mod natural paralelismul prin setarea GOMAXPROCS.
Multi-threading-ul Java, care este frecvent utilizat, este un concept reprezentativ al paralelismului.
De ce sunt goroutine-urile avantajoase?
Ușoare (lightweight)
Costul de creare este mult mai mic în comparație cu alte limbaje. Aici, se pune întrebarea de ce golang utilizează mai puțin. Acest lucru se datorează faptului că locația de creare este gestionată intern de runtime-ul Go. Aceasta se explică prin faptul că este un „logical thread” ușor, mai mic decât o unitate de thread a sistemului de operare, necesită o stivă inițială de aproximativ 2KB și își poate modifica dinamic dimensiunea prin adăugarea de stive în funcție de implementarea utilizatorului.
Gestionarea pe unități de stivă permite crearea și eliminarea foarte rapidă și la costuri reduse, ceea ce face posibilă procesarea a milioane de goroutine fără a deveni o povară. Datorită acestui fapt, Goroutine-ul poate minimiza intervenția kernelului sistemului de operare grație scheduler-ului runtime.
Performanță ridicată (performance)
În primul rând, Goroutine-ul, așa cum am explicat mai sus, implică o intervenție redusă a kernelului sistemului de operare, ceea ce face ca schimbarea de context la nivel de utilizator (User-Level) să fie mai puțin costisitoare decât la nivel de thread al sistemului de operare, permițând o comutare rapidă a sarcinilor.
În plus, utilizează modelul M:N pentru a aloca și gestiona thread-uri ale sistemului de operare. Prin crearea unui pool de thread-uri ale sistemului de operare, se pot procesa sarcini cu un număr redus de thread-uri, fără a fi nevoie de multe thread-uri. 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 sistemului de operare, permițând thread-ului sistemului de operare să utilizeze eficient CPU-ul fără întrerupere, ceea ce duce la o procesare rapidă.
Acest lucru permite Golang-ului să obțină performanțe superioare altor limbaje, în special în operațiile I/O.
Concis (concise)
Un avantaj major 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 unor mecanisme complexe de blocare, cum ar fi Mutex și Semaphore, iar utilizarea blocărilor impune în mod obligatoriu luarea în considerare a stărilor de blocaj (DeadLock), ceea ce face ca faza de proiectare inițială să devină complexă.
Goroutine promovează transferul de date prin Channel, conform filozofiei „nu comunicați prin partajarea memoriei, ci partajați memoria prin comunicare”. De asemenea, SELECT, combinat cu Channel, permite procesarea canalelor de la cele care au date pregătite. În plus, utilizarea sync.WaitGroup permite așteptarea simplă până la finalizarea tuturor goroutine-urilor, facilitând gestionarea fluxului de lucru. Datorită acestor instrumente, se pot preveni problemele de concurență a datelor între thread-uri și se poate realiza o procesare concurentă mai sigură.
În plus, prin utilizarea contextului (context) la nivel de utilizator (User-Level), se pot controla ciclul de viață, anularea, timeout-urile, deadline-urile și domeniul de aplicare al cererilor, asigurând un anumit grad de stabilitate.
Operațiuni paralele ale Goroutine (GOMAXPROCS)
Deși am menționat avantajele concurenței goroutine-urilor, s-ar putea să vă întrebați dacă nu suportă și paralelismul. Numărul de nuclee CPU a depășit recent două cifre, spre deosebire de trecut, iar PC-urile de acasă au, de asemenea, un număr considerabil de nuclee.
Cu toate acestea, Goroutine-ul efectuează și operațiuni paralele, iar acest lucru se realizează prin GOMAXPROCS.
Dacă GOMAXPROCS nu este setat, acesta va fi configurat diferit în funcție de versiune.
- Anterior versiunii 1.5: Valoarea implicită era 1. Dacă era necesar mai mult de 1, setarea era obligatorie prin
runtime.GOMAXPOCS(runtime.NumCPU()). - De la 1.5 la 1.24: Valoarea a fost modificată la numărul total de nuclee logice disponibile. De atunci, dezvoltatorii nu mai trebuie să o seteze, cu excepția cazurilor în care există restricții specifice.
- 1.25: Fiind un limbaj popular în mediile containerizate, verifică cGroup-ul din Linux pentru a determina
limita CPUconfigurată pentru container.
Astfel, dacă numărul de nuclee logice este 10, iar limita CPU este 5, GOMAXPROCS va fi setat la valoarea mai mică, adică 5.
Modificarea din versiunea 1.25 are un impact semnificativ. Aceasta a crescut utilitatea limbajului în mediile containerizate. Prin aceasta, s-au putut reduce crearea inutilă de thread-uri și schimbările de context, prevenind astfel CPU throttling-ul.
1package main
2
3import (
4 "fmt"
5 "math/rand"
6 "runtime"
7 "time"
8 "sync" // Adăugați importul pentru sync
9)
10
11func exe(name int, wg *sync.WaitGroup) {
12 defer wg.Done()
13
14 fmt.Printf("Goroutine %d: începere\n", name) // Traduceți "시작" în "începere"
15 time.Sleep(10 * time.Millisecond) // Întârziere pentru simularea lucrului
16 fmt.Printf("Goroutine %d: finalizare\n", name) // Traduceți "시작" în "finalizare" pentru a indica sfârșitul
17}
18
19func main() {
20 runtime.GOMAXPROCS(2) // Utilizați doar 2 nuclee CPU
21 wg := sync.WaitGroup()
22 goroutineCount := 10
23 wg.Add(goroutineCount)
24
25 for i := 0; i < goroutineCount; i++ {
26 go exe(i, &wg)
27 }
28
29 fmt.Println("Se așteaptă finalizarea tuturor goroutine-urilor...") // Traduceți "모든 goroutine이 끝날 때까지 대기합니다..."
30 wg.Wait()
31 fmt.Println("Toate sarcinile au fost finalizate.") // Traduceți "모든 작업이 완료되었습니다."
32
33}
34
Scheduler-ul Goroutine (Modelul M:N)
Revenind la secțiunea anterioară despre gestionarea alocării thread-urilor sistemului de operare utilizând modelul M:N, vom examina mai detaliat modelul GMP al goroutine-urilor.
- G (Goroutine): Cea mai mică unitate de lucru executată în Go.
- M (Machine): Thread-ul sistemului de operare (locația efectivă a muncii).
- P (Processor): Procesul logic gestionat de runtime-ul Go.
P are, în plus, 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-ul.
Procesul de funcționare al GMP este următorul:
- Când o G (Goroutine) este creată, aceasta este alocată în coada de execuție locală a unui P (Processor).
- P (Processor) alocă G (Goroutine) din coada sa de execuție locală către un M (Machine).
- M (Machine) returnează starea G (Goroutine): block, complete, preempted.
- Work-Stealing (Furtul de muncă): Dacă coada de execuție locală a unui P devine goală, un alt P verifică coada globală. Dacă nici acolo nu există G (Goroutine), acesta „fură” sarcini de la un alt P (Processor) local pentru a se asigura că toate M-urile funcționează fără întrerupere.
- Gestionarea apelurilor de sistem (Blocking): Dacă o G (Goroutine) întâmpină un Block în timpul execuției, M (Machine) intră în stare de așteptare. În acest moment, P (Processor) se separă de M (Machine) blocat și se combină cu un alt M (Machine) pentru a executa următoarea G (Goroutine). Astfel, nu există risipă de CPU chiar și în timpul timpului de așteptare al operațiilor I/O.
- Dacă o G (Goroutine) preia controlul (preempted) pentru o perioadă lungă, i se oferă o șansă de execuție unei alte G (Goroutine).
Garbage Collector-ul (GC) din Golang rulează, de asemenea, deasupra Goroutine-urilor, permițând curățarea memoriei în paralel, cu întreruperi minime ale execuției aplicației (STW), utilizând eficient resursele sistemului.
În concluzie, Golang are unul dintre cele mai puternice avantaje ale limbajului, și sunt multe altele, așa că sper ca mulți dezvoltatori să se bucure de Go.
Vă mulțumesc.