Come Go gestisce la concorrenza meglio rispetto ai modelli di threading tradizionali

Come Go gestisce la concorrenza meglio rispetto ai modelli di threading tradizionali

Nella moderna ingegneria del software, la creazione di applicazioni in grado di eseguire più attività contemporaneamente non è più un lusso: è un requisito fondamentale. Dai server Web ad alto rendimento ai servizi di streaming in tempo reale, la concorrenza è al centro delle prestazioni.

Per decenni, i linguaggi di programmazione tradizionali come C++, Java e Python si sono affidati ai modelli di threading nativi del sistema operativo per gestire attività simultanee. Tuttavia, quando Google progettò Go (Golang) alla fine degli anni 2000, prese una strada radicalmente diversa. Invece di esporre i thread grezzi del sistema operativo, Go ha introdotto Goroutines e uno specializzato M:N Scheduler.

In questo articolo esamineremo i limiti architetturali dei modelli di threading tradizionali ed esploreremo il motivo per cui la progettazione della concorrenza di Go è significativamente più efficiente, scalabile e intuitiva per gli sviluppatori.


1. I colli di bottiglia del threading tradizionale (il modello 1:1)

La maggior parte dei sistemi runtime tradizionali utilizza un modello di threading 1:1. In questo modello, ogni thread creato nel codice dello spazio utente si associa direttamente a un thread dello spazio kernel gestito dal sistema operativo (OS).

Sebbene semplice, questa mappatura 1:1 introduce tre colli di bottiglia critici:

A. Carico di memoria (stack di grandi dimensioni)

Un thread del sistema operativo è una risorsa pesante. Per impostazione predefinita, i sistemi operativi allocano una dimensione di stack fissa e contigua a ciascun thread (in genere da 1 MB a 8 MB).

  • Se desideri gestire 10.000 connessioni simultanee, l’allocazione di 10.000 thread del sistema operativo richiederebbe tra 10 GB e 80 GB di RAM solo per la sola memoria dello stack di thread.
  • Ciò rende la gestione di un’elevata concorrenza con un elevato numero di connessioni estremamente costosa e praticamente impossibile su hardware server standard.

B. Costi elevati di cambio di contesto

Quando il sistema operativo cambia l’esecuzione da un thread a un altro, esegue un cambio di contesto. Poiché i thread del sistema operativo sono gestiti dal kernel, un cambio di contesto richiede l’attraversamento del limite utente-kernel. La CPU deve:

  1. Salvare lo stato dei registri correnti.
  2. Svuotare le righe della cache della CPU e aggiornare le tabelle delle pagine (buffer lookaside di traduzione).
  3. Passa alla modalità kernel, seleziona il thread successivo e carica il suo stato salvato.
  4. Ritorna alla modalità utente.

L’intero viaggio di andata e ritorno richiede circa da 1 a 2 microsecondi, che rappresentano centinaia o migliaia di cicli della CPU spesi esclusivamente per il sovraccarico amministrativo anziché per l’esecuzione della logica aziendale effettiva.

C. Limiti di pianificazione del sistema operativo

Lo scheduler del sistema operativo è di uso generale. Tratta i thread del database, i thread dell’interfaccia utente e le connessioni di rete leggere con la stessa euristica di pianificazione. Poiché non comprende il contesto a livello di applicazione, non può ottimizzare la pianificazione in base al fatto che un thread sia bloccato su un socket di rete, in attesa di un blocco interno o in esecuzione di calcoli attivi.


2. La soluzione Go: lo scheduler M:N (Goroutines)

Go aggira le limitazioni del threading del sistema operativo introducendo Goroutines e implementando il proprio pianificatore di runtime. Invece di mappare i thread 1:1, Go utilizza un modello di pianificazione M:N in cui M goroutine leggere vengono multiplexate su N thread fisici del sistema operativo.

Diagramma del modello di pianificazione tradizionale 1:1 rispetto a Go M:N

