Go Context: cos'è e come usarlo per scrivere codice robusto

banner

Mentirò iniziando questo articolo dicendo che non si parlerà di ottimizzazione, perché in realtà si parlerà proprio di quello. Tuttavia, l’ottimizzazione in Go non è tanto legata a micro ottimizzazioni del codice (anche se ovviamente ci sono), quanto piuttosto alla capacità di scrivere codice robusto, resiliente e manutenibile.

Partendo da questo (sincero) assunto, vi introduco un argomento che in Go conta tanto quanto sapere cos’è una struct o come si lancia una goroutine, cioè il context.

Sembra banale, ma andiamo fino in fondo. Etimologicamente è un termine molto usato, forse abusato, in tanti linguaggi di programmazione e in tanti framework: pensate ad esempio al contesto Spring (“ma perché nei log vedo questo bean?”, “eeeeh sta nel contesto”).

In Go il contesto (context) è più che mai utile e controllabile ed è necessario in tantissimi casi d’uso. In questo caso, questa panoramica vuole essere una semplice bussola per l’uso, soprattutto per coloro che si approcciano al linguaggio e si sentono un po’ spersi nel vederlo passare come primo argomento di (quasi) tutte le funzioni (indovinato?).

Partiamo con una definizione: ufficialmente il contesto “carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes.” (da documentazione). In altre parole, è un contenitore di informazioni pronte all’uso per tutte le routine di un programma Go al fine di non renderle isolate fra loro.

Com’è fatto

Sotto il cofano, il context è una delle interfacce più semplici e potenti della libreria standard. È composta da quattro funzioni:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

A parte la struttura ben documentata dai creatori, quello che è interessante è capirne l’organizzazione. Partiamo dal presupposto che il contesto in Go è immutabile, quindi non si modifica mai un contesto esistente, piuttosto se ne crea uno nuovo a partire da uno parent. Di conseguenza si crea una struttura ad albero:

  • se annulli un contesto parent, il segnale si propaga automaticamente a tutti i child;
  • se annulli un contesto child, il parent (e gli altri child) rimangono attivi.

Spoiler: questa gerarchia è ciò che permette al segnale di cancellazione di viaggiare in modo pulito da una richiesta HTTP fino alla singola query sul database.

Come abbiamo fatto anche negli altri articoli, non ci accontentiamo della teoria. Proviamo a capire meglio attraverso dei casi d’uso. Sarò sincero, per il/le lettore/lettrici più esperto/e e navigato/e sicuramente non saranno esaustivi ma probabilmente quelli che seguiranno sono i casi più frequenti e “normali” della nostra quotidianità.

Sincronizzazione delle routine e prevenzione dei leak

Non potevo esimermi dal complicare le cose fin da subito, ovviamente. Tuttavia credo che il caso d’uso più importante sia proprio questo.

Vero è che non capita tutti i giorni di dover risolvere problemi di sincronia tra le goroutine, dal momento che la maggior parte delle volte possono essere eseguite in modo isolato l’uno dall’altro. Tuttavia anche nei casi semplici si celano problemi subdoli che portano inevitabilmente alla saturazione delle risorse.

Il fatto che le routine siano spesso isolate non è garanzia che non possano avere problemi. Ad esempio, se devo processare un file molto grande e lo faccio in maniera parallela (opportunamente), ma il richiedente chiude la connessione, che ne faccio delle mie routine? In Go, si continuerà a macinare operazioni e arriveranno altre richieste che ne avvieranno delle altre. Lascio alla vostra immaginazione le conseguenze (alert spoiler: leak).

Per evitare questo tipo di inconvenienti, in Go esiste la possibilità di segnalare a tutte le goroutine che il turno è finito, possono smettere di processare e liberare risorse per le prossime; questo tramite il contesto. Sicuramente vuol dire complicare leggermente il codice perché oggettivamente una goroutine che rispetta questo genere di pattern è un po’ più complicata da scrivere. Solitamente ha almeno questo aspetto:

go func(ctx context.Context) {
    for {
        select {
        case <-ctx.Done(): // segnale di chiusura ricevuto
            return
        default:
            doStuff()
        }
    }
}(ctx)

Di seguito vi lascio un simpatico test che potete incollare su playground e vedere cosa succede:

package main

import (
 "context"
 "fmt"
 "time"
)

// funzione unsafe che stampa qualcosa ogni 500ms (tip: dovete killare il processo per interromperla :) )
func unsafeWorker() {
 go func() {
  for {
   fmt.Print("unsafeWorker is running\n")
   time.Sleep(500 * time.Millisecond)
  }
 }()
}

// funzione safe che stampa qualcosa ogni 500ms ma si accorge di segnali di stop
func safeWorker(ctx context.Context) {
 go func() {
  for {
   select {
   case <-ctx.Done(): // segnale di chiusura ricevuto
    fmt.Print("safeWorker received stop signal\n")
    return
   default:
    fmt.Print("safeWorker is running\n")
    time.Sleep(500 * time.Millisecond)
   }
  }
 }()
}

func main() {
 ctx, cancel := context.WithCancel(context.Background())

 unsafeWorker()
 safeWorker(ctx)

 time.Sleep(1 * time.Second)

 cancel() // segnale di cancellazione del contesto

 select {} // tip: un modo di lasciare appeso il main durante il funzionamento dei worker in questione
}

Metadati e tracing

Ora che la potenza del context è più chiara, è facile pensare di usarlo per muovere informazioni tra i vari layer di un’architettura (micro o macro che sia). Attenzione però, dobbiamo sempre pensare tenere un’architettura software pulita senza farci trasportare dall’ergonomia degli strumenti.

