Il context in Go

Il pacchetto context della libreria standard di Go rappresenta uno degli strumenti più potenti e idiomatici a disposizione degli sviluppatori per gestire la propagazione di segnali di cancellazione, scadenze temporali e valori arbitrari attraverso la catena di chiamate di una goroutine. Introdotto ufficialmente in Go 1.7, il pacchetto context ha ridefinito il modo in cui i programmi Go gestiscono operazioni concorrenti, richieste HTTP, accessi a database e qualsiasi forma di lavoro asincrono che richieda coordinamento e controllo del ciclo di vita.

Comprendere il context in Go significa comprendere la filosofia stessa del linguaggio: semplicità, esplicitezza e controllo. A differenza di altri meccanismi di gestione del ciclo di vita presenti in altri linguaggi, il context in Go viene passato esplicitamente come primo argomento alle funzioni, rendendo il flusso di controllo immediatamente leggibile e verificabile staticamente dal compilatore.

La struttura fondamentale: l'interfaccia Context

Il cuore del pacchetto è l'interfaccia Context, definita nel seguente modo:

// Context definisce il contratto fondamentale del pacchetto
type Context interface {
    // Deadline restituisce il momento in cui il lavoro deve essere completato
    Deadline() (deadline time.Time, ok bool)

    // Done restituisce un canale chiuso quando il context viene annullato
    Done() <-chan struct{}

    // Err restituisce l'errore che spiega perché il context è stato annullato
    Err() error

    // Value restituisce il valore associato alla chiave specificata
    Value(key any) any
}

Ogni metodo dell'interfaccia assolve un compito preciso e ortogonale agli altri. Deadline consente alle goroutine di sapere in anticipo quanto tempo hanno a disposizione per completare il proprio lavoro, permettendo loro di allocare le risorse di conseguenza. Done restituisce un canale in sola lettura che viene chiuso quando il context viene cancellato o scade: questo è il meccanismo principale con cui le goroutine ricevono il segnale di terminazione. Err fornisce la ragione della cancellazione, restituendo context.Canceled in caso di cancellazione esplicita o context.DeadlineExceeded in caso di scadenza del timeout. Infine, Value permette di recuperare valori arbitrari associati al context, una funzionalità da usare con cautela.

I context radice: Background ed TODO

Il pacchetto context fornisce due context radice, entrambi non cancellabili, privi di scadenza e senza valori associati. Questi sono i punti di partenza da cui derivano tutti gli altri context attraverso le funzioni di derivazione.

package main

import (
    "context"
    "fmt"
)

func main() {
    // Background è il context radice principale, usato in main, init e nei test
    bgCtx := context.Background()
    fmt.Println(bgCtx)

    // TODO è usato quando non si è sicuri di quale context usare
    todoCtx := context.TODO()
    fmt.Println(todoCtx)
}

context.Background() è il context radice che viene tipicamente usato nelle funzioni di livello superiore come main, nei test e nei punti di ingresso delle richieste in entrata. context.TODO() è semanticamente identico a Background, ma comunica agli sviluppatori e agli strumenti di analisi statica che il context in quel punto è ancora indeterminato e dovrà essere raffinato in futuro. L'utilizzo di TODO è una forma di documentazione nel codice.

Derivare context con WithCancel

context.WithCancel è la funzione di derivazione più fondamentale. Restituisce una copia del context padre con un nuovo canale Done e una funzione cancel che, quando invocata, chiude tale canale e cancella tutti i context figli derivati da questo.

package main

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

func fetchData(ctx context.Context, id int) {
    select {
    case <-time.After(2 * time.Second):
        // Simula un'operazione lunga che termina normalmente
        fmt.Printf("dati recuperati per id=%d\n", id)
    case <-ctx.Done():
        // Il context è stato cancellato prima del completamento
        fmt.Printf("operazione annullata per id=%d: %v\n", id, ctx.Err())
    }
}

func main() {
    // Crea un context cancellabile derivato da Background
    ctx, cancel := context.WithCancel(context.Background())

    // Defer garantisce che la funzione cancel venga sempre chiamata
    defer cancel()

    // Avvia la goroutine con il context cancellabile
    go fetchData(ctx, 42)

    // Annulla il context dopo 500ms, prima che fetchData termini
    time.Sleep(500 * time.Millisecond)
    cancel()

    // Aspetta che la goroutine stampi il messaggio di annullamento
    time.Sleep(100 * time.Millisecond)
}

