GoSuda

Principi Fondamentali delle Goroutine

By hamori
views ...

Goroutine

Quando si chiede ai Gopher di illustrare i vantaggi di golang, un argomento frequente è la concorrenza (Concurrency). La base di tale argomento risiede nella goroutine, che è leggera e facile da gestire. Ho redatto un breve testo a riguardo.

Concorrenza (Concurrency) vs Parallelismo (Parallelism)

Prima di comprendere le goroutine, vorrei analizzare due concetti che vengono spesso confusi.

  • Concorrenza: La concorrenza riguarda la gestione di molte attività contemporaneamente. Non significa necessariamente che vengano eseguite simultaneamente, ma è un concetto strutturale e logico in cui diversi compiti vengono suddivisi in unità più piccole ed eseguiti a turno, facendo sembrare all'utente che più attività siano gestite contemporaneamente. La concorrenza è possibile anche su un singolo core.
  • Parallelismo: Il parallelismo è "l'esecuzione simultanea di più attività su più core". Significa letteralmente procedere con il lavoro in parallelo, eseguendo diversi compiti contemporaneamente.

Le goroutine, tramite lo scheduler del runtime di Go, facilitano l'implementazione della concorrenza e sfruttano naturalmente anche il parallelismo tramite l'impostazione GOMAXPROCS.

Il multi thread di Java, spesso caratterizzato da un elevato tasso di utilizzo, è un concetto rappresentativo del parallelismo.

Perché le goroutine sono vantaggiose?

Sono leggere (lightweight)

Il costo di creazione è molto basso rispetto ad altri linguaggi. Ci si potrebbe chiedere perché golang ne utilizzi poche: la ragione è che la loro posizione di creazione è gestita internamente al runtime di Go. Questo perché sono thread logici leggeri, come menzionato sopra. Sono più piccoli delle unità thread del sistema operativo (OS thread), richiedono uno stack iniziale di circa 2KB e sono dinamicamente variabili, con l'aggiunta di stack in base all'implementazione dell'utente.

Essendo gestite a livello di stack, la creazione e l'eliminazione sono estremamente veloci ed economiche, consentendo l'elaborazione di milioni di goroutine senza oneri eccessivi. Di conseguenza, le Goroutine possono minimizzare l'intervento del kernel del sistema operativo grazie allo scheduler del runtime.

Hanno buone prestazioni (performance)

Innanzitutto, come spiegato sopra, le Goroutine hanno un ridotto intervento del kernel del sistema operativo e, quando avviene lo context switching a livello utente (User-Level), il costo è inferiore rispetto all'unità OS thread, consentendo un rapido cambio di attività.

Inoltre, vengono gestite assegnandole agli OS thread utilizzando il modello M:N. È possibile gestire il carico con un numero ridotto di thread, senza la necessità di un grande pool di OS thread. Ad esempio, se si verifica uno stato di attesa come una chiamata di sistema, il runtime di Go esegue un'altra goroutine sull'OS thread, consentendo all'OS thread di non restare inattivo e di utilizzare la CPU in modo efficiente per un'elaborazione rapida.

Ciò consente a Golang di ottenere prestazioni superiori rispetto ad altri linguaggi, in particolare nelle operazioni di I/O.

Sono concise (concise)

Un grande vantaggio è anche la possibilità di gestire facilmente una funzione con la sola parola chiave go quando è richiesta la concorrenza.

L'uso di Lock complessi come Mutex e Semaphore è necessario, e quando si utilizzano i Lock, è inevitabile considerare la condizione di DeadLock, il che rende necessario un passaggio di progettazione complesso fin dalla fase iniziale di sviluppo.

La filosofia delle Goroutine è "non comunicare condividendo la memoria, ma condividi la memoria comunicando", e incoraggia il trasferimento dei dati tramite Channel. SELECT è combinato con i Channel e supporta anche la funzionalità che permette di elaborare i dati a partire dal Channel pronto. Inoltre, utilizzando sync.WaitGroup, è possibile attendere semplicemente che tutte le goroutine siano terminate, facilitando la gestione del flusso di lavoro. Grazie a questi strumenti, è possibile prevenire problemi di competizione sui dati tra i thread e ottenere una gestione della concorrenza più sicura.

Inoltre, utilizzando il context, è possibile controllare il ciclo di vita, l'annullamento, il timeout, la scadenza (deadline) e l'ambito della richiesta a livello utente (User-Level), garantendo un certo grado di stabilità.

