ProgrammazioneSviluppatore Backend

Come funzionano le goroutine e lo scheduler di Go, e perché è importante gestire correttamente l'esecuzione concorrente delle attività?

Supera i colloqui con l'assistente IA Hintsage

Risposta.

Le goroutine sono thread leggeri forniti dall'architettura di Go fin dalle prime versioni per raggiungere un'efficace concorrenza. Storicamente, l'idea di un lightweight-thread è emersa come un tentativo di superare il costo dei thread di sistema, oltre a rispondere all'alta richiesta di applicazioni server scalabili. Go è stato progettato fin dall'inizio come un linguaggio per sistemi server e di rete, dove milioni di attività devono essere elaborate in parallelo.

Problema: La concorrenza può rapidamente portare a race conditions, deadlock e aumento dei consumi di memoria se non si controlla il ciclo di vita delle goroutine, non si considera la loro pianificazione e non si gestisce la loro terminazione.

Soluzione: Le goroutine vengono avviate mediante la parola chiave go. Il lavoro delle goroutine è pianificato dallo scheduler di Go, che utilizza un modello M:N (M thread di sistema gestiscono N goroutine del linguaggio Go). Per gestire il ciclo di vita si utilizzano canali, WaitGroup, context e il controllo sulla chiusura dei canali.

Esempio di codice:

package main import ("fmt"; "time") func worker(id int) { fmt.Printf("Worker %d started\n", id) time.Sleep(time.Second) fmt.Printf("Worker %d done\n", id) } func main() { for i := 1; i <= 3; i++ { go worker(i) } time.Sleep(2 * time.Second) }

Caratteristiche chiave:

  • Creazione immediata e economica di goroutine (decine di migliaia di volte più economiche rispetto ai thread di sistema).
  • Interazione diretta tramite canali, garantendo sincronizzazione e scambio di dati.
  • Necessità di controllo manuale sulla terminazione del lavoro (quali goroutine attendono, chi le interrompe, come viene segnalata la fermata).

Domande insidiose.

Se non si attende esplicitamente una goroutine in main, verrà sempre eseguita?

No, l'esecuzione di main termina — il processo si concluderà indipendentemente dallo stato delle goroutine figlie, e non tutte le attività saranno completate.

L'avvio di go func(...) da un ciclo garantisce che ogni goroutine ottenga il proprio valore delle variabili del ciclo?

No, si verifica un problema di cattura della variabile del ciclo, le goroutine potrebbero lavorare con lo stesso valore di slice/variabile. È necessario utilizzare una copia della variabile, ad esempio, passando come argomento:

for i := 0; i < 3; i++ { go func(n int) { fmt.Println(n) }(i) }

Può una goroutine bloccare lo scheduler di Go e impedire l'esecuzione delle altre?

Sì, se in essa si avvia un ciclo infinito o molto pesante senza punti di switch (ad esempio, senza chiamate di funzione temporale o yield), può trattenere il thread di sistema — anche se ciò contrasta con l'ideologia di Go riguardo alla "multitasking cooperativa". Ad esempio, una funzione pesante senza blocchi:

func busy() { for { // Nessuna attesa o chiamate bloccanti } }

Errori tipici e anti-pattern

  • Avvio di goroutine senza controllarne la terminazione
  • Cattura delle variabili del ciclo senza passarle all'interno delle funzioni anonime
  • Sovraccarico del sistema a causa di "goroutine che perdono memoria" (leak di goroutine non terminate)
  • Ignorare gli errori di sincronizzazione durante lo scambio tramite canali

Esempio dalla vita reale

Caso negativo

In un microservizio si avvia periodicamente una goroutine per leggere dal database, ma si dimentica di terminarla in caso di annullamento della richiesta. Di conseguenza, rimangono "appese" goroutine che nel tempo portano al consumo di tutta la memoria RAM.

Vantaggi:

  • Alta velocità di avvio
  • Facilità di scalabilità

Svantaggi:

  • Perdite di memoria
  • Aumento dei tempi di risposta
  • Completamento imprevedibile

Caso positivo

Si utilizza il context per controllare l'annullamento delle attività, WaitGroup — per gestire la terminazione di tutte le goroutine prima dell'arresto dell'applicazione, e canali — per una corretta trasmissione dei dati tra gli esecutori.

Vantaggi:

  • Ciclo di vita prevedibile
  • Gestione della terminazione
  • Facile scalabilità

Svantaggi:

  • Necessità di scrivere esplicitamente la logica di annullamento e sincronizzazione
  • Architettura del programma leggermente più complessa