Un aspetto critico da comprendere è che la funzione cancel deve essere sempre chiamata per evitare memory leak. Anche se il context padre viene cancellato, i context figli e le relative risorse interne non vengono liberati fino a quando cancel non viene invocata. Per questa ragione è quasi sempre corretto utilizzare defer cancel() immediatamente dopo la creazione del context.

Scadenze e timeout: WithDeadline e WithTimeout

Gestire le scadenze temporali è uno dei casi d'uso più comuni del pacchetto context. Le funzioni WithDeadline e WithTimeout consentono di specificare rispettivamente un momento assoluto nel tempo e una durata relativa oltre la quale il context viene automaticamente cancellato.

package main

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

func queryDatabase(ctx context.Context, query string) (string, error) {
    // Simula una query al database con latenza variabile
    done := make(chan string, 1)

    go func() {
        // Simula il tempo di esecuzione della query
        time.Sleep(3 * time.Second)
        done <- "risultato: record trovato"
    }()

    select {
    case result := <-done:
        return result, nil
    case <-ctx.Done():
        // Il context è scaduto prima del completamento della query
        return "", ctx.Err()
    }
}

func main() {
    // WithTimeout: il context scade dopo 1 secondo
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel()

    result, err := queryDatabase(ctx, "SELECT * FROM users")
    if err != nil {
        fmt.Printf("errore: %v\n", err)
        return
    }
    fmt.Println(result)
}

La differenza tra WithDeadline e WithTimeout è puramente sintattica: WithTimeout(ctx, d) è equivalente a WithDeadline(ctx, time.Now().Add(d)). In entrambi i casi, il runtime di Go si occuperà di cancellare automaticamente il context al momento opportuno, anche se la funzione cancel restituita non viene mai chiamata esplicitamente. Tuttavia, chiamare cancel prima della scadenza è sempre preferibile perché libera le risorse immediatamente.

package main

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

func main() {
    // WithDeadline: scade a un momento assoluto nel tempo
    deadline := time.Now().Add(2 * time.Second)
    ctx, cancel := context.WithDeadline(context.Background(), deadline)
    defer cancel()

    // Verifica se esiste una deadline e quanto tempo rimane
    if d, ok := ctx.Deadline(); ok {
        fmt.Printf("deadline impostata: %v\n", d)
        fmt.Printf("tempo rimanente: %v\n", time.Until(d))
    }

    select {
    case <-time.After(3 * time.Second):
        fmt.Println("operazione completata")
    case <-ctx.Done():
        // Il context è scaduto prima del completamento
        fmt.Printf("scaduto: %v\n", ctx.Err())
    }
}

Propagare valori con WithValue

Il metodo Value dell'interfaccia Context e la funzione context.WithValue consentono di associare coppie chiave-valore a un context e propagarle lungo la catena di chiamate. Questa funzionalità è spesso oggetto di discussione in quanto può essere utilizzata in modo improprio. La documentazione ufficiale di Go è esplicita: i valori nel context devono essere dati a livello di richiesta, non parametri opzionali delle funzioni.

package main

import (
    "context"
    "fmt"
)

// contextKey è un tipo privato per evitare collisioni di chiavi tra pacchetti
type contextKey struct{ name string }

var (
    // requestIDKey è la chiave per recuperare l'ID della richiesta HTTP
    requestIDKey = contextKey{"requestID"}
    // userIDKey è la chiave per recuperare l'ID dell'utente autenticato
    userIDKey    = contextKey{"userID"}
)

func withRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, requestIDKey, id)
}

func requestIDFromContext(ctx context.Context) (string, bool) {
    // Recupera il valore e verifica che sia del tipo corretto
    id, ok := ctx.Value(requestIDKey).(string)
    return id, ok
}

func processRequest(ctx context.Context) {
    if id, ok := requestIDFromContext(ctx); ok {
        fmt.Printf("elaborazione richiesta con ID: %s\n", id)
    } else {
        fmt.Println("nessun ID richiesta nel context")
    }
}

func main() {
    ctx := context.Background()

    // Aggiunge l'ID della richiesta al context
    ctx = withRequestID(ctx, "req-abc-123")

    // Aggiunge l'ID utente al context derivato
    ctx = context.WithValue(ctx, userIDKey, 99)

    processRequest(ctx)
}

Un aspetto fondamentale è l'uso di tipi privati come chiave per evitare le collisioni tra pacchetti. Se si usasse una stringa ordinaria come "userID" come chiave, due pacchetti diversi potrebbero accidentalmente sovrascrivere i valori dell'altro. Usando un tipo definito localmente, come contextKey nell'esempio, si garantisce che le chiavi siano opache e uniche nel loro scope.