Operazioni parallele delle Goroutine (GOMAXPROCS)

Dopo aver parlato dei vantaggi della concorrenza delle goroutine, potreste chiedervi se non supportino il parallelismo. Il numero di core nelle CPU recenti supera la doppia cifra, a differenza del passato, e anche i PC domestici hanno un numero non trascurabile di core.

Tuttavia, le Goroutine eseguono anche operazioni parallele, ed è qui che interviene GOMAXPROCS.

Se GOMAXPROCS non viene impostato, la configurazione varia a seconda della versione.

  1. Prima della 1.5: Valore predefinito 1. Se è necessario un valore maggiore di 1, è essenziale impostarlo con un metodo come runtime.GOMAXPOCS(runtime.NumCPU()).

  2. Dalla 1.5 alla 1.24: È stato modificato per utilizzare tutti i core logici disponibili. Da questo momento, non è necessario che lo sviluppatore lo imposti, a meno che non siano richiesti vincoli specifici.

  3. 1.25: Essendo un linguaggio noto negli ambienti container, verifica i cGroup su linux per determinare la restrizione CPU impostata sul container.

    Quindi, se il numero di core logici è 10 e il valore di restrizione CPU è 5, GOMAXPROCS viene impostato sul numero inferiore, ovvero 5.

La modifica nella versione 1.25 è molto significativa. Questo perché l'utilizzo del linguaggio negli ambienti container è aumentato. Ciò ha permesso di ridurre la creazione di thread non necessari e lo context switching, prevenendo lo 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: 시작\n", name) // Goroutine %d: Inizio
14	time.Sleep(10 * time.Millisecond) // 작업 시뮬레이션을 위한 지연 // Ritardo per la simulazione del lavoro
15	fmt.Printf("Goroutine %d: 시작\n", name) // Goroutine %d: Inizio
16}
17
18func main() {
19	runtime.GOMAXPROCS(2) // CPU 코어 2개만 사용 // Utilizza solo 2 core 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("모든 goroutine이 끝날 때까지 대기합니다...") // Attesa del completamento di tutte le goroutine...
29	wg.Wait()
30	fmt.Println("모든 작업이 완료되었습니다.") // Tutte le operazioni sono state completate.
31
32}
33

Lo Scheduler delle Goroutine (Modello M:N)

Approfondendo la parte precedente relativa al modello M:N utilizzato per assegnare e gestire gli OS thread, si arriva al modello GMP delle goroutine.

  • G (Goroutine): La più piccola unità di lavoro eseguita in Go
  • M (Machine): OS thread (la posizione effettiva del lavoro)
  • P (Processor): Il processo logico gestito dal runtime di Go

P ha anche una coda di esecuzione locale (Local Run Queue) e funge da scheduler che assegna le G assegnate a M. In sintesi, la goroutine è

Il processo di funzionamento di GMP è il seguente:

  1. Quando una G (Goroutine) viene creata, viene allocata nella coda di esecuzione locale del P (Processor).
  2. Il P (Processor) assegna la G (Goroutine) presente nella coda di esecuzione locale a M (Machine).
  3. M (Machine) restituisce lo stato della G (Goroutine): block, complete, preempted.
  4. Work-Stealing (Furto di lavoro): Se la coda di esecuzione locale di un P si svuota, un altro P verifica la coda globale. Se anche lì non ci sono G (Goroutine), "ruba" il lavoro da un altro P (Processor) locale, facendo in modo che tutte le M operino senza interruzioni.
  5. Gestione delle chiamate di sistema (Blocking): Se si verifica un Block durante l'esecuzione di una G (Goroutine), la M (Machine) entra in uno stato di attesa. In questo momento, il P (Processor) si disaccoppia dalla M (Machine) bloccata e si combina con un'altra M (Machine) per eseguire la G (Goroutine) successiva. In questo modo, non c'è spreco di CPU anche durante il tempo di attesa delle operazioni di I/O.
  6. Se una G (Goroutine) occupa il processore (preempted) per un lungo periodo, viene data l'opportunità di esecuzione a un'altra G (Goroutine).

Anche il GC (Garbage Collector) di Golang viene eseguito sulla Goroutine, consentendo di ripulire la memoria in parallelo con una sospensione minima dell'esecuzione dell'applicazione (STW), utilizzando in modo efficiente le risorse di sistema.

Infine, Golang ha uno dei maggiori punti di forza nel linguaggio, e ce ne sono molti altri, quindi spero che molti sviluppatori si divertano con Go.

Grazie.