Questa architettura è governata da tre elementi strutturali primari, spesso definiti modello GMP:

  • G (Goroutine): Rappresenta una singola goroutine. Include lo stack di esecuzione, il contatore del programma e lo stato della goroutine. Un G non è un thread del sistema operativo; è una semplice struttura Go che costa solo circa 2KB di memoria per l’inizializzazione.
  • M (Macchina): rappresenta un thread fisico del sistema operativo/kernel. È gestito dallo scheduler del sistema operativo ed è responsabile dell’esecuzione delle istruzioni del codice macchina delle goroutine.
  • P (Processore): rappresenta un processore logico o un contesto di esecuzione. Per impostazione predefinita, il numero di istanze P corrisponde al numero di core CPU logici sulla macchina host (controllata da GOMAXPROCS). Una macchina M deve acquisire un processore logico P per eseguire il codice Go.

Perché il modello GMP è superiore

Poiché le goroutine sono gestite interamente nello spazio utente dal runtime Go:

  1. Stack dinamici: una goroutine inizia con un piccolo stack di 2KB. Quando l’esecuzione lo richiede, lo stack cresce dinamicamente (allocando segmenti di memoria contigui più grandi nell’heap) e si restringe. Ciò consente a Go di eseguire centinaia di migliaia di goroutine contemporaneamente su un singolo laptop.
  2. Cambi di contesto rapidi: il passaggio da una goroutine all’altra avviene interamente nello spazio utente senza invocare cambi di contesto del kernel. Vengono salvati solo il contatore del programma e alcuni registri della CPU. Questo cambio di contesto nello spazio utente richiede solo da 10 a 100 nanosecondi, da circa 10 a 100 volte più velocemente di un cambio di thread del sistema operativo.

3. Furto di lavoro e I/O non bloccanti

Il runtime Go utilizza due meccanismi di pianificazione avanzati per garantire che i core fisici della CPU non siano mai sottoutilizzati: Work Stealing e Syscall Hand-off.

A. L’algoritmo del furto di lavoro

Ogni processore logico P mantiene la propria coda di esecuzione locale di goroutine. Inoltre, esiste una coda di esecuzione globale per l’overflow. Se un processore P esaurisce tutte le goroutine nella sua coda di esecuzione locale, non va in sospensione. Invece, esegue un furto di lavoro: controlla altri processori logici e ruba metà delle loro goroutine in coda per bilanciare il carico di lavoro su tutti i core della CPU.

Local Queue P1: [ G1, G2, G3 ]  ---> Running on Thread M1
Local Queue P2: [ ]            ---> Running on Thread M2 (Idle)
                                     P2 steals G3 and G2 from P1!

B. Poller di rete e I/O non bloccanti

Quando una goroutine esegue I/O di rete (ad esempio, leggendo da una connessione al database o effettuando una chiamata HTTP), Go non blocca il thread del sistema operativo sottostante M.

Invece, il runtime Go registra il blocco con un Network Poller dedicato (che utilizza efficienti API di multiplexing specifiche del sistema operativo come epoll su Linux, kqueue su macOS o IOCP su Windows). La goroutine bloccata G viene staccata dal thread M e parcheggiata nel poller di rete.

Il thread M preleva immediatamente un’altra goroutine eseguibile dalla coda. Una volta completato l’evento I/O, il poller di rete sposta G su una coda di esecuzione attiva per riprendere l’esecuzione.


4. Canali e memoria condivisa (il modello CSP)

I modelli di threading tradizionali coordinano attività simultanee condividendo la memoria (ad esempio, passando puntatori tra thread). Per evitare corse di dati, gli sviluppatori devono gestire manualmente blocchi, mutex e variabili di condizione:

// Traditional Java Shared Memory Approach
synchronized(lock) {
    sharedResource.updateState();
}

Questo modello è notoriamente soggetto a errori e spesso provoca situazioni di stallo, condizioni di competizione e colli di bottiglia nella coerenza della cache.

Go implementa il modello formale Communicating Sequential Processes (CSP), riassunto dal famoso proverbio Golang:

