CodeGym /Corsi /Go SELF /Concorrenza vs parallelismo: cosa fa il runtime di Go

Concorrenza vs parallelismo: cosa fa il runtime di Go

Go SELF
Livello 65, Lezione 0
Disponibile

1. A cosa ci serve la concorrenza

Se fino a questo momento programmare vi sembrava qualcosa del tipo «scrivi i comandi — il computer esegue obbediente», la concorrenza cambia un po' le regole del gioco: ora scrivete i comandi e l'ordine di alcuni eventi diventa... non affare vostro. E non è un bug, ma una caratteristica importante: il mondo attorno al programma non aspetta che abbiate finito una funzione per iniziarne un'altra.

Immaginate una situazione applicativa tipica: leggiamo un file, elaboriamo i dati, stampiamo il risultato; in parallelo vorremmo registrare log sul progresso, reagire a un'annullamento, servire richieste in arrivo o semplicemente non «congelare» l'interfaccia. In scenari del genere la concorrenza non è un «acceleratore», ma un modo di organizzare il lavoro. Go è famoso proprio perché offre un modello di concorrenza comodo «out of the box».

Concorrenza e parallelismo: qual è la differenza

Le parole sono davvero simili, quindi spesso si confondono, e poi ci si stupisce dei risultati. La concorrenza riguarda la struttura del programma (come organizziamo più attività), il parallelismo riguarda l'hardware (come vengono davvero eseguite le istruzioni). Si può avere concorrenza senza parallelismo, e si può avere parallelismo senza una «buona» concorrenza nel senso di un modello di codice comodo.

Fissiamo le definizioni nel modo più pratico possibile.

Termine Definizione semplice Cosa di solito si “ottiene”
Concorrenza (concurrency) Più attività logiche avanzano “intervallandosi nel tempo” Una struttura comoda: attesa di I/O, attività in background, gestione di più richieste
Parallelismo (parallelism) Esecuzione davvero simultanea su più core CPU Un potenziale aumento di velocità nei calcoli (ma non garantito)

Una buona analogia (una sola, promesso, senza fan club delle analogie): la concorrenza è quando un cuoco fa bollire la minestra e friggere la cotoletta allo stesso tempo, passando dall'una all'altra. Il parallelismo è quando avete due cuochi che cucinano davvero contemporaneamente. Si può organizzare il lavoro in modo concorrenziale con un solo cuoco (riesce a fare di più), ma resta comunque una sola persona. E viceversa: due cuochi possono intralciarsi a vicenda se il piano è pessimo.

2. Il runtime di Go e lo scheduler delle goroutine

Processo, thread OS e “magia” delle goroutine

Quando avviate un programma, il sistema operativo crea un processo, e dentro il processo ci sono thread e altra infrastruttura. Se si scrive codice concorrente «alla cieca» usando direttamente i thread del sistema operativo, di solito la cosa diventa pesante: i thread sono costosi, non se ne possono creare molti, e la sincronizzazione si trasforma rapidamente in una missione «indovina dov'è il deadlock».

Go fa un trucco che in pratica sembra «abbiano portato la magia»: invece di costringervi a gestire direttamente i thread OS, Go vi offre le goroutine, unità di esecuzione molto leggere. Ma le goroutine non vivono da sole: sono gestite dal runtime di Go.

Il runtime di Go è la parte della piattaforma che gira insieme al vostro codice e si occupa di diverse cose importanti. Pianifica le goroutine, gestisce il garbage collector (GC), aiuta con le attese di rete e, in generale, fa sì che il vostro codice appaia semplice ma funzioni velocemente. Storicamente, i miglioramenti delle prestazioni di Go hanno riguardato anche lo scheduler delle goroutine; per esempio, nelle note di rilascio di Go 1.1 venivano menzionate separatamente ottimizzazioni, incluso lo scheduler, oltre all'apparizione del race detector come strumento importante per il codice concorrente.

Modello semplificato G / M / P

Ora arriva un punto importante: non stiamo entrando nei dettagli interni del runtime come in un «corso sugli interni di Go». Ma ci serve un modello così da poter ragionare correttamente sul comportamento di un programma concorrente e non costruire la logica su coincidenze casuali.

In Go si usa spesso un modello semplificato di tre lettere:

Lettera Come si decifra Significato intuitivo
G goroutine «attività» che vuole essere eseguita
M machine (thread) thread OS che esegue davvero le istruzioni
P processor (scheduler context) «permesso» di eseguire codice Go; intermediario tra G e M

Detto in modo semplice, le goroutine (G) sono una coda di attività. I thread OS (M) sono le mani operative reali. E P è il limitatore e il dispatcher: quanti «posti di lavoro» per eseguire codice Go sono attualmente attivi.

In modo schematico si può rappresentare così:

