context esiste per un motivo principale: la gestione del ciclo di vita delle goroutine. Fornisce un modo standard e componibile per propagare segnali di cancellazione, deadline e metadati request-scoped attraverso i confini delle API. Capire come funziona in produzione fa la differenza tra un servizio che si spegne pulitamente e uno che perde goroutine sotto carico.Ogni servizio Go non banale usa context. Lo si passa dagli HTTP handler alle query sul database, dagli interceptor gRPC alle chiamate API downstream. Ma molti ingegneri lo trattano come poco piu di una convenzione per soddisfare le firme delle funzioni. Questo post spiega come context funziona davvero, dove fallisce silenziosamente quando viene mal utilizzato, e i pattern che contano in produzione.
Cosa fa context (e cosa non fa)#
L’interfaccia context.Context ha quattro metodi:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}Questi corrispondono a tre capacita distinte:
- Cancellazione: un channel (
Done()) che si chiude quando il context viene cancellato. Il lavoro downstream controlla questo e si ferma. - Deadline e timeout: un momento nel tempo dopo il quale
Done()si chiude automaticamente eErr()restituiscecontext.DeadlineExceeded. - Valori request-scoped: uno store immutabile chiave-valore propagato attraverso la catena di chiamate. Questa e la feature piu abusata.
Context non e un meccanismo di comunicazione general-purpose. Per quello si usano i channel. Context e il plumbing che permette a un chiamante di dire “se cancello o vado in timeout, tutto il downstream deve saperlo.”
L’anti-pattern della chiave di context#
L’errore piu comune con context.WithValue e usare un tipo built-in (di solito string) come chiave:
// SBAGLIATO: qualsiasi package puo leggere o sovrascrivere questa chiave
ctx = context.WithValue(ctx, "userID", 42)La documentazione di Go e esplicita: le chiavi devono essere tipi comparabili che non sono esportati da nessun package, per evitare collisioni tra package che usano la stessa stringa. Il pattern corretto usa un tipo non esportato:
package auth
// contextKey e un tipo non esportato per le chiavi context in questo package.
// Usare un tipo con nome previene le collisioni con le chiavi di altri package.
type contextKey int
const (
userIDKey contextKey = iota
tenantIDKey
)
func WithUserID(ctx context.Context, id int64) context.Context {
return context.WithValue(ctx, userIDKey, id)
}
func UserID(ctx context.Context) (int64, bool) {
id, ok := ctx.Value(userIDKey).(int64)
return id, ok
}Poiche contextKey e un tipo non esportato, nessun altro package puo accidentalmente leggere o scrivere i tuoi valori. Le funzioni accessor tipizzate forniscono anche un’API type-safe invece di disseminare chiamate .Value() e type assertion in tutto il codebase.
Cancellazione delle goroutine con select#
Il pattern piu importante per il Go in produzione e controllare ctx.Done() nelle goroutine che eseguono lavoro di lunga durata. Se si lancia una goroutine ignorando il context, si ha un goroutine leak in attesa di manifestarsi.
func processItems(ctx context.Context, items []string) error {
results := make(chan string, len(items))
go func() {
for _, item := range items {
results <- expensiveTransform(item)
}
close(results)
}()
var processed []string
for {
select {
case <-ctx.Done():
// Il chiamante ha cancellato o e andato in timeout. Ferma il lavoro e restituisce l'errore.
return ctx.Err()
case result, ok := <-results:
if !ok {
// Channel chiuso: tutti gli elementi elaborati.
return nil
}
processed = append(processed, result)
}
}
}L’istruzione select mette in gara la cancellazione del context con il channel del lavoro. Quello che si attiva per primo vince. Questo e il pattern che distingue i servizi che gestiscono i picchi di carico in modo elegante da quelli che accumulano goroutine durante i periodi di lentezza.
Cancellazione degli HTTP handler#
Quando un client si disconnette a meta richiesta, il package net/http di Go cancella automaticamente r.Context(). Se l’handler ignora il context, continua a fare lavoro per un client che non sta piu ascoltando.
func searchHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
query := r.URL.Query().Get("q")
// Passa il context della richiesta downstream. Se il client si disconnette,
// ctx.Done() si chiude e la query al database ritorna prima.
results, err := db.SearchProducts(ctx, query)
if err != nil {
if errors.Is(err, context.Canceled) {
// Client disconnesso. Non e necessario scrivere una risposta.
return
}
http.Error(w, "search failed", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(results)
}Propaga sempre il context della richiesta da r.Context() in ogni chiamata downstream. Non creare mai un nuovo context.Background() dentro un handler per lavoro che appartiene a quella richiesta.
HTTP client con timeout#
Questo e probabilmente il caso d’uso piu comune in produzione. Ogni chiamata HTTP in uscita dovrebbe avere un timeout. Senza di esso, un servizio downstream lento puo tenere le goroutine bloccate indefinitamente.
func fetchUser(ctx context.Context, userID string) (*User, error) {
// Imponi una deadline per chiamata indipendentemente dal context padre.
// Il minore tra i due (deadline del padre o 5s) vince.
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() // Chiama sempre cancel per rilasciare le risorse.
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.example.com/users/"+userID, nil)
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching user: %w", err)
}
defer resp.Body.Close()
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
return &user, nil
}Usa http.NewRequestWithContext (non il piu vecchio req.WithContext) per tutto il nuovo codice. Imposta il context al momento della costruzione, che e l’API piu pulita.
Chiama sempre defer cancel() immediatamente dopo context.WithTimeout o context.WithDeadline. Se lo dimentichi, la goroutine del timer interno del context fa un leak fino a quando la deadline non scatta da sola.
Query al database con context#
I moderni driver per database (database/sql, pgx, go-redis) accettano tutti il context. Passalo cosi le query in corso vengono cancellate quando la richiesta va in timeout o il client si disconnette.
func (r *ProductRepository) FindByID(ctx context.Context, id int64) (*Product, error) {
const query = `SELECT id, name, price, stock FROM products WHERE id = $1`
var p Product
err := r.db.QueryRowContext(ctx, query, id).Scan(
&p.ID, &p.Name, &p.Price, &p.Stock,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("querying product %d: %w", id, err)
}
return &p, nil
}Quando ctx viene cancellato, QueryRowContext restituisce un errore che wrappa context.Canceled. Questo si propaga risalendo lo stack delle chiamate, e il tuo handler puo decidere se loggarlo o ignorarlo silenziosamente.
WithValue: quando usarlo e quando no#
context.WithValue e per metadati request-scoped che attraversano i confini delle API dove aggiungere un parametro alla funzione non e pratico. Esempi che hanno senso:
- Trace ID e request ID per il logging strutturato
- Identita dell’utente autenticato impostata dal middleware
- Tenant ID in servizi multi-tenant
Esempi che non appartengono al context:
- Valori di configurazione (usa una struct di config)
- Istanze di logger (iniettale tramite campi struct)
- Connessioni al database (iniettale tramite dependency injection)
- Parametri di funzione (aggiungi semplicemente il parametro)
Il test e semplice: se rimuovere il valore dal context richiederebbe di aggiungere un parametro a ogni funzione nella catena, potrebbe appartenerci. Se richiederebbe di aggiungerlo a una sola funzione, non appartiene al context.
I valori del context non sono type-safe per impostazione predefinita e non hanno visibilita nelle firme delle funzioni. Un abuso porta a codice difficile da ragionare. Tratta WithValue come ultima risorsa per le cross-cutting concern, non come un modo per evitare di passare parametri.
Architettura: propagazione del context in una richiesta#
Ecco come il context scorre attraverso un tipico servizio a tre livelli:
flowchart TD
A[HTTP Request] -->|r.Context| B[Handler]
B -->|WithTimeout 5s| C[Service Layer]
C -->|propagate ctx| D[Repository]
C -->|propagate ctx| E[Downstream HTTP Call]
D -->|QueryRowContext| F[(Database)]
E -->|NewRequestWithContext| G[External API]
B -->|ctx.Done closes on disconnect| H[Cancelled]
H -->|error bubbles up| C
H -->|query returns| D
L’handler possiede il context radice (da r.Context()). Ogni livello lo passa invariato, oppure lo wrappa con una deadline piu stretta. Quando scatta la cancellazione, il segnale si propaga a ogni chiamata in corso simultaneamente.
Errori comuni#
Usare chiavi string per i valori del context
"userID" si sovrascriveranno a vicenda. Usa sempre un tipo con nome non esportato definito nel tuo package come tipo di chiave.Passare nil come context
nil come argomento context. Questo causa un panic alla prima chiamata al metodo del context. Usa context.Background() come context radice in main, nei test e in qualsiasi posto dove non c’e un context padre da cui derivare. Usa context.TODO() come segnaposto quando sai che il context deve essere plumbato ma non l’hai ancora fatto.Ignorare ctx.Done() nelle goroutine
ctx.Done() significa che la goroutine viene eseguita fino al completamento anche quando il chiamante ha cancellato. In un servizio ad alto traffico, questo si accumula come goroutine leak che si manifestano come crescita della memoria e latenza crescente nel tempo.Creare context.Background() dentro gli handler
r.Context().Memorizzare il context in una struct
context.Context come campo di una struct. I context sono pensati per scorrere attraverso le catene di chiamate, non essere mantenuti come stato. Se ti ritrovi a memorizzarne uno, di solito e il segnale di un problema di design.Se vuoi approfondire questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architetture cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.