1 Introduzione alle Goroutines
1.1 Concetti di Base di Concorrenza e Parallelismo
Concorrenza e parallelismo sono due concetti comuni nella programmazione multi-threaded. Vengono utilizzati per descrivere eventi o l'esecuzione del programma che possono verificarsi contemporaneamente.
- Concorrenza si riferisce a più attività che vengono elaborate nello stesso lasso di tempo, ma solo un'attività viene eseguita in un dato momento. Le attività passano rapidamente l'una all'altra, dando all'utente l'illusione di esecuzione simultanea. La concorrenza è adatta per i processori single-core.
- Parallelismo si riferisce a più attività che vengono effettivamente eseguite contemporaneamente nello stesso momento, il che richiede il supporto dei processori multi-core.
Il linguaggio Go è progettato tenendo presente la concorrenza come uno dei suoi obiettivi principali. Essa realizza modelli efficienti di programmazione concorrente attraverso Goroutines e Canali. Il runtime di Go gestisce le Goroutines e può pianificare queste Goroutines su più thread di sistema per ottenere elaborazioni parallele.
1.2 Goroutines nel Linguaggio Go
Le Goroutines sono il concetto principale per ottenere la programmazione concorrente nel linguaggio Go. Sono thread leggeri gestiti dal runtime di Go. Dal punto di vista dell'utente, sono simili ai thread, ma consumano meno risorse e si avviano più rapidamente.
Le caratteristiche delle Goroutines includono:
- Leggere: le Goroutines occupano meno memoria di stack rispetto ai thread tradizionali e le loro dimensioni dello stack possono espandersi o contrarsi dinamicamente secondo necessità.
- Basso overhead: l'overhead per la creazione e la distruzione delle Goroutines è molto inferiore rispetto a quello dei thread tradizionali.
- Meccanismo di comunicazione semplice: i Canali forniscono un meccanismo di comunicazione semplice ed efficace tra le Goroutines.
- Design non bloccante: le Goroutines non bloccano le altre Goroutines dall'eseguire determinate operazioni. Ad esempio, mentre una Goroutine sta aspettando operazioni di I/O, altre Goroutines possono continuare ad eseguire.
2 Creazione e Gestione delle Goroutines
2.1 Come Creare una Goroutine
Nel linguaggio Go, è possibile creare facilmente una Goroutine utilizzando la parola chiave go
. Quando si prefissa una chiamata di funzione con la parola chiave go
, la funzione verrà eseguita in modo asincrono in una nuova Goroutine.
Vediamo un esempio semplice:
package main
import (
"fmt"
"time"
)
// Definire una funzione per stampare Ciao
func sayHello() {
fmt.Println("Ciao")
}
func main() {
// Avvia una nuova Goroutine utilizzando la parola chiave go
go sayHello()
// La Goroutine principale attende un periodo per consentire a sayHello di eseguirsi
time.Sleep(1 * time.Second)
fmt.Println("Funzione Principale")
}
Nel codice sopra, la funzione sayHello()
sarà eseguita in modo asincrono in una nuova Goroutine. Ciò significa che la funzione main()
non attende che sayHello()
finisca prima di continuare. Pertanto, usiamo time.Sleep
per mettere in pausa la Goroutine principale, consentendo l'esecuzione della dichiarazione di stampa in sayHello
. Questo è solo a scopo dimostrativo. Nello sviluppo effettivo, di solito utilizziamo canali o altri metodi di sincronizzazione per coordinare l'esecuzione di diverse Goroutines.
Nota: Nelle applicazioni pratiche,
time.Sleep()
non dovrebbe essere utilizzato per attendere che una Goroutine finisca, poiché non è un meccanismo di sincronizzazione affidabile.
2.2 Meccanismo di Pianificazione delle Goroutines
In Go, la pianificazione delle Goroutines è gestita dal pianificatore del runtime di Go, che è responsabile dell'allocazione del tempo di esecuzione sui processori logici disponibili. Il pianificatore di Go utilizza la tecnologia di pianificazione M:N
(multiple Goroutines mappate su più thread OS) per ottenere migliori prestazioni sui processori multi-core.
GOMAXPROCS e Processori Logici
GOMAXPROCS
è una variabile dell'ambiente che definisce il numero massimo di CPU disponibili al pianificatore del runtime, con il valore predefinito che corrisponde al numero di core della CPU della macchina. Il runtime di Go assegna un thread OS per ogni processore logico. Impostando GOMAXPROCS
, possiamo limitare il numero di core utilizzato dal runtime.
import "runtime"
func init() {
runtime.GOMAXPROCS(2)
}
Il codice sopra imposta un massimo di due core per pianificare le Goroutines, anche quando il programma viene eseguito su una macchina con più core.
Funzionamento dello Scheduler
Lo scheduler funziona utilizzando tre entità importanti: M (macchina), P (processore) e G (Goroutine). M rappresenta una macchina o un thread, P rappresenta il contesto della pianificazione e G rappresenta una Goroutine specifica.
- M: Rappresenta una macchina o un thread, fungendo da astrazione dei thread del kernel OS.
- P: Rappresenta le risorse necessarie per eseguire una Goroutine. Ogni P ha una coda locale di Goroutine.
- G: Rappresenta una Goroutine, inclusi il suo stack di esecuzione, l'insieme di istruzioni e altre informazioni.
I principi di funzionamento dello scheduler Go sono:
- M deve avere un P per eseguire G. Se non c'è un P, M verrà restituito alla cache dei thread.
- Quando G non è bloccato da altri G (ad esempio, in chiamate di sistema), viene eseguito sullo stesso M quanto più possibile, contribuendo a mantenere i dati locali di G 'caldi' per una maggiore utilizzazione efficiente della cache CPU.
- Quando una G è bloccata, M e P si separeranno e P cercherà un nuovo M o risveglierà un nuovo M per servire altre G.
go func() {
fmt.Println("Ciao dalla Goroutine")
}()
Il codice precedente mostra l'avvio di una nuova Goroutine, che indurrà lo scheduler ad aggiungere questa nuova G alla coda per l'esecuzione.
Pianificazione Preemptive delle Goroutine
Nelle fasi iniziali, Go utilizzava una pianificazione cooperativa, il che significa che le Goroutine potevano privare altre Goroutine se eseguivano a lungo senza rinunciare volontariamente al controllo. Ora, lo scheduler Go implementa una pianificazione preemptive, consentendo alle G a lunga esecuzione di essere messe in pausa per dare ad altre G la possibilità di eseguirsi.
2.3 Gestione del Ciclo di Vita delle Goroutine
Per garantire la robustezza e le prestazioni della tua applicazione Go, è cruciale comprendere e gestire correttamente il ciclo di vita delle Goroutine. Avviare le Goroutine è semplice, ma senza una gestione adeguata, possono causare problemi come memory leak e race condition.
Avvio Sicuro delle Goroutine
Prima di avviare una Goroutine, assicurati di comprendere il suo carico di lavoro e le caratteristiche dell'esecuzione. Una Goroutine dovrebbe avere un inizio e una fine chiari per evitare di creare "Goroutine orfane" senza condizioni di terminazione.
func worker(done chan bool) {
fmt.Println("Lavorando...")
time.Sleep(time.Second) // simulare un'attività costosa
fmt.Println("Lavoro terminato.")
done <- true
}
func main() {
// Qui viene utilizzato il meccanismo dei canali in Go. Puoi pensare semplicemente al canale come a una coda di messaggi di base e utilizzare l'operatore "<-" per leggere e scrivere dati in coda.
done := make(chan bool, 1)
go worker(done)
// Attendere il completamento della Goroutine
<-done
}
Il codice precedente mostra un modo per attendere il completamento di una Goroutine utilizzando il canale done
.
Nota: Questo esempio utilizza il meccanismo dei canali in Go, che verrà dettagliato nei capitoli successivi.
Arresto delle Goroutine
In generale, la fine dell'intero programma terminerà implicitamente tutte le Goroutine. Tuttavia, nei servizi a lunga esecuzione, potremmo avere bisogno di arrestare attivamente le Goroutine.
- Utilizzare i canali per inviare segnali di stop: Le Goroutine possono controllare i canali per verificare i segnali di stop.
stop := make(chan struct{})
go func() {
for {
select {
case <-stop:
fmt.Println("Ricevuto il segnale di stop. Arresto in corso...")
return
default:
// eseguire l'operazione normale
}
}
}()
// Invia il segnale di stop
stop <- struct{}{}
-
Utilizzare il pacchetto
context
per gestire il ciclo di vita:
ctx, cancel := context.WithCancel(context.Background())
go func(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("Ricevuto il segnale di stop. Arresto in corso...")
return
default:
// eseguire l'operazione normale
}
}
}(ctx)
// quando si desidera arrestare la Goroutine
cancel()
L'uso del pacchetto context
consente un controllo più flessibile delle Goroutine, fornendo funzionalità di timeout e di cancellazione. Nelle grandi applicazioni o microservizi, il context
è il modo consigliato per controllare i cicli di vita delle Goroutine.