Il context nelle richieste HTTP

L'integrazione del pacchetto context con il server HTTP della libreria standard di Go è uno degli scenari più comuni e importanti. A partire da Go 1.7, ogni http.Request include un context che viene automaticamente cancellato quando la connessione del client viene chiusa, quando la richiesta viene completata o quando il timeout del server scatta.

package main

import (
    "context"
    "fmt"
    "net/http"
    "time"
)

type contextKey struct{ name string }

var requestIDKey = contextKey{"requestID"}

// requestIDMiddleware inietta un ID univoco nel context di ogni richiesta
func requestIDMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Genera un ID per questa richiesta (semplificato)
        id := fmt.Sprintf("req-%d", time.Now().UnixNano())

        // Arricchisce il context della richiesta con l'ID
        ctx := context.WithValue(r.Context(), requestIDKey, id)

        // Propaga il context arricchito al prossimo handler
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// slowHandler simula un handler che esegue un'operazione lenta
func slowHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    // Recupera l'ID richiesta dal context
    id, _ := ctx.Value(requestIDKey).(string)

    select {
    case <-time.After(5 * time.Second):
        fmt.Fprintf(w, "[%s] risposta completata\n", id)
    case <-ctx.Done():
        // Il client ha chiuso la connessione o il server ha cancellato la richiesta
        fmt.Printf("[%s] richiesta annullata: %v\n", id, ctx.Err())
        http.Error(w, "richiesta annullata", http.StatusServiceUnavailable)
    }
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/slow", slowHandler)

    // Applica il middleware all'intero server
    handler := requestIDMiddleware(mux)

    server := &http.Server{
        Addr:         ":8080",
        Handler:      handler,
        // Timeout globale per tutte le richieste in entrata
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 10 * time.Second,
    }

    fmt.Println("server in ascolto su :8080")
    server.ListenAndServe()
}

Effettuare chiamate HTTP con il context

Il pacchetto net/http supporta il context anche lato client. L'uso di http.NewRequestWithContext consente di associare un context a una richiesta in uscita, in modo che la richiesta venga automaticamente annullata se il context viene cancellato o scade.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "time"
)

func fetchURL(ctx context.Context, url string) (string, error) {
    // Crea la richiesta HTTP con il context fornito dal chiamante
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return "", fmt.Errorf("creazione richiesta: %w", err)
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return "", fmt.Errorf("esecuzione richiesta: %w", err)
    }
    defer resp.Body.Close()

    // Legge il corpo della risposta
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", fmt.Errorf("lettura risposta: %w", err)
    }

    return string(body), nil
}

func main() {
    // Imposta un timeout di 3 secondi per la chiamata HTTP esterna
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    body, err := fetchURL(ctx, "https://example.com")
    if err != nil {
        fmt.Printf("errore: %v\n", err)
        return
    }
    fmt.Printf("ricevuti %d byte\n", len(body))
}

Context e goroutine: pattern di coordinazione

Uno dei pattern più potenti che il pacchetto context abilita è la cancellazione a cascata di goroutine. Quando un context padre viene cancellato, tutti i context figli derivati vengono automaticamente cancellati, e questo consente di arrestare interi alberi di goroutine con una singola chiamata.

package main

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

// worker simula un'unità di lavoro che rispetta il context
func worker(ctx context.Context, id int, wg *sync.WaitGroup) {
    defer wg.Done()

    for {
        select {
        case <-ctx.Done():
            // Il segnale di cancellazione è arrivato, il worker termina
            fmt.Printf("worker %d terminato: %v\n", id, ctx.Err())
            return
        case <-time.After(500 * time.Millisecond):
            // Esegue un'iterazione di lavoro
            fmt.Printf("worker %d: ciclo completato\n", id)
        }
    }
}

func main() {
    // Context radice con timeout di 2 secondi
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    var wg sync.WaitGroup
    numWorkers := 3

    for i := 1; i <= numWorkers; i++ {
        wg.Add(1)
        // Ogni worker riceve lo stesso context: alla scadenza tutti terminano
        go worker(ctx, i, &wg)
    }

    // Attende che tutti i worker abbiano terminato
    wg.Wait()
    fmt.Println("tutti i worker hanno terminato")
}

Context con errgroup