“Non comunicare condividendo la memoria; condividi invece la memoria comunicando.”

Go fornisce Canali come primitive di prima classe. I canali fungono da code indipendenti dal tipo che consentono alle goroutine di inviare e ricevere messaggi per sincronizzare l’esecuzione e trasferire la proprietà dei dati.


5. Implementazione pratica: goroutine e canali in azione

Ecco un pratico programma Go che dimostra come più goroutine di lavoro possono elaborare attività contemporaneamente e restituire risultati attraverso un canale, gestendo il flusso di dati in modo sicuro senza blocchi.

package main

import (
	"fmt"
	"time"
)

// Task represents a unit of work
type Task struct {
	ID       int
	Duration time.Duration
}

// Result represents the outcome of a processed task
type Result struct {
	TaskID int
	Value  string
}

// worker processes incoming tasks from the jobs channel and sends results
func worker(id int, jobs <-chan Task, results chan<- Result) {
	for job := range jobs {
		fmt.Printf("Worker %d: Started processing job %d\n", id, job.ID)
		time.Sleep(job.Duration) // Simulate CPU/IO delay
		
		results <- Result{
			TaskID: job.ID,
			Value:  fmt.Sprintf("Processed by worker %d in %v", id, job.Duration),
		}
	}
}

func main() {
	// Create tasks
	tasks := []Task{
		{ID: 101, Duration: 200 * time.Millisecond},
		{ID: 102, Duration: 400 * time.Millisecond},
		{ID: 103, Duration: 100 * time.Millisecond},
		{ID: 104, Duration: 300 * time.Millisecond},
	}

	// Buffered channels prevent sender blocking
	jobs := make(chan Task, len(tasks))
	results := make(chan Result, len(tasks))

	// Spawn 3 concurrent worker goroutines
	for w := 1; w <= 3; w++ {
		go worker(w, jobs, results)
	}

	// Feed tasks into the job channel
	for _, task := range tasks {
		jobs <- task
	}
	close(jobs) // Closing tells workers no more jobs are coming

	// Collect and aggregate results
	for i := 0; i < len(tasks); i++ {
		res := <-results
		fmt.Printf("[RESULT] Job %d: %s\n", res.TaskID, res.Value)
	}
	close(results)
}

6. Confronto tra architetture: thread del sistema operativo e goroutine

Metrica/Caratteristica Thread del sistema operativo tradizionale (modello 1:1) Vai Goroutine (modello M:N)
Memoria di avvio Fisso (in genere 1 MB - 8 MB) Dinamico (inizia da ~2KB)
Ora cambio contesto Lento (1.000 - 2.000 nanosecondi) Veloce (10 - 100 nanosecondi)
Spazio di cambio Spazio del kernel (cambio di contesto pesante) Spazio utente (programmatore di runtime)
Costo di creazione Costoso (comporta chiamate di sistema del sistema operativo) Estremamente economico (allocazione semplice)
Comunicazione Memoria condivisa (Mutex, Semaforo) Canali (passaggio di messaggi CSP)
Rischio di stallo Alto (difficile da tracciare manualmente) Mitigato dalla progettazione del canale e dal rilevamento del runtime di compilazione

Conclusione

Disaccoppiando la concorrenza dal modello di threading grezzo del sistema operativo, Go ha risolto i problemi fondamentali di scalabilità delle moderne architetture backend.

Le goroutine consentono una concorrenza massiccia con un sovraccarico di memoria minimo, lo scheduler M:N ottimizza l’utilizzo della CPU tramite il furto del lavoro senza costi di cambio del contesto del kernel e i canali forniscono un paradigma di concorrenza sicuro ed espressivo.

Che tu stia creando microservizi o sistemi distribuiti di grandi dimensioni, l’architettura di concorrenza integrata di Go garantisce che la tua applicazione rimanga reattiva, efficiente in termini di risorse e facile da mantenere.


Scopri ulteriori approfondimenti sullo sviluppo software e sull’ingegneria backend sul blog di Ghaznix →