Ecco perché bisogna considerare i rischi di riempire il contesto con parametri di ogni tipo, per due motivi principali:

  • Tipizzazione: tutto quello che viene messo nel contesto è un interface{}* (o any nelle versioni recenti). Ciò implica che ogni volta che viene recuperato un parametro bisogna farne il cast. Il compilatore non ci aiuta e il rischio di errore è dietro l’angolo.
  • Chiarezza delle firme: la leggibilità del codice ne risente inevitabilmente. Una pessima idea è nascondere nel contesto un dato necessario alla logica della funzione, ad esempio:
func Myfunc(ctx context.Context){
     id, ok := ctx.WithValue("ID").(string)

     if !ok{
        return
     }

     doStuffWithId()

}

Molto meglio è scrivere qualcosa di più esplicito, ad esempio:

func Myfunc(ctx context.Context, id string){
     doStuffWithId()
}

Consiglio non richiesto: generalmente i dati che incidono sull’esecuzione logica della funzione dovrebbero essere sempre espliciti (parametri della funzione) mentre quelli che possono essere classificati come metadati dovrebbero viaggiare nel context (e.g. token di autenticazione o TraceID).

Tracing

Il caso più comune di metadata è proprio il tracing. Ad oggi lo standard de facto è OpenTelemetry e le librerie ufficiali per Go si basano interamente sull’uso del contesto per trasportare l’identità di una richiesta attraverso i vari microservizi. Tralasciando i dettagli implementativi su come siano strutturate le tracce e le span, sulla riconciliazioni e via dicendo, è interessante osservare come venga sfruttato il contesto per la creazione di gerarchie di span in modo quasi automatico. Esempio:

    updatedCtx, span := tracer.Start(ctx, "my-span")
    defer span.End()

Nota: quando parliamo di span, si intende un’unità di lavoro che fa parte di una traccia (trace) più grande. Ad esempio, una richiesta HTTP potrebbe essere una traccia, mentre le operazioni al database o le chiamate a servizi esterni potrebbero essere span all’interno di quella traccia.

Queste righe creano uno span che, se possibile, viene agganciato a quello già presente nel contesto ctx. Viene poi restituito un updatedCtx che contiene i nuovi metadata di tracciamento da usare nelle chiamate successive. Senza il passaggio del contesto, questa catena si romperebbe, trasformando il sistema di monitoraggio in una serie di eventi slegati e inutilizzabili.

Graceful shutdown ed integrazione con database

L’applicazione più diffusa del contesto come gestore del ciclo di vita delle applicazioni riguarda anche le integrazioni con l’esterno, quindi i server che espongono le nostre APIs e magari i database con cui interagiamo spesso.

In questo senso il context è importante per due motivi:

  • Gestione del timeout: le operazioni (HTTP o da/verso database) potrebbero rimanere appese all’infinito. Supponiamo ad esempio che durante l’esecuzione di un’operazione verso un database, il client che l’ha richiesta chiuda la connessione. Le nostre operazioni continuerebbero a occupare risorse inutilmente. Tramite il contesto (WithTimeout) siamo in grado di liberare risorse dopo un tempo di attesa massimo.
// Creiamo un contesto con un timeout di 5 secondi per lo spegnimento
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

// A questo punto, il server si chiuderà seguendo i tempi del contesto
if err := server.Shutdown(ctx); err != nil {
    log.Printf("Errore durante lo spegnimento: %v", err)
}
  • Cancellazione a cascata: soprattutto in ambito database potremmo voler interrompere un’intera sequenza di operazioni al fallimento di una di queste. Per farlo, il contesto ci permette di propagare il segnale di cancellazione su tutta la catena di operazioni per interrompere tutto il lavoro. Esempio:
// Usiamo il contesto della richiesta HTTP (r.Context())
// Se la richiesta viene cancellata dall'utente, la query si interrompe
rows, err := db.QueryContext(r.Context(), "SELECT name FROM users WHERE id = ?", id)
if err != nil {
    return err
}
defer rows.Close()

In sostanza, passare il ctx come primo argomento rimarrà una pratica ripetitiva, che sembra poco elegante, ma è ciò che rende un’applicazione Go davvero robusta. È lo strumento che permette di gestire il ciclo di vita delle operazioni e di interrompere il lavoro inutile prima che saturi le risorse.

Non vediamolo come un capriccio stilistico dei creatori di Go, ma uno strumento potente che serve a scrivere codice robusto; una volta capita l’utilità (e usato un paio di volte) non se ne fa più a meno.

Quelli che abbiamo visto sono casi molto generici, sicuramente esistono molti casi limite e più specifici che però esulano dal contesto divulgativo di questo articolo. Come le scorse volte, anche in questo caso non c’è nulla di obbligatorio, non ci sono regole ferree da rispettare ma strumenti il cui utilizzo è affidato interamente a noi.

Alla prossima!

Post correlati

TheRedCode.it - Il mondo #tech a piccoli #bit

Partners

Community, aziende e persone che supportano attivamente il blog

Logo di Codemotion
Logo di GrUSP
Logo di Python Milano
Logo di Schrodinger Hat
Logo di Python Biella Group
Logo di Fuzzy Brains
Logo di Django Girls
Logo di Improove
Logo del libro open source
Logo di NgRome
Logo de La Locanda del Tech
Logo di Tomorrow Devs
Logo di DevDojo

Vuoi diventare #tech content creator? 🖊️

Se vuoi raccontare la tua sul mondo #tech con dei post a tema o vuoi condividere la tua esperienza con la community, sei nel posto giusto! 😉

Manda una mail a collaborazioni[at]theredcode.it con la tua proposta e diventa la prossima penna del blog!

Ma sì, facciamolo!