flowchart LR
    G1[Goroutine G] --> P1[P: pianificazione]
    G2[Goroutine G] --> P1
    P1 --> M1[M: thread OS]
    P2[P: pianificazione] --> M2[M: thread OS]

Su GOMAXPROCS

Esiste il parametro GOMAXPROCS, che determina quanti «P» il runtime usa per eseguire codice Go. Nelle versioni moderne di Go il valore predefinito di solito corrisponde al numero di CPU disponibili (core/processori logici), e questo spesso significa: il parallelismo è possibile, ma non è comunque garantito «a vostro piacimento».

Potete semplicemente vedere il valore corrente così:

package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println(runtime.GOMAXPROCS(0)) // ad esempio: 8
}

Qui runtime.GOMAXPROCS(0) restituisce il valore corrente senza modificarlo. Per noi, adesso, non è importante l'impostazione, ma l'idea: il runtime decide quanto davvero può essere eseguito contemporaneamente del codice Go.

4. Non determinismo e ciclo di vita del programma

Perché l'ordine di esecuzione non è un contratto

Questo è il cuore della lezione di oggi. Finché avete un solo flusso di esecuzione, potete leggere mentalmente il programma dall'alto verso il basso e capire più o meno cosa accadrà. Appena aggiungete la concorrenza, entra in gioco uno scheduler che può alternare l'esecuzione tra attività diverse. Quindi alcune cose, per esempio l'ordine di stampa, diventano non deterministiche.

Prima un esempio di controllo: codice sequenziale normale. Qui l'ordine è granitico:

package main

import "fmt"

func main() {
	fmt.Println("A") // A
	fmt.Println("B") // B
}

Ora attiviamo minimamente la concorrenza (sì, basta una sola parola: go). E all'improvviso il vostro «predittore del futuro» interno inizia a sbagliare:

package main

import (
	"fmt"
	"time"
)

func main() {
	go fmt.Println("A")
	go fmt.Println("B")

	time.Sleep(10 * time.Millisecond) // trucco per la dimostrazione
}

Ciò che è importante capire qui (ed è proprio un indicatore in grassetto): l'ordine “A/B” non è garantito. A volte sarà A poi B, a volte B poi A, a volte vi sembrerà addirittura che il computer vi stia prendendo in giro (in realtà no, sta solo lavorando onestamente).

time.Sleep qui serve solo perché il programma non termini all'istante. Non è un «modo corretto di aspettare», è un oggetto di scena dimostrativo, come una chitarra di cartone a lezione di musica: somiglia a una chitarra, ma non ci si può suonare in concerto.

main finita = processo terminato

C'è una regola che i principianti quasi sempre imparano «sperimentalmente», cioè per sorpresa: se la funzione main termina, termina l'intero processo, e nessuna goroutine non ancora finita aspetterà «educatamente» di completare il proprio lavoro. Semplicemente si interrompono insieme al programma.

Vediamo un esempio:

package main

import (
	"fmt"
	"time"
)

func main() {
	go func() {
		time.Sleep(50 * time.Millisecond)
		fmt.Println("goroutine finished") // potrebbe non essere stampato
	}()

	fmt.Println("main finished") // main finished
}

Qui main stampa "main finished" ed esce. Dopo di ciò il processo ha pieno diritto di terminare prima che la goroutine si svegli dopo Sleep. A volte (a seconda dello scheduler e della velocità) avrete fortuna e vedrete entrambe le righe, ma sarebbe proprio «fortuna», non un contratto.

Questa regola è particolarmente importante per le applicazioni reali (CLI, server, worker): se avviate un lavoro in background, dovete pensare esplicitamente a chi e come aspetterà la sua conclusione.

5. Prestazioni e attesa

Perché la concorrenza non deve per forza velocizzare

Un pensiero molto umano: «Se faccio tutto in parallelo, sarà più veloce». A volte sì. A volte no. A volte diventa persino più lento — e anche questo è normale.

La concorrenza dà spesso vantaggi soprattutto negli scenari I/O: rete, dischi, attesa di timer, richieste. Lì non siete costantemente limitati dalla CPU, e la possibilità di «passare ad altro» aumenta davvero la capacità complessiva di elaborazione.

Ma per i task CPU-bound (quando fate solo calcoli) l'accelerazione dipende dal numero di core, dai costi aggiuntivi della pianificazione, dal lavoro del GC e dal fatto che le goroutine non abbiano iniziato a litigare energicamente per la stessa risorsa. Quindi la concorrenza è prima di tutto una questione di organizzazione, non di «turbo-mode».

Se vogliamo dirlo in modo molto breve e onesto: la concorrenza rende il programma più capace di fare molte cose, e il parallelismo, se c'è, può rendere alcuni calcoli più veloci, ma solo se non vi mettete voi stessi i bastoni tra le ruote.

Perché Sleep non è sincronizzazione

