Go context.Context fatto bene: Cancellazione, Timeouts e Valori
Il context in Go rappresenta il controllo del flusso, non l'archiviazione.
Il context.Context di Go è abbastanza semplice da essere usato in modo improprio — ed è proprio questo il problema.
La maggior parte degli sviluppatori Go impara rapidamente le regole di superficie: passare il context come primo argomento, verificare ctx.Done(), usare context.WithTimeout e non passare mai nil.
func DoSomething(ctx context.Context) error {
// ...
}
Queste regole sono utili, ma coprono solo la parte facile. Nei servizi di produzione, il context non è solo una convenzione di parametro — è il piano di controllo per il ciclo di vita della richiesta.

Il context dice al lavoro quando fermarsi, quanto tempo gli resta, quale percorso di cancellazione è stato intrapreso e quali valori con ambito di richiesta devono attraversare i confini delle API. Usato bene, previene le perdite di goroutine, evita lavoro sprecato, propaga le scadenze e rende i servizi più facili da terminare. Usato male, diventa un contenitore di dipendenze nascoste, variabili globali fittizie, timeout dimenticati, timer in fuga e un comportamento di cancellazione confuso.
La versione leggermente opinabile è questa: usa il context per la cancellazione, le scadenze e i metadati con ambito di richiesta, e non usarlo come contenitore di dipendenze.
A cosa serve il context
Il pacchetto context ha tre compiti principali — cancellazione, scadenze e timeout, e valori con ambito di richiesta — e questi tre compiti coprono tutto ciò per cui è progettato.
Un context dovrebbe rispondere a domande come:
Questa richiesta è stata annullata?
Quanto tempo ha questa operazione rimasta?
Quale ID di richiesta dovrebbe essere allegato ai log?
Quale utente autenticato è associato a questa richiesta?
Un context non dovrebbe rispondere a domande come:
Dov'è la mia connessione al database?
Dov'è il mio logger?
Dov'è la mia configurazione?
Quale implementazione del servizio dovrei usare?
Queste sono dipendenze — passale esplicitamente tramite i parametri della funzione (vedi Iniezione di Dipendenze in Go per modelli su come farlo in modo pulito). Il context è per il ciclo di vita e i metadati della richiesta, non per l’assemblaggio dell’applicazione.
La forma base del context
L’interfaccia fondamentale è piccola:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Le parti importanti sono:
Done()viene chiuso quando il context viene annullato o la sua scadenza scade.Err()spiega perché il context è terminato.Deadline()ti dice se il context ha una scadenza.Value()memorizza dati con ambito di richiesta.
La maggior parte del codice non implementa questa interfaccia. Riceve un context e lo passa in profondità.
La prima regola: passare il context esplicitamente
Per le funzioni che eseguono operazioni con ambito di richiesta o annullabili, passa il context come primo parametro — questa è la convenzione standard di Go e ciò che ogni libreria e strumento nell’ecosistema si aspetta:
func GetUser(ctx context.Context, id string) (*User, error) {
// ...
}
Fallo per le funzioni che potrebbero:
- Chiamare un database
- Chiamare un altro servizio
- Aspettare su una coda
- Avviare lavoro in background
- Bloccarsi su I/O
- Usare un timeout
- Avere bisogno di valori con ambito di richiesta
- Avere bisogno di cancellazione
Non aggiungere il context a piccole funzioni pure che non ne hanno bisogno.
Questo è accettabile:
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
Non ogni funzione ha bisogno di un context. Aggiungere il context ovunque rende il codice rumoroso.
Non memorizzare il context in strutture (struct)
Memorizzare un context in una struttura è uno degli errori più comuni nei codebase Go, e vale la pena segnalarlo esplicitamente. Non fare questo:
type UserService struct {
ctx context.Context
db *sql.DB
}
Fai questo invece:
type UserService struct {
db *sql.DB
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
// ...
}
Un context appartiene a una richiesta, un’operazione o un compito, mentre una struttura di servizio di solito vive molto più a lungo di una singola richiesta. Mescolare questi cicli di vita rende la cancellazione poco chiara e rende difficile capire a quale operazione appartenga un context.
Ci sono rare eccezioni per tipi che rappresentano genuinamente un singolo ciclo di vita operativo, ma sono così rare che la regola predefinita dovrebbe essere semplice:
Passa il context. Non memorizzarlo.
Non passare nil context
Non passare mai nil come context.
Sbagliato:
err := svc.DoWork(nil)
Usa context.Background() quando non esiste un context esistente:
err := svc.DoWork(context.Background())
Nei test, usa il context del test quando possibile:
func TestDoWork(t *testing.T) {
err := svc.DoWork(t.Context())
if err != nil {
t.Fatal(err)
}
}
Un context nil può causare un panic quando il codice chiama metodi su di esso. Un context background è esplicito e sicuro.
Context di Background, TODO e di richiesta
Ci sono tre punti di partenza comuni.
context.Background
Usa context.Background() a livello superiore di un programma quando non esiste un context genitore — è il context radice da cui vengono derivati tutti i context figli:
func main() {
ctx := context.Background()
_ = run(ctx)
}
oppure:
func TestSomething(t *testing.T) {
ctx := context.Background()
_ = ctx
}
context.TODO
Usa context.TODO() quando sai che dovrebbe essere usato un context ma non hai ancora deciso quale.
ctx := context.TODO()
Questo è utile durante le migrazioni, ma non dovrebbe diventare permanente se esiste un context reale.
Context di richiesta
Nei server HTTP, usa il context della richiesta:
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_ = ctx
}
Il context della richiesta viene annullato quando la connessione del client si chiude, la richiesta viene annullata o il server completa l’elaborazione della richiesta.
Per i servizi web, questo è di solito il context che dovresti passare in profondità al codice dell’applicazione.
Cancellazione con context.WithCancel
Usa context.WithCancel quando vuoi fermare esplicitamente un lavoro.
ctx, cancel := context.WithCancel(parent)
defer cancel()
La funzione cancel restituita annulla il context figlio e rilascia le risorse associate ad esso. Chiamala sempre quando hai finito — anche se il context andrà in timeout alla fine, chiamare cancel in anticipo evita di mantenere le risorse vive più a lungo del necessario.
Esempio:
func RunWorker(parent context.Context) error {
ctx, cancel := context.WithCancel(parent)
defer cancel()
done := make(chan error, 1)
go func() {
done <- doBackgroundWork(ctx)
}()
select {
case <-ctx.Done():
return ctx.Err()
case err := <-done:
return err
}
}
Il modello è semplice:
- Deriva un context figlio.
- Usa defer per cancel.
- Passa il context figlio al lavoro che dovrebbe fermarsi insieme.
- Osserva
ctx.Done().
Timeout con context.WithTimeout
Usa context.WithTimeout quando un’operazione ha una durata massima.
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
Esempio con un client HTTP:
func FetchUser(ctx context.Context, client *http.Client, url string) (*http.Response, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
Questo rende il timeout parte dell’operazione, non un’impostazione globale nascosta.
Chiamare sempre cancel
Quando chiami WithCancel, WithTimeout o WithDeadline, chiama sempre la funzione cancel restituita — questo è importante per la correttezza.
Buono:
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
Sbagliato:
ctx, _ := context.WithTimeout(parent, 5*time.Second)
Non chiamare cancel può far sì che i timer e i context figli restino attivi più a lungo del necessario.
I canali dei timer fanno anche parte del contratto: da Go 1.27 i canali dietro time.After e time.NewTimer sono sempre senza buffer (unbuffered), e il vecchio opt-out asynctimerchan non compila più con il suo valore pre-1.23. La Guida all’aggiornamento a Go 1.27 elenca quell’impostazione insieme agli altri valori rimossi di GODEBUG che fanno fallire la build.
Scadenze (Deadlines) contro Timeout
Un timeout è relativo:
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
Una scadenza è assoluta:
deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()
La maggior parte del codice delle applicazioni usa i timeout. Le scadenze sono utili quando una richiesta ha un orario di fine fisso che dovrebbe essere condiviso tra più operazioni — per esempio, se una richiesta ha 900 millisecondi rimanenti, non dare a ogni chiamata downstream un nuovo timeout di 1 secondo; propaga invece il budget rimanente.
Budget di timeout tra i livelli del servizio
Un errore comune è impilare timeout acriticamente.
func Handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
_ = service.DoWork(ctx)
}
func (s *Service) DoWork(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return s.repo.Query(ctx)
}
Questo sembra inoffensivo, ma nasconde il budget reale. Il livello di servizio dovrebbe di solito rispettare la scadenza del chiamante invece di reimpostare il timer allo stesso valore.
Un modello migliore è:
func Handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := service.DoWork(ctx); err != nil {
// handle error
return
}
}
Poi all’interno del servizio:
func (s *Service) DoWork(ctx context.Context) error {
return s.repo.Query(ctx)
}
Aggiungi un timeout figlio solo quando una sotto-operazione ha bisogno di un budget più piccolo:
func (s *Service) DoWork(ctx context.Context) error {
queryCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
return s.repo.Query(queryCtx)
}
Il modello mentale corretto è immediato: l’intera richiesta ha un unico budget esterno, le sotto-operazioni specifiche possono avere budget più piccoli ricavati da quel budget, e nessun livello estende silenziosamente la richiesta oltre ciò che il chiamante ha inteso.
Verifica ctx.Err() per distinguere la cancellazione dal timeout
Quando un context termina, ctx.Err() restituisce il motivo.
Di solito è uno di questi:
context.Canceled
context.DeadlineExceeded
Esempio:
select {
case <-ctx.Done():
return ctx.Err()
case result := <-resultCh:
return handle(result)
}
Questo consente ai chiamanti di distinguere la cancellazione dal timeout, e quella distinzione conta in pratica. Una richiesta annullata spesso significa che il client si è disconnesso, mentre un errore di scadenza superata di solito significa che il tuo servizio era troppo lento — non dovrebbero essere sempre loggati, ritentati o segnalati allo stesso modo.
Usa context.Cause per migliori motivi di cancellazione
Il Go moderno supporta anche la cancellazione consapevole del motivo (cause-aware cancellation).
Le funzioni utili includono:
context.WithCancelCausecontext.WithTimeoutCausecontext.WithDeadlineCausecontext.Cause
Il semplice ctx.Err() ti dice il motivo generale: annullato o scadenza superata.
context.Cause(ctx) può dirti la causa più specifica.
Esempio:
var ErrShutdown = errors.New("server shutting down")
func Run(ctx context.Context) error {
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
go func() {
// Some shutdown signal arrived.
cancel(ErrShutdown)
}()
<-ctx.Done()
return context.Cause(ctx)
}
Usa la cancellazione consapevole del motivo quando il motivo è importante per i chiamanti, i log o il comportamento di pulizia, e evitalo dove un semplice ctx.Err() è sufficiente — il dettaglio extra vale solo quando la diagnosi lo richiede genuinamente.
Esempio di server HTTP
Un normale handler HTTP dovrebbe partire da r.Context(). Per una spiegazione completa su come strutturare i servizi HTTP in Go, vedi Creare API REST in Go. Gin, Echo e Fiber avvolgono ciascuno context.Context all’interno del proprio tipo di context della richiesta, e le differenze tra gli stack sono confrontate in Go Web Frameworks nel 2026: net/http, chi, Gin, Echo, Fiber Confrontati.
func GetUserHandler(svc *UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := r.PathValue("id")
user, err := svc.GetUser(ctx, id)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, user)
}
}
Il servizio dovrebbe accettare e propagare il context:
type UserService struct {
repo *UserRepository
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(ctx, id)
}
Il repository dovrebbe usare metodi database consapevoli del context:
type UserRepository struct {
db *sql.DB
}
func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
const query = `
select id, email, name
from users
where id = $1
`
var user User
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Email,
&user.Name,
)
if err != nil {
return nil, err
}
return &user, nil
}
La cosa importante è la catena — ogni livello passa lo stesso context al successivo:
Non rompere la catena creando context.Background() a metà.
L’errore di context.Background(): rompere la catena di cancellazione
Questo è un bug comune:
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(context.Background(), id)
}
Questo scarta tutte le informazioni di cancellazione e scadenza dal chiamante. Se il client si disconnette, la query al database continua a girare. Se la richiesta va in timeout, il lavoro downstream potrebbe essere ancora in corso. Se il server si sta spegnendo, questo codice lo ignora completamente. Sostituire il context ricevuto con context.Background() all’interno della logica di business è quasi sempre sbagliato.
Usa il context che ti è stato dato:
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(ctx, id)
}
Usa context.Background() solo al bordo dove non esiste un context genitore.
Esempio di client HTTP
Per le richieste HTTP in uscita, allega il context alla richiesta.
func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
Non fare questo:
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
Questo crea una richiesta senza il context dell’operazione.
Evita anche di fare affidamento solo su http.Client.Timeout. Può essere utile come limite di sicurezza, ma i context della richiesta ti danno una migliore propagazione lungo la catena di chiamate.
Un modello comune è:
func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
Usa questo quando la chiamata all’API downstream ha un budget specifico all’interno di una richiesta più grande.
Esempio database
La maggior parte delle API database in Go ha metodi consapevoli del context. Per una visione più ampia di come le librerie di accesso ai dati in Go gestiscano il context — incluso GORM, Ent, Bun e sqlc — vedi Confronto tra ORM Go per PostgreSQL.
Usali.
Buono:
rows, err := db.QueryContext(ctx, query, args...)
Buono:
err := db.QueryRowContext(ctx, query, id).Scan(&name)
Buono:
result, err := db.ExecContext(ctx, query, args...)
Sbagliato:
rows, err := db.Query(query, args...)
Le forme consapevoli del context consentono alle operazioni database di fermarsi quando la richiesta viene annullata o va in timeout, il che è particolarmente importante per query lente, database sovraccarichi e API rivolte all’utente dove la latenza influisce direttamente sull’esperienza dell’utente.
Transazioni e context
Le transazioni richiedono un’attenta gestione del context.
Una transazione dovrebbe di solito iniziare con il context dell’operazione:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
Poi usa lo stesso context per le operazioni della transazione:
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
Fai attenzione ai timeout attorno alle transazioni. Se il context viene annullato prima di Commit, la transazione potrebbe essere annullata (rollback). Potrebbe essere ciò che vuoi, ma dovrebbe essere intenzionale.
Per transazioni lunghe, la risposta migliore di solito non è un timeout più lungo — è una transazione più corta che fa meno lavoro per unità di tempo.
Worker in background e context
I worker in background dovrebbero ricevere un context che rappresenti il loro ciclo di vita.
Esempio:
type Worker struct {
logger *slog.Logger
}
func (w *Worker) Run(ctx context.Context) error {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := w.doOnce(ctx); err != nil {
w.logger.Error("worker iteration failed", "err", err)
}
}
}
}
Questo worker si ferma pulitamente quando il context viene annullato, e il suo ticker viene correttamente pulito tramite defer ticker.Stop(). In main, creerebbe un context radice legato ai segnali del SO:
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
worker := &Worker{logger: slog.Default()}
if err := worker.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
slog.Error("worker stopped", "err", err)
}
}
Questo è il context usato correttamente: descrive il ciclo di vita del lavoro del processo, e quando il SO invia un segnale, l’intero albero di goroutine che condivide questo context si fermerà insieme.
Prevenire le perdite di goroutine con la cancellazione del context
Una perdita di goroutine (goroutine leak) si verifica quando una goroutine rimane bloccata per sempre dopo che non è più utile.
Il context aiuta a prevenirlo.
Sbagliato:
func StartWorker() {
go func() {
for {
doWork()
time.Sleep(time.Second)
}
}()
}
Questa goroutine non ha un percorso di spegnimento.
Migliore:
func StartWorker(ctx context.Context) {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
doWork()
}
}
}()
}
Qualsiasi goroutine che esegue un loop dovrebbe quasi sempre avere un percorso di cancellazione.
Questo non significa che ogni goroutine debba ricevere il context direttamente, ma il sistema dovrebbe avere un modo chiaro per fermarla.
context.AfterFunc
context.AfterFunc esegue una funzione dopo che un context è stato annullato.
Può essere utile per la pulizia, lo sblocco di operazioni o il collegamento di API che non supportano nativamente il context.
Esempio:
func waitWithContext(ctx context.Context, ch <-chan struct{}) error {
stop := context.AfterFunc(ctx, func() {
// Wake up or clean up if needed.
})
defer stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-ch:
return nil
}
}
Usa AfterFunc con cautela — avvia la logica quando la cancellazione avviene, il che può rendere il flusso di controllo più difficile da seguire. Per la maggior parte del codice delle applicazioni, un normale select su ctx.Done() è più chiaro e più facile da comprendere. AfterFunc è più prezioso quando hai bisogno di adattare la cancellazione del context a un’API che non accetta già il context.
context.WithoutCancel
context.WithoutCancel crea un context che non viene annullato quando il genitore viene annullato.
Questo è utile, ma è anche facile da usare in modo improprio.
Esempio di caso d’uso:
func Handler(audit *AuditLog) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Handle request...
_ = ctx
auditCtx := context.WithoutCancel(ctx)
go func() {
ctx, cancel := context.WithTimeout(auditCtx, 2*time.Second)
defer cancel()
_ = audit.Write(ctx, "request completed")
}()
}
}
L’idea è che la scrittura di audit potrebbe dover continuare brevemente anche dopo che il context della richiesta è stato annullato. Questo dovrebbe essere raro e deliberato — non usare WithoutCancel come modo per evitare di affrontare la cancellazione. Usalo solo quando il lavoro figlio deve genuinamente sopravvivere alla cancellazione del genitore, e aggiungi sempre un nuovo timeout: un context che ignora la cancellazione ma non porta una scadenza può facilmente creare perdite di goroutine in background.
Valori del context fatti bene
I valori del context sono per dati con ambito di richiesta che attraversano i confini delle API.
Buoni esempi:
- ID della richiesta
- ID di traccia (trace ID)
- ID utente autenticato
- ID tenant
- locale
- principale di sicurezza
- metadati di correlazione
Cattivi esempi:
- connessione al database
- logger come dipendenza nascosta
- feature flags per il normale flusso di controllo
- parametri facoltativi della funzione
- configurazione
- client di servizio
Una regola utile: se il valore fa parte dell’identità della richiesta o del context di osservabilità, potrebbe appartenere al context. Se è una dipendenza di cui il tuo codice ha bisogno per fare il suo lavoro, passala esplicitamente.
Usa chiavi tipizzate per i valori del context
Non usare stringhe semplici come chiavi del context.
Sbagliato:
ctx = context.WithValue(ctx, "userID", "123")
Questo può entrare in conflitto con altri pacchetti.
Usa un tipo di chiave personalizzato non esportato:
type userIDKey struct{}
func WithUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, userIDKey{}, userID)
}
func UserIDFromContext(ctx context.Context) (string, bool) {
userID, ok := ctx.Value(userIDKey{}).(string)
return userID, ok
}
Questo modello ti dà la sicurezza del tipo al confine del pacchetto, evita collisioni di chiavi con altri pacchetti e mantiene la superficie dell’API del context pulita con funzioni di accesso tipizzate.
Non usare valori del context per parametri facoltativi
Questo è sbagliato:
ctx = context.WithValue(ctx, "pageSize", 100)
users, err := repo.ListUsers(ctx)
Questo nasconde il contratto della funzione.
Preferisci parametri espliciti:
users, err := repo.ListUsers(ctx, ListUsersOptions{
PageSize: 100,
})
I valori del context non dovrebbero sostituire gli argomenti delle funzioni. Gli input nascosti rendono il codice più difficile da capire, testare e revisionare — e chiunque legga la firma della funzione non avrà idea che il parametro esista.
Logging e context
Ci sono due approcci comuni al logging con il context. Gli esempi qui usano il pacchetto log/slog di Go — per un approfondimento sul logging strutturato con slog nei servizi di produzione, vedi Logging Strutturato in Go con slog.
Approccio 1: Estrarre i valori e allegarli ai log
func LogRequest(ctx context.Context, logger *slog.Logger, msg string) {
if requestID, ok := RequestIDFromContext(ctx); ok {
logger = logger.With("request_id", requestID)
}
logger.Info(msg)
}
Questo mantiene il logger esplicito come dipendenza appropriata e usa il context solo per valori con ambito di richiesta che hanno genuinamente bisogno di attraversare i confini delle API.
Approccio 2: Memorizzare il logger nel context
Alcuni codebase memorizzano un logger nel context.
Questo può essere comodo, ma non lo consiglio come predefinito. Trasforma il context in un contenitore di dipendenze.
La mia preferenza:
- Passa le dipendenze del logger esplicitamente.
- Memorizza gli ID di traccia e gli ID di richiesta nel context.
- Aggiungi quei valori ai log ai confini o nella middleware.
Questo mantiene le dipendenze visibili.
Context e tracing
Il tracing è uno dei casi d’uso più forti per i valori del context, ed è un abbinamento genuinamente buono. OpenTelemetry e sistemi simili usano il context per propagare gli span di traccia attraverso le chiamate alle funzioni e i confini dei processi, perché i dati di traccia sono esattamente il tipo di metadati con ambito di richiesta per cui il context è stato progettato.
Un tipico modello sembra questo:
func (s *Service) DoWork(ctx context.Context) error {
ctx, span := s.tracer.Start(ctx, "Service.DoWork")
defer span.End()
return s.repo.Query(ctx)
}
Il context trasporta lo span di traccia attivo, e il repository può creare uno span figlio da esso. Ogni livello aggiunge il proprio span senza alcun passaggio esplicito di oggetti tracer — il context fa quello lavoro in modo trasparente per l’intero albero di chiamate.
Gestione degli errori con il context
Quando un’operazione si ferma a causa della cancellazione del context, preserva quelle informazioni. I modelli qui completano le strategie di progettazione degli errori più ampie trattate in Architettura della Gestione degli Errori in Go.
Esempio:
err := svc.DoWork(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
// Client canceled or caller stopped the work.
return err
}
if errors.Is(err, context.DeadlineExceeded) {
// Timeout.
return err
}
return err
}
Non avvolgere acriticamente gli errori del context in modo da nasconderli.
Avvolgere con %w preserva errors.Is, quindi i chiamanti possono ancora rilevare la cancellazione o il timeout:
if err != nil {
return fmt.Errorf("query user: %w", err)
}
Sostituire l’errore completamente scarta quelle informazioni e rompe qualsiasi chiamante che controlli per tipi specifici di errore del context:
if err != nil {
return errors.New("query user failed")
}
Mappare gli errori del context alle risposte HTTP
Gli errori del context spesso si mappano su diversi esiti HTTP.
Esempio:
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, context.Canceled):
// The client likely went away.
// Some systems log this as a client closed request.
return
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
default:
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
Non trattare la cancellazione del client come un fallimento dell’applicazione — se l’utente ha chiuso la scheda del browser, non significa che il tuo servizio si sta comportando male, e loggarlo come errore aggiunge rumore senza segnale.
Context nelle middleware
Le middleware HTTP sono un luogo comune per aggiungere valori con ambito di richiesta.
Esempio di middleware per l’ID di richiesta:
type requestIDKey struct{}
func WithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, requestIDKey{}, requestID)
}
func RequestIDFromContext(ctx context.Context) (string, bool) {
requestID, ok := ctx.Value(requestIDKey{}).(string)
return requestID, ok
}
func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = newRequestID()
}
ctx := WithRequestID(r.Context(), requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Questo è un buon uso del context. L’ID della richiesta appartiene alla richiesta, dovrebbe attraversare l’intera catena di chiamate, e allegarlo a log e trace a ogni livello è esattamente il tipo di preoccupazione di osservabilità trasversale che i valori del context sono progettati per supportare.
Context nei test
Nei test, evita di usare context.Background() acriticamente.
Preferisci t.Context() quando il lavoro appartiene al ciclo di vita del test:
func TestService(t *testing.T) {
ctx := t.Context()
err := service.DoWork(ctx)
if err != nil {
t.Fatal(err)
}
}
Per il comportamento dei timeout, testa con un timeout reale solo se il timeout è piccolo e significativo.
Per il codice concorrente e dipendente dal tempo, considera l’uso di testing/synctest — Testare il Codice Go Concorrente con synctest copre questo strumento in dettaglio:
func TestTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
time.Sleep(30 * time.Second)
if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
t.Fatalf("got %v, want deadline exceeded", ctx.Err())
}
})
}
Questo ti permette di testare valori di timeout reali senza aspettare il tempo reale.
Context e errgroup
Per gruppi di goroutine che dovrebbero essere annullate insieme, errgroup è spesso un buon abbinamento.
errgroup coordina la cancellazione, ma non limita nulla di per sé: senza un limite, un loop di mille elementi avvia mille goroutine. Per il lato della capacità — SetLimit, canali semaforo, pool di worker e la forma della perdita che sopravvive a Wait — vedi Limitare la Concorrenza in Go.
Esempio:
func FetchAll(ctx context.Context, ids []string, client *Client) error {
g, ctx := errgroup.WithContext(ctx)
for _, id := range ids {
id := id
g.Go(func() error {
_, err := client.Fetch(ctx, id)
return err
})
}
return g.Wait()
}
Se una goroutine restituisce un errore, il context del gruppo viene annullato e altre goroutine che rispettano ctx.Done() possono fermarsi in anticipo. Questo è molto più pulito della gestione manuale di più goroutine, canali e percorsi di cancellazione. La chiave qui è “rispettare il context” — errgroup non può fermare un lavoro che ignora ctx.Done().
Spegnimento graduale (Graceful shutdown)
Il context è centrale per lo spegnimento graduale.
Una configurazione tipica del server ha:
- un context radice annullato dai segnali del SO
- un server HTTP
- worker in background
- un timeout di spegnimento
- logica di pulizia
Esempio:
func main() {
root, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
server := &http.Server{
Addr: ":8080",
Handler: routes(),
}
go func() {
<-root.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
slog.Error("server shutdown failed", "err", err)
}
}()
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server failed", "err", err)
os.Exit(1)
}
}
Notare che il context di spegnimento non è lo stesso del context radice — il root è già annullato quando arriva il segnale del SO. Un context di timeout separato dà al processo di spegnimento una quantità limitata di tempo per drenare le richieste in corso prima di forzare l’uscita, che è la distinzione sottile ma importante che fa funzionare davvero lo spegnimento graduale.
Anti-pattern comuni
Anti-pattern 1: Usare il context come contenitore di dipendenze
Sbagliato:
ctx = context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "logger", logger)
ctx = context.WithValue(ctx, "config", cfg)
Passa le dipendenze esplicitamente.
Anti-pattern 2: Creare context.Background dentro la logica di business
Sbagliato:
func (s *Service) DoWork(ctx context.Context) error {
return s.repo.Save(context.Background())
}
Questo rompe la propagazione della cancellazione.
Anti-pattern 3: Dimenticare cancel
Sbagliato:
ctx, _ := context.WithTimeout(parent, time.Second)
Buono:
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()
Anti-pattern 4: Mettere parametri facoltativi nel context
Sbagliato:
ctx = context.WithValue(ctx, "includeDeleted", true)
Usa strutture di opzioni esplicite.
Anti-pattern 5: Passare il context troppo in profondità nel codice puro
Sbagliato:
func Add(ctx context.Context, a, b int) int {
return a + b
}
Il calcolo puro non ha bisogno di context a meno che non sia a lunga durata o annullabile.
Anti-pattern 6: Ignorare la cancellazione nei loop
Sbagliato:
for item := range items {
process(item)
}
Migliore:
for item := range items {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := process(ctx, item); err != nil {
return err
}
}
Anti-pattern 7: Ingoiare gli errori del context
Sbagliato:
if err != nil {
return errors.New("operation failed")
}
Buono:
if err != nil {
return fmt.Errorf("operation failed: %w", err)
}
Preserva gli errori di cancellazione e scadenza.
Un controllo pratico del context
Usa questa checklist per il codice backend Go.
Firme delle funzioni
- Il context è il primo parametro.
- Il context non è memorizzato in strutture a lunga vita.
- Il context non è passato a funzioni helper pure a meno che non necessario.
- Il context nil non è mai usato.
Cancellazione
- I loop a lunga durata verificano
ctx.Done(). - Le goroutine hanno un percorso di spegnimento.
- I cicli di vita dei worker sono legati a un context genitore.
- La cancellazione del context è propagata alle chiamate downstream.
Timeout
- I timeout di richiesta esterni sono impostati al confine.
- I timeout delle sotto-operazioni sono più piccoli del budget esterno.
- Le funzioni cancel sono sempre chiamate.
- I timeout non sono impilati acriticamente a ogni livello.
Valori
- I valori del context sono con ambito di richiesta.
- Le chiavi usano tipi personalizzati, non stringhe semplici.
- Le dipendenze non sono memorizzate nel context.
- I parametri facoltativi non sono memorizzati nel context.
Errori
context.Canceledecontext.DeadlineExceededsono preservati.- Gli errori del context sono mappati correttamente ai confini delle API.
- La cancellazione consapevole del motivo è usata solo quando il motivo è importante.
Test
- I test usano
t.Context()dove appropriato. - I test dei timeout evitano sleep reali lenti.
- Il comportamento dei timeout concorrenti è testato con
testing/synctestquando utile. - Le perdite di goroutine sono verificate assicurando che esistano percorsi di spegnimento.
Come controllare l’uso del context in un codebase Go
Cerca questi modelli:
grep -R "context.Background()" .
grep -R "context.TODO()" .
grep -R "WithTimeout" .
grep -R "WithCancel" .
grep -R "WithValue" .
grep -R "type .* struct" .
Poi chiedi:
context.Background()è usato solo ai confini di livello superiore?- Le funzioni cancel sono sempre chiamate?
- I timeout sono posizionati a confini sensati?
- I valori del context sono realmente con ambito di richiesta?
- Le dipendenze sono nascoste nei valori del context?
- Le goroutine sono fermabili?
- Gli errori del context sono preservati?
Questo è un buon abitodine di code review, perché molti bug del context non sono bug di sintassi — sono bug di ciclo di vita che emergono solo sotto condizioni di cancellazione, carico o spegnimento.
Le mie regole opinabili
Queste regole sono noiose, ma funzionano.
Regola 1: Il context è flusso di controllo
Usa il context per controllare la cancellazione, le scadenze e i metadati della richiesta.
Non usarlo per contrabbandare dipendenze.
Regola 2: Il chiamante possiede il budget
Una funzione dovrebbe di solito rispettare il context che riceve.
Crea un timeout figlio più corto solo quando la sotto-operazione ha bisogno di un budget specifico più piccolo.
Regola 3: Background appartiene al bordo
Usa context.Background() in main, nei test e nella configurazione di livello superiore.
Non usarlo dentro i metodi di servizio e repository per sfuggire alla cancellazione.
Regola 4: I valori dovrebbero essere noiosi
ID di richiesta, ID di traccia, ID utente e ID tenant appartengono al context. Connessioni al database, logger, strutture di configurazione e client di servizio no — sono dipendenze e dovrebbero essere passate esplicitamente.
Regola 5: Ogni goroutine ha bisogno di un ciclo di vita
Se una goroutine si avvia, dovresti sapere esattamente come si ferma. Il context è spesso la risposta giusta, e se non è il context, dovrebbe esserci qualche altro meccanismo chiaro — un canale, una primitiva di sincronizzazione o un segnale esplicito.
Pensieri finali
context.Context non è complicato perché l’API è grande — l’API è piccola. È complicato perché rappresenta il ciclo di vita, e il ciclo di vita è architettura. Ogni decisione su dove il context fluisce, dove viene derivato e dove si ferma è una decisione su come il tuo servizio gestisce il fallimento, il carico e lo spegnimento.
Un context usato bene rende i servizi Go più facili da annullare, più facili da spegnere, più facili da osservare e meno propensi a perdere goroutine. Un context usato male nasconde dipendenze, scarta scadenze e rende il codice più difficile da ragionare sotto pressione.
La conclusione pratica è semplice:
Passa il context in profondità.
Non memorizzarlo.
Non sostituire i parametri espliciti con valori.
Rispetta la cancellazione.
Usa i timeout ai confini.
Chiama sempre cancel.
Questo è il context di Go fatto bene.
Questo articolo fa parte del cluster Architettura delle Applicazioni in Produzione, che copre la struttura del codice, l’accesso ai dati, i modelli di integrazione e l’architettura dei test per sistemi Go e Python di produzione.