Il pacchetto golang.org/x/sync/errgroup estende i pattern del context fornendo un modo idiomatico per gestire gruppi di goroutine con cancellazione automatica al primo errore. errgroup.WithContext crea un gruppo e un context derivato che viene automaticamente cancellato quando una delle goroutine del gruppo restituisce un errore non nil.

package main

import (
    "context"
    "fmt"
    "time"

    "golang.org/x/sync/errgroup"
)

func fetchResource(ctx context.Context, name string, duration time.Duration, fail bool) error {
    select {
    case <-time.After(duration):
        if fail {
            // Simula un errore che causa la cancellazione dell'intero gruppo
            return fmt.Errorf("errore nel recupero di %s", name)
        }
        fmt.Printf("%s: completato con successo\n", name)
        return nil
    case <-ctx.Done():
        // Un'altra goroutine ha fallito e questo context è stato annullato
        fmt.Printf("%s: annullato a causa di: %v\n", name, ctx.Err())
        return ctx.Err()
    }
}

func main() {
    // Crea un errgroup con context derivato da Background
    g, ctx := errgroup.WithContext(context.Background())

    // Avvia tre operazioni parallele; la seconda fallirà dopo 1 secondo
    g.Go(func() error {
        return fetchResource(ctx, "risorsa-A", 2*time.Second, false)
    })
    g.Go(func() error {
        return fetchResource(ctx, "risorsa-B", 1*time.Second, true)
    })
    g.Go(func() error {
        return fetchResource(ctx, "risorsa-C", 3*time.Second, false)
    })

    // Attende il completamento di tutte le goroutine e raccoglie il primo errore
    if err := g.Wait(); err != nil {
        fmt.Printf("errore del gruppo: %v\n", err)
    }
}

Context con WithCancelCause (Go 1.20+)

Go 1.20 ha introdotto context.WithCancelCause, una variante di WithCancel che consente di associare una causa specifica alla cancellazione. Questo risolve un limite storico del pacchetto: quando un context viene cancellato, ctx.Err() restituisce solo Canceled o DeadlineExceeded, senza alcun dettaglio sulla causa originale. Con WithCancelCause, la causa può essere recuperata tramite context.Cause(ctx).

package main

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

var (
    // ErrRateLimit rappresenta un errore di limite di velocità API
    ErrRateLimit = errors.New("limite di velocità API superato")
    // ErrUnauthorized rappresenta un errore di autenticazione
    ErrUnauthorized = errors.New("autenticazione non valida")
)

func main() {
    // Crea un context con cause, che permette di specificare la ragione
    ctx, cancel := context.WithCancelCause(context.Background())
    defer cancel(nil)

    go func() {
        time.Sleep(500 * time.Millisecond)
        // Annulla con una causa specifica e significativa
        cancel(ErrRateLimit)
    }()

    select {
    case <-ctx.Done():
        // ctx.Err() restituisce context.Canceled, non la causa originale
        fmt.Printf("ctx.Err(): %v\n", ctx.Err())

        // context.Cause() restituisce la causa specifica passata a cancel
        fmt.Printf("causa: %v\n", context.Cause(ctx))

        // Ora è possibile fare branch sulla causa specifica
        if errors.Is(context.Cause(ctx), ErrRateLimit) {
            fmt.Println("azione: aspetta e riprova più tardi")
        }
    }
}

Implementare un context personalizzato

Sebbene sia raro, è possibile implementare l'interfaccia Context per creare context con comportamenti personalizzati. Un caso d'uso tipico è la creazione di un context che non può essere cancellato, utile quando si vuole isolare il ciclo di vita di un'operazione da quello del suo chiamante.

package main

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

// detachedContext è un context che non può essere cancellato dal padre
type detachedContext struct {
    ctx context.Context
}

// Deadline non ha scadenza per il context distaccato
func (d detachedContext) Deadline() (time.Time, bool) {
    return time.Time{}, false
}

// Done restituisce sempre nil: questo context non viene mai cancellato
func (d detachedContext) Done() <-chan struct{} {
    return nil
}

// Err restituisce sempre nil poiché il context non è mai cancellato
func (d detachedContext) Err() error {
    return nil
}

// Value delega la ricerca dei valori al context originale
func (d detachedContext) Value(key any) any {
    return d.ctx.Value(key)
}

// detach crea un context distaccato che non eredita la cancellazione del padre
func detach(ctx context.Context) context.Context {
    return detachedContext{ctx: ctx}
}

func sendAuditLog(ctx context.Context, message string) {
    // Questa operazione deve completarsi anche se la richiesta padre è cancellata
    fmt.Printf("audit log: %s (contex cancellato: %v)\n", message, ctx.Err() != nil)
    time.Sleep(100 * time.Millisecond)
    fmt.Println("audit log salvato con successo")
}

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

    // Annulla il context immediatamente
    cancel()

    // Distacca il context per l'operazione critica di audit
    auditCtx := detach(ctx)

    // sendAuditLog non verrà interrotto dalla cancellazione del padre
    sendAuditLog(auditCtx, "operazione di pagamento avviata")
}

Best practice e anti-pattern

L'uso corretto del pacchetto context richiede di interiorizzare alcune regole fondamentali che la comunità Go ha definito nel corso degli anni. La prima e più importante è che il context deve sempre essere il primo parametro di una funzione e deve essere chiamato ctx per convenzione. Non memorizzare mai un context in una struttura dati: i context sono pensati per essere passati esplicitamente.

// ERRATO: context memorizzato nella struttura, non passato esplicitamente
type badService struct {
    ctx context.Context
    db  *database
}

// CORRETTO: context passato come primo parametro del metodo
type goodService struct {
    db *database
}

func (s *goodService) FindUser(ctx context.Context, id int64) (*User, error) {
    // Passa il context alle operazioni sottostanti
    return s.db.QueryContext(ctx, "SELECT * FROM users WHERE id = ?", id)
}

Un altro anti-pattern comune è l'abuso di context.WithValue per passare parametri che dovrebbero essere argomenti espliciti delle funzioni. Il context non è un modo per aggirare le firme delle funzioni: i valori nel context devono essere dati trasversali come ID di tracciatura, credenziali di autenticazione o informazioni di localizzazione, non dati di business logic.

// ERRATO: usare WithValue per passare parametri della logica di business
func createOrder(ctx context.Context) error {
    // Questo è un anti-pattern: i parametri business non appartengono al context
    productID := ctx.Value("productID").(int)
    quantity  := ctx.Value("quantity").(int)
    _ = productID
    _ = quantity
    return nil
}

// CORRETTO: i parametri di business sono argomenti espliciti della funzione
func createOrderCorrect(ctx context.Context, productID, quantity int) error {
    // Il context trasporta solo metadati trasversali
    return nil
}

Context nei test

I test che coinvolgono operazioni con timeout, cancellazioni o goroutine traggono grande beneficio dal pacchetto context. È possibile creare context con timeout brevi nei test per garantire che le operazioni lente vengano identificate come fallimenti, oppure usare context cancellabili per simulare scenari di interruzione.

package main

import (
    "context"
    "errors"
    "testing"
    "time"
)

// processWithContext simula un'operazione che rispetta il context
func processWithContext(ctx context.Context) error {
    select {
    case <-time.After(5 * time.Second):
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

// TestProcessWithContextTimeout verifica il comportamento in caso di timeout
func TestProcessWithContextTimeout(t *testing.T) {
    // Il context scade in 100ms, molto meno dei 5 secondi dell'operazione
    ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    defer cancel()

    err := processWithContext(ctx)

    if !errors.Is(err, context.DeadlineExceeded) {
        t.Errorf("atteso DeadlineExceeded, ottenuto: %v", err)
    }
}

// TestProcessWithContextCancellation verifica il comportamento in caso di cancellazione
func TestProcessWithContextCancellation(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())

    // Annulla il context dopo 50ms
    go func() {
        time.Sleep(50 * time.Millisecond)
        cancel()
    }()

    err := processWithContext(ctx)

    if !errors.Is(err, context.Canceled) {
        t.Errorf("atteso Canceled, ottenuto: %v", err)
    }
}

Conclusioni

Il pacchetto context di Go è molto più di un semplice meccanismo di cancellazione. È la realizzazione concreta di un principio di design fondamentale: il controllo esplicito e consapevole del ciclo di vita delle operazioni. Attraverso la propagazione esplicita lungo la catena di chiamate, il context rende visibile e controllabile qualcosa che in altri linguaggi è spesso implicito e difficile da gestire correttamente.

Padroneggiare il context significa comprendere quando usare WithCancel, quando preferire WithTimeout, come strutturare le chiavi di WithValue per evitare collisioni, e soprattutto quando non usare il context per cose che appartengono alla firma esplicita delle funzioni. Con le aggiunte di Go 1.20 come WithCancelCause, il pacchetto continua a evolversi per rispondere ai pattern reali degli sviluppatori, mantenendo però la sua caratteristica più preziosa: la semplicità dell'interfaccia fondamentale.