Ora arriva un punto che i principianti adorano, perché «funziona». E poi iniziano a odiarlo, perché «su un altro computer non funziona». Parliamo del tentativo di sincronizzazione tramite time.Sleep.

Immaginiamo di voler «aspettare» che una goroutine faccia qualcosa. Il percorso più allettante sembra questo:

package main

import (
	"fmt"
	"time"
)

func main() {
	go func() {
		fmt.Println("work done") // work done
	}()

	time.Sleep(1 * time.Millisecond) // "speriamo che basti"
}

Il problema è che questo non è un protocollo di attesa, ma una puntata al casinò. Sulla vostra macchina può «bastare», su CI no, sul portatile in modalità risparmio energetico — «a volte», e nel momento in cui si apre il browser con 40 schede accanto — proprio triste.

La conclusione corretta di questa lezione suona così: Sleep può aiutare a mostrare l'effetto della concorrenza negli esempi, ma non deve essere il modo per rendere corretto un programma.

6. Dove compare la concorrenza nelle applicazioni reali

Anche se oggi non scriverete nemmeno una goroutine, la concorrenza è già lì vicino, perché i programmi moderni raramente vivono in un vuoto ideale. Appena compaiono un server, la gestione di più richieste, attività in background, logging, timeout, terminazione corretta — iniziamo a ragionare in termini di attività che vivono «contemporaneamente».

Per esempio, quando parliamo di una chiusura ordinata di un server, vogliamo che il server smetta di accettare nuove richieste ma attenda il completamento di quelle in corso. Nell'ecosistema Go questo è uno scenario importante e supportato da tempo: nelle note di rilascio di Go 1.8, per esempio, viene menzionato separatamente il graceful shutdown del server HTTP come possibilità di «minimizzare il downtime», aspettando le richieste «in volo».

La lezione di oggi vi serve proprio come base mentale: quando più avanti vedrete codice che «avvia il lavoro separatamente» e «aspetta un gruppo di attività», non lo percepirete come un rituale magico. Capirete: il runtime pianifica l'esecuzione, l'ordine non è garantito e la correttezza richiede accordi espliciti.

7. Errori tipici e perché accadono

Errore n. 1: confondere concorrenza con velocità e restare delusi.
Il principiante lancia più attività «allo stesso tempo», si aspetta che tutto diventi due volte più veloce, e ottiene lo stesso tempo o persino peggio. Succede perché la concorrenza riguarda soprattutto la struttura, mentre l'accelerazione è possibile solo in presenza di parallelismo e di una corretta divisione del lavoro senza inutile contesa per le risorse.

Errore n. 2: considerare fisso l'ordine di esecuzione (o di stampa).
Non appena compare la concorrenza, l'ordine degli eventi diventa non deterministico. Se scrivete codice che dipende logicamente dal fatto che venga stampato prima "A" e poi "B", allora state costruendo una casa sulla sabbia. A volte resta in piedi, a volte no — e la cosa peggiore è che si rompe «di rado», cioè nel momento più scomodo.

Errore n. 3: usare time.Sleep come meccanismo di attesa della conclusione.
Sleep è una pausa, non un accordo. Non dice al runtime «aspetta la goroutine», dice al vostro thread «riposa un attimo». Sulle macchine veloci «casualmente» si entra nell'intervallo giusto, su quelle lente no. I programmi affidabili non si costruiscono così: usano protocolli espliciti per aspettare la fine del lavoro.

Errore n. 4: pensare che le goroutine manterranno in vita il programma.
È una trappola psicologica comune: se una goroutine sta facendo qualcosa, allora il programma dovrebbe aspettare. Ma Go è fatto diversamente: la terminazione di main termina l'intero processo e le goroutine non concluse si interrompono. Non è crudeltà del linguaggio — è una regola semplice e utile del ciclo di vita.

Errore n. 5: cercare di indovinare il comportamento dello scheduler.
A volte viene da pensare: «beh, avvierà prima questa goroutine, perché l'ho creata per prima». Lo scheduler non ha promesso niente a nessuno. Inoltre, il comportamento reale dipende dalla versione del runtime, dal carico, dal numero di core, dai timing, dal GC e da moltissimi dettagli. L'unica strategia stabile è non costruire la correttezza su ipotesi di ordine e tempo di esecuzione senza un accordo esplicito.

1
Compito
Go SELF, livello 65, lezione 0
Bloccato
Due termini
Due termini
1
Compito
Go SELF, livello 65, lezione 0
Bloccato
Limite dei processori
Limite dei processori
1
Compito
Go SELF, livello 65, lezione 0
Bloccato
Modalità dello scheduler
Modalità dello scheduler
1
Compito
Go SELF, livello 65, lezione 0
Bloccato
Modello di accelerazione
Modello di accelerazione
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION