Go context.Context gjort rätt: Avbrott, tidsgränser och värden

Go-kontext är kontrollflöde, inte lagring.

Sidinnehåll

Go:s context.Context är enkel nog att använda fel — och det är just det problemet.

De flesta Go-utvecklare lär sig de grundläggande reglerna snabbt: överför context som det första argumentet, kontrollera ctx.Done(), använd context.WithTimeout och överför aldrig nil.

func DoSomething(ctx context.Context) error {
	// ...
}

Dessa regler är användbara, men de täcker bara den enkla delen. I produktionsmiljöer är context inte bara en parameterkonvention — det är kontrollplanet för livslängden hos en begäran.

Go context: avbrott sprids genom anropskedjan

Context berättar för arbete när det ska sluta, hur mycket tid som återstår, vilken avbrottsväg som togs och vilka begäran-specifika värden som behöver transporteras över API-gränser. Används den väl, förhindrar den läckande goroutiner, undviker onödigt arbete, sprider deadline-tider och gör tjänster enklare att stänga av. Används den dåligt, blir den en påse med dolda beroenden, falska globala variabler, glömda timeout-värden, läckande tidtagare och förvirrande avbrottsbeteende.

Den lite åsiktsstyrda versionen är denna: använd context för avbrott, deadline-tider och begäran-specifik metadata, och använd den inte som en behållare för beroenden.

Vad context är till för

Paketet context har tre huvuduppgifter — avbrott, deadline-tider och tidsgränser, samt begäran-specifika värden — och dessa tre uppgifter täcker allt det är designat för.

En context bör besvara frågor som:

Har denna begäran avbrutits?
Hur mycket tid har denna operation kvar?
Vilket begäran-ID ska kopplas till loggarna?
Vilken autentiserad användare är associerad med denna begäran?

En context bör inte besvara frågor som:

Var finns min databasanslutning?
Var finns min loggare?
Var finns min konfiguration?
Vilken tjänsteimplementering ska jag använda?

Det är beroenden — överför dem explicit genom funktionsparametrar (se Beroendeinjektion i Go för mönster på hur man gör detta rent). Context är för begäranens livslängd och begäranens metadata, inte för applikationstråddragning.

Den grundläggande formen av context

Det kärnaktiga gränssnittet är litet:

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

De viktiga delarna är:

  • Done() stängs när contexten avbryts eller dess deadline löper ut.
  • Err() förklarar varför contexten avslutades.
  • Deadline() berättar om contexten har en deadline.
  • Value() lagrar begäran-specifik data.

De flesta kodimplementerar inte detta gränssnitt. Den tar emot en context och överför den vidare.

Den första regeln: överför context explicit

För funktioner som utför begäran-specifikt eller avbrytbart arbete, överför context som den första parametern — detta är den standardkonventionen i Go och vad varje bibliotek och verktyg i ekosystemet förväntar sig:

func GetUser(ctx context.Context, id string) (*User, error) {
	// ...
}

Gör detta för funktioner som kan:

  • Anropa en databas
  • Anropa en annan tjänst
  • Vänta på en kö
  • Starta bakgrundsarbete
  • Blockeras vid I/O
  • Använda en tidsgräns
  • Behöver begäran-specifika värden
  • Behöver avbrott

Lägg inte till context i små rena funktioner som inte behöver den.

Detta är okej:

func NormalizeEmail(email string) string {
	return strings.ToLower(strings.TrimSpace(email))
}

Inte varje funktion behöver en context. Att lägga till context överallt gör koden bråkig.

Lagra inte context i strukturer

Att lagra en context i en struktur är ett av de vanligaste misstagen i Go-kodbasen, och det är värt att nämna explicit. Gör inte detta:

type UserService struct {
	ctx context.Context
	db  *sql.DB
}

Gör detta istället:

type UserService struct {
	db *sql.DB
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	// ...
}

En context tillhör en begäran, operation eller uppgift, medan en tjänststruktur oftast lever mycket längre än någon enskild begäran. Att blanda dessa livslängder gör avbrott otydligt och gör det svårt att resonera kring vilken operation en context tillhör.

Det finns sällsynta undantag för typer som genuint representerar en enkel operations livslängd, men de är tillräckligt sällsynta så att den grundregeln bör vara enkel:

Överför context. Lagra den inte.

Överför inte nil-context

Överför aldrig nil som en context.

Dåligt:

err := svc.DoWork(nil)

Använd context.Background() när det inte finns någon befintlig context:

err := svc.DoWork(context.Background())

I tester, använd testcontexten när det är möjligt:

func TestDoWork(t *testing.T) {
	err := svc.DoWork(t.Context())
	if err != nil {
		t.Fatal(err)
	}
}

En nil-context kan panikera när kod anropar metoder på den. En bakgrundskontext är explicit och säker.

Bakgrund-, TODO- och begäran-context

Det finns tre vanliga startpunkter.

context.Background

Använd context.Background() på toppnivån i ett program när ingen överordnad context finns — det är rotcontexten från vilken alla undercontexter härleds:

func main() {
	ctx := context.Background()
	_ = run(ctx)
}

eller:

func TestSomething(t *testing.T) {
	ctx := context.Background()
	_ = ctx
}

context.TODO

Använd context.TODO() när du vet att en context bör användas men inte har bestämt vilken än.

ctx := context.TODO()

Detta är användbart under migration, men det bör inte bli permanent om en verklig context finns.

Begäran-context

I HTTP-server, använd begärancontexten:

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	_ = ctx
}

Begärancontexten avbryts när klientanslutningen stängs, begäran avbryts eller servern är klar med att hantera begäran.

För webbtjänster är detta oftast contexten du bör överföra vidare till applikationskoden.

Avbrott med context.WithCancel

Använd context.WithCancel när du vill stoppa arbete explicit.

ctx, cancel := context.WithCancel(parent)
defer cancel()

Den returnerade cancel-funktionen avbryter undercontexten och frigör resurser associerade med den. Anrop alltid den när du är klar — även om contexten till slut kommer att löpa ut, undviker tidigt anrop av cancel att hålla resurser vid liv längre än nödvändigt.

Exempel:

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
	}
}

Mönstret är enkelt:

  • Härled en undercontext.
  • Deferred cancel.
  • Överför undercontexten till arbete som ska stoppa tillsammans.
  • Övervaka ctx.Done().

Timeout-värden med context.WithTimeout

Använd context.WithTimeout när en operation har en maximal varaktighet.

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

Exempel med en HTTP-klient:

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)
}

Detta gör timeout-värdet en del av operationen, inte en dold global inställning.

Anrop alltid cancel

När du anropar WithCancel, WithTimeout eller WithDeadline, anrop alltid den returnerade cancel-funktionen — detta är viktigt för korrekthet.

Bra:

ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()

Dåligt:

ctx, _ := context.WithTimeout(parent, 5*time.Second)

Att misslyckas med att anropa cancel kan hålla tidtagare och undercontexter vid liv längre än nödvändigt.

Deadlines vs timeouts

En timeout är relativ:

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

En deadline är absolut:

deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()

De flesta applikationskoder använder timeouts. Deadlines är användbara när en begäran har en fast sluttid som ska delas över flera operationer — till exempel, om en begäran har 900 millisekunder kvar, ge inte varje nedströmsanrop en ny 1-sekunds timeout; sprid den återstående budgeten istället.

Timeout-budgeter över tjänstelager

Ett vanligt misstag är att stapla timeouts blint.

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)
}

Detta ser oskyldigt ut, men det döljer den verkliga budgeten. Tjänstelagret bör vanligtvis respektera anroparens deadline istället för att återställa tidtagaren till samma värde.

Ett bättre mönster är:

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 {
		// hantera fel
		return
	}
}

Därefter inuti tjänsten:

func (s *Service) DoWork(ctx context.Context) error {
	return s.repo.Query(ctx)
}

Lägg till en underordnad timeout endast när en deloperation behöver en mindre budget:

func (s *Service) DoWork(ctx context.Context) error {
	queryCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
	defer cancel()

	return s.repo.Query(queryCtx)
}

Den rätta mentala modellen är enkel: hela begäran har en yttre budget, specifika deloperationer kan ha mindre budgetar skurna ut från den budgeten, och inget lager utökar tyst begäran bortom vad anroparen avsåg.

Kontrollera ctx.Err() för att skilja avbrott från timeout

När en context slutar, returnerar ctx.Err() anledningen.

Vanligtvis är det ett av:

context.Canceled
context.DeadlineExceeded

Exempel:

select {
case <-ctx.Done():
	return ctx.Err()
case result := <-resultCh:
	return handle(result)
}

Detta låter anropare skilja avbrott från timeout, och den skillnaden är viktig i praktiken. En avbruten begäran betyder ofta att klienten kopplades ur, medan ett deadline-överskridet fel vanligtvis betyder att din tjänst var för långsam — de bör inte alltid loggas, försökas igen eller rapporteras på samma sätt.

Använd context.Cause för bättre avbrottsanledningar

Modern Go stöder också orsaksmedvetet avbrott.

De användbara funktionerna inkluderar:

  • context.WithCancelCause
  • context.WithTimeoutCause
  • context.WithDeadlineCause
  • context.Cause

Vanlig ctx.Err() berättar den breda anledningen: avbruten eller deadline överskriden.

context.Cause(ctx) kan berätta den mer specifika orsaken.

Exempel:

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)
}

Använd orsaksmedvetet avbrott när anledningen är viktig för anropare, loggar eller städningsbeteende, och undvik det där en vanlig ctx.Err() räcker — den extra detaljen är bara värd det när diagnostik genuint kräver det.

HTTP-serverexempel

En normal HTTP-handläggare bör starta från r.Context(). För en full genomgång av att strukturera Go HTTP-tjänster, se Bygga REST-API:er i Go.

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)
	}
}

Tjänsten bör acceptera och sprida contexten:

type UserService struct {
	repo *UserRepository
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(ctx, id)
}

Repositoriet bör använda contextmedvetna databasmetoder:

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
}

Det viktiga är kedjan — varje lager överför samma context vidare till nästa:

flowchart TD A[HTTP begärancontext] --> B[Handläggare] B --> C[Tjänst] C --> D[Repositorium] D --> E[Databasfråga]

Bryt inte kedjan genom att skapa context.Background() i mitten.

context.Background()-misstaget: att bryta avbrottskedjan

Detta är en vanlig bugg:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(context.Background(), id)
}

Detta kasserar all avbrotts- och deadlineinformation från anroparen. Om klienten kopplar ur, fortsätter databasfrågan att köras. Om begäran löper ut, kan nedströmsarbetet fortfarande vara igång. Om servern stängs av, ignorerar denna kod det helt. Att ersätta den mottagna contexten med context.Background() inuti affärslogik är nästan alltid fel.

Använd contexten du fick:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(ctx, id)
}

Använd endast context.Background() vid kanten där ingen överordnad context finns.

HTTP-klientexempel

För utgående HTTP-begäran, koppla contexten till begäran.

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)
}

Gör inte detta:

req, err := http.NewRequest(http.MethodGet, endpoint, nil)

Detta skapar en begäran utan operationscontext.

Undvik också att enbart förlita dig på http.Client.Timeout. Det kan vara användbart som en säkerhetsgräns, men begärancontexter ger dig bättre spridning över anropskedjan.

Ett vanligt mönster är:

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)
}

Använd detta när den nedströms API-anropet har en specifik budget inuti en större begäran.

Databasexempel

De flesta Go-databas-API:er har contextmedvetna metoder. För en bredare titt på hur Go-dataåtkomstbibliotek hanterar context — inklusive GORM, Ent, Bun och sqlc — se Jämföra Go ORM:er för PostgreSQL.

Använd dem.

Bra:

rows, err := db.QueryContext(ctx, query, args...)

Bra:

err := db.QueryRowContext(ctx, query, id).Scan(&name)

Bra:

result, err := db.ExecContext(ctx, query, args...)

Dåligt:

rows, err := db.Query(query, args...)

De contextmedvetna formerna tillåter databasoperationer att stoppa när begäran avbryts eller löper ut, vilket är särskilt viktigt för långsamma frågor, överbelastade databaser och användarvänliga API:er där latens direkt påverkar användarupplevelsen.

Transaktioner och context

Transaktioner kräver noggrann contexthantering.

En transaktion bör vanligtvis börja med operationscontexten:

tx, err := db.BeginTx(ctx, nil)
if err != nil {
	return err
}
defer tx.Rollback()

Använd sedan samma context för transaktionsoperationer:

if _, err := tx.ExecContext(ctx, query, args...); err != nil {
	return err
}

if err := tx.Commit(); err != nil {
	return err
}

Var försiktig med timeouts runt transaktioner. Om contexten avbryts innan Commit, kan transaktionen rullas tillbaka. Det kan vara vad du vill, men det bör vara avsiktligt.

För långa transaktioner är det bättre svaret oftast inte en längre timeout — det är en kortare transaktion som utför mindre arbete per enhet.

Bakgrundsarbetare och context

Bakgrundsarbetare bör ta emot en context som representerar deras livslängd.

Exempel:

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)
			}
		}
	}
}

Denna arbetare stannar rent när contexten avbryts, och dess tickare städas korrekt via defer ticker.Stop(). I main skulle du skapa en rotcontext kopplad till OS-signaler:

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)
	}
}

Detta är context använt korrekt: den beskriver processarbetets livslängd, och när OS:et skickar en signal, kommer hela trädet av goroutiner som delar denna context att stoppa tillsammans.

Förhindra läckande goroutiner med contextavbrott

En läckande goroutine händer när en goroutine förblir blockerad för alltid efter att den inte längre är användbar.

Context hjälper till att förhindra detta.

Dåligt:

func StartWorker() {
	go func() {
		for {
			doWork()
			time.Sleep(time.Second)
		}
	}()
}

Denna goroutine har ingen stängningsväg.

Bättre:

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()
			}
		}
	}()
}

Varje goroutine som looper bör nästan alltid ha en avbrottsväg.

Det betyder inte att varje goroutine måste ta emot context direkt, men systemet bör ha ett tydligt sätt att stoppa den.

context.AfterFunc

context.AfterFunc kör en funktion efter att en context avbrutits.

Det kan vara användbart för städning, att blockera upp operationer eller att broa API:er som inte nativt stöder context.

Exempel:

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
}

Använd AfterFunc med försiktighet — den startar logik när avbrott inträffar, vilket kan göra kontrollflödet svårare att följa. För de flesta applikationskoder är ett normalt selectctx.Done() tydligare och enklare att resonera kring. AfterFunc är mest värdefull när du behöver anpassa contextavbrott till ett API som inte redan accepterar context.

context.WithoutCancel

context.WithoutCancel skapar en context som inte avbryts när överordnade avbryts.

Detta är användbart, men det är också lätt att missanvända.

Exempel på användningsfall:

func Handler(audit *AuditLog) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		// Hantera begäran...
		_ = ctx

		auditCtx := context.WithoutCancel(ctx)

		go func() {
			ctx, cancel := context.WithTimeout(auditCtx, 2*time.Second)
			defer cancel()

			_ = audit.Write(ctx, "request completed")
		}()
	}
}

Idén är att revisionsskrivningen kan behöva fortsätta kort efter att begärancontexten avbrutits. Detta bör vara sällsynt och avsiktligt — använd inte WithoutCancel som ett sätt att undvika att hantera avbrott. Använd den endast när underarbetet genuint måste överleva det överordnade avbrottet, och lägg alltid till en ny timeout: en context som ignorerar avbrott men inte bär någon deadline kan lätt skapa bakgrundsgoroutine-läckor.

Contextvärden gjorda rätt

Contextvärden är för begäran-specifik data som korsar API-gränser.

Bra exempel:

  • begäran-ID
  • spårnings-ID
  • autentiserat användar-ID
  • tenant-ID
  • språk/region
  • säkerhetsprincip
  • korrelationsmetadata

Dåliga exempel:

  • databasanslutning
  • loggare som ett dolt beroende
  • funktionsflaggor för vanlig kontrollflöde
  • valbara funktionsparametrar
  • konfiguration
  • tjänsteklienter

En användbar regel: om värdet är en del av begäranens identitet eller observabilitetscontext, kan det tillhöra context. Om det är ett beroende din kod behöver för att utföra sitt jobb, överför det explicit.

Använd typade nycklar för contextvärden

Använd inte vanliga strängar som contextnycklar.

Dåligt:

ctx = context.WithValue(ctx, "userID", "123")

Detta kan kollidera med andra paket.

Använd en oexporterad anpassad nyckeltyp:

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
}

Detta mönster ger dig typsäkerhet vid paketgränsen, undviker nyckelkollisioner med andra paket och håller context-API-ytan ren med typade åtkomstfunktioner.

Använd inte contextvärden för valbara parametrar

Detta är dåligt:

ctx = context.WithValue(ctx, "pageSize", 100)
users, err := repo.ListUsers(ctx)

Detta döljer funktionskontraktet.

Föredra explicita parametrar:

users, err := repo.ListUsers(ctx, ListUsersOptions{
	PageSize: 100,
})

Contextvärden bör inte ersätta funktionsargument. Dolda indata gör koden svårare att förstå, testa och granska — och alla som läser funktionsignaturen kommer inte att ha en aning om att parametern ens existerar.

Loggning och context

Det finns två vanliga tillvägagångssätt för loggning med context. Exemplen här använder Go:s log/slog-paket — för en djupare dykning i strukturerad loggning med slog i produktionsmiljöer, se Strukturerad loggning i Go med slog.

Tillvägagångssätt 1: Extrahera värden och koppla dem till loggar

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)
}

Detta håller loggaren explicit som ett ordentligt beroende och använder context endast för begäran-specifika värden som legitimt behöver korsa API-gränser.

Tillvägagångssätt 2: Lagra loggare i context

Vissa kodbasen lagrar en loggare i context.

Detta kan vara bekvämt, men jag rekommenderar det inte som standard. Det gör context till en beroendebehållare.

Min preferens:

  • Överför loggarberoenden explicit.
  • Lagra spårnings-ID:n och begäran-ID:n i context.
  • Lägg till dessa värden i loggar vid gränser eller middleware.

Detta håller beroenden synliga.

Context och spårning

Spårning är ett av de starkaste användningsförfallen för contextvärden, och det är genuint bra lämpligt. OpenTelemetry och liknande system använder context för att sprida spårningsspännningar över funktionsanrop och processgränser, eftersom spårningsdata är exakt den typ av begäran-specifik metadata context var designad för att bära.

Ett typiskt mönster ser ut som:

func (s *Service) DoWork(ctx context.Context) error {
	ctx, span := s.tracer.Start(ctx, "Service.DoWork")
	defer span.End()

	return s.repo.Query(ctx)
}

Contexten bär den aktiva spårningsspännningen, och repositoriet kan skapa en underordnad spännning från den. Varje lager lägger till sin egen spännning utan någon explicit överföring av tracer-objekt — contexten utför detta arbete transparent över hela anropsträdet.

Felhantering med context

När en operation stoppar på grund av contextavbrott, bevara den informationen. Mönstren här kompletterar de bredare felhanteringsstrategierna som täcks i Go-felhanteringsarkitektur.

Exempel:

err := svc.DoWork(ctx)
if err != nil {
	if errors.Is(err, context.Canceled) {
		// Klienten avbröt eller anroparen stoppade arbetet.
		return err
	}

	if errors.Is(err, context.DeadlineExceeded) {
		// Timeout.
		return err
	}

	return err
}

Dölj inte blint contextfel på ett sätt som döljer dem.

Inpackning med %w bevarar errors.Is, så anropare kan fortfarande upptäcka avbrott eller timeout:

if err != nil {
	return fmt.Errorf("query user: %w", err)
}

Att ersätta felet helt kasserar den informationen och bryter varje anropare som kontrollerar specifika contextfeltyper:

if err != nil {
	return errors.New("query user failed")
}

Kartlägga contextfel till HTTP-svar

Contextfel kartläggs ofta till olika HTTP-utfall.

Exempel:

func writeError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, context.Canceled):
		// Klienten har troligen försvunnit.
		// Vissa system loggar detta som en avbruten klientbegäran.
		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
	}
}

Behandla inte klientavbrott som ett applikationsfel — om användaren stängde webbläsarfliken, är det inte din tjänst som beter sig fel, och att logga det som ett fel lägger till brus utan signal.

Context i middleware

HTTP-middleware är en vanlig plats att lägga till begäran-specifika värden.

Exempel på begäran-ID-middleware:

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))
	})
}

Detta är en bra användning av context. Begäran-ID:t tillhör begäran, det bör resa genom hela anropskedjan, och att koppla det till loggar och spårningar på varje lager är exakt den typ av tvärsnittlig observabilitetsfråga som contextvärden är designade för att stödja.

Context i tester

I tester, undvik att använda context.Background() blint.

Föredra t.Context() när arbetet tillhör testets livslängd:

func TestService(t *testing.T) {
	ctx := t.Context()

	err := service.DoWork(ctx)
	if err != nil {
		t.Fatal(err)
	}
}

För timeoutbeteende, testa med en verklig timeout endast om timeouten är liten och meningsfull.

För samtidighetskod och tidsberoende kod, överväg att använda testing/synctestTesta samtidig Go-kod med synctest täcker detta verktyg i djupet:

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())
		}
	})
}

Detta låter dig testa verkliga timeout-värden utan att vänta på verklig tid.

Context och errgroup

För grupper av goroutiner som ska avbrytas tillsammans, är errgroup ofta en bra passform.

Exempel:

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()
}

Om en goroutine returnerar ett fel, avbryts gruppcontexten och andra goroutiner som respekterar ctx.Done() kan stoppa i förtid. Detta är mycket renare än att manuellt hantera flera goroutiner, kanaler och avbrottsvägar. Den nyckelfrasen här är “respektera contexten” — errgroup kan inte stoppa arbete som ignorerar ctx.Done().

Graceful shutdown (smidig avstängning)

Context är centralt för smidig avstängning.

En typisk serveruppsättning har:

  • en rotcontext avbruten av OS-signaler
  • en HTTP-server
  • bakgrundsarbetare
  • en avstängningstid
  • städningslogik

Exempel:

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)
	}
}

Observera att avstängningscontexten inte är samma som rotcontexten — roten är redan avbruten när OS-signalen ankommer. En separat timeout-context ger avstängningsprocessen en begränsad mängd tid att tömma pågående begäran innan tvångsavstängning, vilket är den subtila men viktiga distinktionen som gör att smidig avstängning faktiskt fungerar.

Vanliga antipattern

Antipattern 1: Använda context som en beroendebehållare

Dåligt:

ctx = context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "logger", logger)
ctx = context.WithValue(ctx, "config", cfg)

Överför beroenden explicit.

Antipattern 2: Att skapa context.Background inuti affärslogik

Dåligt:

func (s *Service) DoWork(ctx context.Context) error {
	return s.repo.Save(context.Background())
}

Detta bryter avbrottspropageringen.

Antipattern 3: Att glömma cancel

Dåligt:

ctx, _ := context.WithTimeout(parent, time.Second)

Bra:

ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()

Antipattern 4: Att lägga valbara parametrar i context

Dåligt:

ctx = context.WithValue(ctx, "includeDeleted", true)

Använd explicita optionsstrukturer.

Antipattern 5: Att överföra context för djupt in i ren kod

Dåligt:

func Add(ctx context.Context, a, b int) int {
	return a + b
}

Ren beräkning behöver inte context om den inte är långvarig eller avbrytbar.

Antipattern 6: Att ignorera avbrott i looper

Dåligt:

for item := range items {
	process(item)
}

Bättre:

for item := range items {
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	if err := process(ctx, item); err != nil {
		return err
	}
}

Antipattern 7: Att svälja contextfel

Dåligt:

if err != nil {
	return errors.New("operation failed")
}

Bra:

if err != nil {
	return fmt.Errorf("operation failed: %w", err)
}

Bevara avbrotts- och deadline-fel.

En praktisk contextchecklista

Använd denna checklista för Go-backend-kod.

Funktionssignaturer

  • Context är den första parametern.
  • Context lagras inte i långlivade strukturer.
  • Context överförs inte till rena hjälpfunktioner om det inte behövs.
  • Nil-context används aldrig.

Avbrott

  • Långvariga looper kontrollerar ctx.Done().
  • Goroutiner har en stängningsväg.
  • Arbetarelivslängder är kopplade till en överordnad context.
  • Contextavbrott sprids till nedströmsanrop.

Timeouts

  • Yttre begäran-timeouts ställs in vid gränsen.
  • Deloperation-timeouts är mindre än den yttre budgeten.
  • Cancel-funktioner anropas alltid.
  • Timeouts staplas inte blint på varje lager.

Värden

  • Contextvärden är begäran-specifika.
  • Nycklar använder anpassade typer, inte vanliga strängar.
  • Beroenden lagras inte i context.
  • Valbara parametrar lagras inte i context.

Fel

  • context.Canceled och context.DeadlineExceeded bevaras.
  • Contextfel kartläggs korrekt vid API-gränser.
  • Orsaksmedvetet avbrott används endast när anledningen är viktig.

Tester

  • Tester använder t.Context() där det är lämpligt.
  • Timeout-tester undviker långsamma verkliga väntetider.
  • Samtidig timeout-beteende testas med testing/synctest när det är användbart.
  • Läckande goroutiner kontrolleras genom att säkerställa att stängningsvägar finns.

Hur man granskar contextanvändning i en Go-kodbas

Sök efter dessa mönster:

grep -R "context.Background()" .
grep -R "context.TODO()" .
grep -R "WithTimeout" .
grep -R "WithCancel" .
grep -R "WithValue" .
grep -R "type .* struct" .

Fråga sedan:

  • Används context.Background() endast vid toppnivågränser?
  • Anropas cancel-funktioner alltid?
  • Placeras timeouts vid rimliga gränser?
  • Är contextvärden verkligen begäran-specifika?
  • Döljs beroenden i contextvärden?
  • Kan goroutiner stoppas?
  • Bevaras contextfel?

Detta är en bra kodgranskningsvanhet, eftersom många contextbuggar inte är syntaxbuggar — de är livslängdsbuggar som bara yttrar sig under avbrott, belastning eller avstängningsförhållanden.

Mina åsiktsstyrda regler

Dessa regler är tråkiga, men de fungerar.

Regel 1: Context är kontrollflöde

Använd context för att kontrollera avbrott, deadline-tider och begäranmetadata.

Använd den inte för att smugla beroenden.

Regel 2: Anroparen äger budgeten

En funktion bör vanligtvis respektera contexten den tar emot.

Skapa endast en kortare underordnad timeout när deloperationen behöver en specifik mindre budget.

Regel 3: Background tillhör kanten

Använd context.Background() i main, tester och toppnivåuppsättning.

Använd den inte inuti tjänst- och repositoriemetoder för att undvika avbrott.

Regel 4: Värden bör vara tråkiga

Begäran-ID, spårnings-ID, användar-ID och tenant-ID tillhör context. Databasanslutningar, loggrare, konfigurationsstrukturer och tjänsteklienter tillhör inte det — de är beroenden och bör överföras explicit.

Regel 5: Varje goroutine behöver en livslängd

Om en goroutine startar, bör du veta exakt hur den stoppar. Context är ofta rätt svar, och om det inte är context, bör det finnas någon annan tydlig mekanism — en kanal, en synkroniseringsprimtiv eller en explicit signal.

Avslutande tankar

context.Context är inte komplicerat eftersom API:t är stort — API:t är litet. Det är komplicerat eftersom det representerar livslängd, och livslängd är arkitektur. Varje beslut om var context flyter, var den härleds och var den stoppar är ett beslut om hur din tjänst hanterar fel, belastning och avstängning.

En väl använd context gör Go-tjänster enklare att avbryta, enklare att stänga av, enklare att observera och mindre benägna att läcka goroutiner. En dåligt använd context döljer beroenden, kasserar deadline-tider och gör koden svårare att resonera kring under press.

Den praktiska poängen är enkel:

Överför context vidare.
Lagra den inte.
Ersätt inte explicita parametrar med värden.
Respektera avbrott.
Använd timeouts vid gränser.
Anrop alltid cancel.

Det är Go-context gjord rätt.

Denna artikel är en del av App Architecture in Production-clustern, som täck kodstruktur, dataåtkomst, integrationsmönster och testarkitektur för produktionsmiljöer för Go- och Python-system.

Källor

Prenumerera

Få nya inlägg om system, infrastruktur och AI-ingenjörskonst.