Menu

Context in Golang: cancellazione, timeout e valori

Come context.Context porta cancellazione, scadenze e valori legati alla richiesta attraverso un programma Go: Background, WithCancel, WithTimeout, WithValue, ctx.Done in select e il context nei server e client HTTP.

Questa pagina include editor eseguibili: modifica, esegui e vedi subito l'output.

Un timeout in dieci righe

Il compito di context.Context è dire al codice quando fermarsi. Qui un'operazione lenta ha 50 ms a disposizione e rinuncia quando il context lo indica:

La prima chiamata termina in 10 ms e restituisce rows <nil>. La seconda richiederebbe 200 ms, ma il context scade a 50 ms (contati da quando è stato creato), quindi restituisce context deadline exceeded.

Niente viene fermato con la forza. Go non ha modo di terminare una goroutine dall'esterno. Un context è un segnale e il codice deve controllarlo: facendo select su ctx.Done(), controllando ctx.Err() tra un passaggio e l'altro, oppure passando ctx a chiamate di libreria (http.NewRequestWithContext, db.QueryContext, exec.CommandContext) che lo controllano al posto tuo.

L'interfaccia Context

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}
MetodoRestituisce
Done()un channel che viene chiuso quando il context viene annullato o scade (nil per un context che non può mai essere annullato)
Err()nil finché è attivo, poi context.Canceled o context.DeadlineExceeded
Deadline()la scadenza e true, oppure ok == false se non ce n'è una
Value(key)il valore memorizzato sotto key in questo context o in un antenato, oppure nil

I context sono immutabili. Non ne modifichi mai uno; ne derivi un figlio con una delle funzioni With, e il figlio aggiunge un segnale di cancellazione, una scadenza o un valore.

Da dove arriva un context

Ogni albero di context parte da una radice:

  • context.Background() per main, init, i test e la configurazione di primo livello dei server.
  • context.TODO() quando una funzione dovrebbe ricevere un context ma il chiamante non ne ha ancora uno. Si comporta esattamente come Background; il nome è un segnaposto per un refactoring futuro.

Dentro un handler HTTP non crei una radice. Usi r.Context(), che il server annulla quando il client si disconnette o quando l'handler ritorna.

WithCancel: fermarsi su richiesta

context.WithCancel restituisce un context figlio e una funzione cancel. Chiamare cancel chiude il channel Done del figlio e i channel Done di tutto ciò che ne deriva.

L'invio del producer sta in un select accanto a ctx.Done(). È questo che gli permette di fermarsi: un semplice out <- i si bloccherebbe per sempre una volta che il consumatore smette di leggere, e la goroutine resterebbe appesa. Il for range nums finale aspetta che il producer abbia chiuso il channel. Mentre il channel si svuota, il producer può ancora riuscire a inviare un valore o due, perché quando entrambi i casi del suo select sono pronti Go ne sceglie uno a caso; la cancellazione è rapida, non istantanea.

cancel si può chiamare più volte e da qualsiasi goroutine senza problemi. Solo la prima chiamata ha effetto.

WithTimeout e WithDeadline

WithTimeout(parent, d) equivale a WithDeadline(parent, time.Now().Add(d)). Usa un timeout per dire "al massimo questo tempo" e una scadenza quando hai un orario assoluto.

Passato il tempo, Done si chiude ed Err restituisce context.DeadlineExceeded. Se viene chiamato prima cancel, Err restituisce context.Canceled. Verifica quale dei due con errors.Is, perché le librerie di solito avvolgono l'errore:

Chiama sempre cancel, anche per un timeout che scatterà da solo. Il context mantiene un timer e un posto nel genitore finché non succede una delle due cose, e defer cancel() li libera entrambi appena la funzione ritorna. go vet segnala una funzione di cancel scartata: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak.

I figli non possono sopravvivere ai genitori

I context formano un albero. Annullare un genitore annulla tutti i discendenti. Un figlio può avere una scadenza più breve di quella del genitore, mai più lunga: vince sempre la scadenza più vicina.

È questo che rende utili i context attraverso i vari livelli. Un handler HTTP riceve un context che muore con la richiesta; una chiamata al database tre livelli più in basso ne deriva un timeout di 2 secondi. Se il client riaggancia dopo 100 ms, la query viene annullata in quel momento, non due secondi dopo.

Fai sempre select su ctx.Done() quando ti blocchi

Qualsiasi goroutine che aspetta (un invio su un channel, una ricezione, un timer) dovrebbe aspettare contemporaneamente anche ctx.Done(). Per i cicli che usano solo la CPU e non si bloccano mai, controlla ctx.Err() ogni tanto:

for i, item := range items {
	if i%1000 == 0 {
		if err := ctx.Err(); err != nil {
			return err
		}
	}
	process(item)
}

Usa time.After dentro un select per un'attesa semplice, ma preferisci un timer che puoi fermare (o un timeout del context) quando l'attesa può essere annullata spesso.

Cause di cancellazione (Go 1.20 e 1.21)

ctx.Err() dice solo canceled o deadline exceeded. Per registrare il motivo, usa le varianti Cause:

WithCancelCause è arrivato in Go 1.20, WithTimeoutCause e WithDeadlineCause in Go 1.21. Err continua a restituire i valori standard, così i controlli esistenti continuano a funzionare; context.Cause fornisce il dettaglio.

WithValue, con parsimonia

context.WithValue(parent, key, value) associa un valore. ctx.Value(key) lo cerca risalendo la catena dei genitori.

Regole per i valori:

  • Usa un tipo non esportato per le chiavi, mai una semplice string. Due package che usano entrambi "user" si sovrascriverebbero a vicenda. (go vet non lo rileva; staticcheck sì.)
  • Incapsula l'accesso in funzioni di supporto tipizzate come WithRequestID e RequestID, così chi le chiama non vede mai any né la chiave.
  • Memorizza solo dati legati alla richiesta che attraversano le API: ID di tracciamento e di richiesta, l'utente autenticato, un logger. Mai parametri opzionali, handle di database o configurazione. Quelli vanno negli argomenti delle funzioni o nei campi delle struct, dove il compilatore può controllarli e chi legge può vederli.
  • La ricerca risale la catena un genitore alla volta, quindi ogni valore che aggiungi allunga di un passo la ricerca degli altri.

Convenzioni

  • ctx context.Context è il primo parametro di qualsiasi funzione che fa I/O, si blocca o chiama qualcosa che lo fa: func Fetch(ctx context.Context, url string) error.
  • Non memorizzare un context in una struct. Passalo a ogni chiamata di metodo. Un context appartiene a una singola operazione, e di solito una struct vive più a lungo. (L'eccezione è un tipo che rappresenta una singola operazione, come http.Request.)
  • Non passare mai nil come context. Usa context.TODO() se non hai niente di meglio.
  • Restituisci ctx.Err(), o avvolgilo con %w, quando ti fermi a causa del context, così i chiamanti possono distinguere un timeout da un vero errore.

Context nei server e nei client HTTP

Lato server: r.Context() viene annullato quando il client si disconnette, quando l'handler ritorna o quando uno stream HTTP/2 viene resettato. Lato client: http.NewRequestWithContext fa rispettare alla richiesta un timeout o una cancellazione. Questo programma fa girare entrambi i lati tramite httptest:

Il client rinuncia a 50 ms e chiude la connessione. Il server se ne accorge, il suo context di richiesta viene annullato e l'handler si ferma invece di spendere altri 450 millisecondi su un report che nessuno leggerà. In un handler reale passi r.Context() a ogni chiamata al database e HTTP, e si fermano tutte insieme.

Altre funzioni di supporto (Go 1.21)

  • context.WithoutCancel(ctx) restituisce un context con gli stessi valori che non viene annullato quando lo è ctx. Usalo per lavori che devono finire dopo la fine della richiesta, come scrivere un log di audit.
  • context.AfterFunc(ctx, f) esegue f in una goroutine propria quando ctx è terminato, e restituisce una funzione stop per annullare la registrazione.

Errori comuni

  • Non chiamare cancel. Scrivi sempre defer cancel() subito dopo WithCancel, WithTimeout o WithDeadline.
  • Avviare una goroutine che ignora ctx. Se si blocca senza fare select su ctx.Done(), annullare non ha effetto e la goroutine resta appesa.
  • Creare un nuovo context.Background() in profondità in una catena di chiamate. Taglia il collegamento con la scadenza e la cancellazione del chiamante. Passa il ctx che hai ricevuto.
  • Confrontare gli errori con ==. Usa errors.Is(err, context.DeadlineExceeded); la maggior parte delle librerie lo avvolge.
  • Usare WithValue per le dipendenze. Un handle di database nascosto in un context è un parametro che il compilatore non può più controllare.
  • Aspettarsi che la cancellazione sia istantanea. Il codice se ne accorge solo al controllo successivo. Un ciclo lungo senza controlli continua a girare.

Domande frequenti

A cosa serve context in Go?

Un context.Context dice a una funzione, e a tutto ciò che essa chiama, quando rinunciare: perché il chiamante ha annullato, perché è scaduto un termine o perché il client si è disconnesso. Può anche trasportare valori legati alla richiesta, come un ID di richiesta. Per convenzione è il primo parametro e si chiama ctx.

Qual è la differenza tra context.Background e context.TODO?

Entrambi restituiscono un context vuoto che non viene mai annullato e non ha scadenze né valori. Si comportano in modo identico. Background() è la radice per main, i test e la configurazione di primo livello. TODO() segna un punto in cui andrebbe passato un context vero ma il codice circostante non ne ha ancora uno, il che lo rende facile da ritrovare in seguito.

Perché devo chiamare cancel dopo context.WithTimeout?

WithTimeout, WithDeadline e WithCancel registrano il nuovo context presso il genitore e possono avviare un timer. Chiamare cancel libera quelle risorse appena hai finito, invece che quando scatta il timeout o viene annullato il genitore. Scrivi defer cancel() subito dopo averlo creato; go vet avvisa quando una funzione di cancel viene scartata.

Cosa significa "context deadline exceeded" in Go?

È il testo di context.DeadlineExceeded, l'errore che ctx.Err() restituisce quando la scadenza di un context è passata. Le funzioni che rispettano il context, come i client HTTP e i driver di database, lo restituiscono (spesso avvolto) quando finiscono il tempo. Controllalo con errors.Is(err, context.DeadlineExceeded).

Devo usare context.WithValue per passare i parametri?

No. Usalo solo per dati legati alla richiesta che attraversano i confini delle API e che le funzioni intermedie non hanno bisogno di conoscere, come un ID di tracciamento o l'utente autenticato. Tutto ciò che serve a una funzione per fare il suo lavoro va nei suoi parametri, dove il compilatore lo controlla.

Illustrazione dei linguaggi di programmazione di Coddy

Impara a programmare con Coddy

INIZIA