Go context.Context richtig verwenden: Abbruch, Timeouts und Werte

„Der Go-Kontext ist eine Kontrollflusssteuerung, keine Speicherung.“

Inhaltsverzeichnis

Gos context.Context ist einfach genug, um es falsch zu verwenden – und genau darin liegt das Problem.

Die meisten Go-Entwickler lernen die Oberflächenregeln schnell: context als ersten Parameter übergeben, ctx.Done() prüfen, context.WithTimeout verwenden und niemals nil übergeben.

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

Diese Regeln sind nützlich, decken aber nur den einfachen Teil ab. In Produktionsdiensten ist context mehr als nur eine Parameterkonvention – er ist die Kontrollenebene für die Lebensdauer einer Anfrage.

Go context: Abbruch propagiert durch die Aufrufkette

Context teilt der Arbeit mit, wann sie aufhören soll, wie viel Zeit ihr noch bleibt, welchen Abbruchweg genommen wurde und welche an die Anfrage gebundenen Werte über API-Grenzen hinweg weitergegeben werden müssen. Gut eingesetzt, verhindert er Goroutine-Leaks, vermeidet nutzlose Arbeit, propagiert Fristen und erleichtert das Herunterfahren von Diensten. Falsch eingesetzt, wird er zu einer Sammlung versteckter Abhängigkeiten, falscher Globalen, vergessener Timeouts, undichtender Timer und verwirrendem Abbruchverhalten.

Die etwas meinungsstärkere Fassung lautet so: Verwenden Sie context für Abbruch, Fristen und an die Anfrage gebundene Metadaten, und verwenden Sie ihn nicht als Abhängigkeitscontainer.

Wofür context gedacht ist

Das context-Paket hat drei Hauptaufgaben – Abbruch, Fristen und Timeouts sowie an die Anfrage gebundene Werte – und diese drei Aufgaben decken alles ab, wofür es designed wurde.

Ein context sollte auf Fragen wie diese antworten:

Wurde diese Anfrage abgebrochen?
Wie viel Zeit hat diese Operation noch?
Welche Request-ID sollte an die Logs angehängt werden?
Welcher authentisierter Benutzer ist mit dieser Anfrage assoziiert?

Ein context sollte nicht auf Fragen wie diese antworten:

Wo ist meine Datenbankverbindung?
Wo ist mein Logger?
Wo ist meine Konfiguration?
Welche Service-Implementierung soll ich verwenden?

Das sind Abhängigkeiten – übergeben Sie sie explizit über Funktionsparameter (siehe Dependency Injection in Go für Muster, um dies sauber zu erledigen). Context ist für die Lebensdauer und Metadaten einer Anfrage da, nicht für die Verdrahtung der Anwendung.

Die grundlegende Struktur von context

Die zentrale Schnittstelle ist klein:

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

Die wichtigen Teile sind:

  • Done() wird geschlossen, wenn der context abgebrochen wird oder seine Frist abläuft.
  • Err() erklärt, warum der context beendet wurde.
  • Deadline() sagt Ihnen, ob der context eine Frist hat.
  • Value() speichert an die Anfrage gebundene Daten.

Die meisten Codes implementieren diese Schnittstelle nicht. Sie erhalten einen context und geben ihn weiter.

Die erste Regel: context explizit übergeben

Für Funktionen, die an die Anfrage gebundene oder abrebbare Arbeit erledigen, übergeben Sie context als ersten Parameter – dies ist die Standard-Go-Konvention und das, was jede Bibliothek und jedes Tool im Ökosystem erwartet:

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

Machen Sie dies für Funktionen, die möglicherweise:

  • eine Datenbank aufrufen
  • einen anderen Dienst aufrufen
  • auf einer Queue warten
  • Hintergrundarbeit starten
  • auf I/O blockiert sind
  • ein Timeout verwenden
  • an die Anfrage gebundene Werte benötigen
  • Abbruch benötigen

Fügen Sie keinen context zu kleinen reinen Funktionen hinzu, die ihn nicht benötigen.

Das ist in Ordnung:

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

Nicht jede Funktion benötigt einen context. Context überall hinzuzufügen macht den Code unübersichtlich.

Speichern Sie keinen context in Structs

Das Speichern eines context in einem Struct ist einer der häufigsten Fehler in Go-Codebasen, und es ist wert, ihn explizit zu benennen. Machen Sie das nicht:

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

Machen Sie stattdessen das hier:

type UserService struct {
	db *sql.DB
}

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

Ein context gehört zu einer Anfrage, Operation oder Aufgabe, während ein Service-Struct in der Regel viel länger lebt als eine einzelne Anfrage. Das Vermischen dieser Lebensdauern macht den Abbruch unklar und es schwer, über nachzudenken, zu welcher Operation ein context gehört.

Es gibt seltene Ausnahmen für Typen, die wirklich eine einzelne Operations-Lebensdauer repräsentieren, aber sie sind selten genug, dass die Standardregel einfach sein sollte:

Übergeben Sie context. Speichern Sie ihn nicht.

Übergeben Sie keinen nil context

Übergeben Sie niemals nil als context.

Schlecht:

err := svc.DoWork(nil)

Verwenden Sie context.Background(), wenn kein bestehender context vorhanden ist:

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

In Tests, verwenden Sie den Test-context, wenn möglich:

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

Ein nil context kann zu einem Panic führen, wenn Code Methoden daran aufruft. Ein Background context ist explizit und sicher.

Background, TODO und Request Contexts

Es gibt drei übliche Ausgangspunkte.

context.Background

Verwenden Sie context.Background() an der obersten Ebene eines Programms, wenn kein Elterncontext existiert – er ist der Wurzelcontext, aus dem alle Kindcontexts abgeleitet werden:

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

oder:

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

context.TODO

Verwenden Sie context.TODO(), wenn Sie wissen, dass ein context verwendet werden sollte, aber noch nicht entschieden haben, welcher.

ctx := context.TODO()

Dies ist während der Migration nützlich, sollte aber nicht dauerhaft bleiben, wenn ein echter context existiert.

Request Context

In HTTP-Servern, verwenden Sie den Request-context:

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

Der Request-context wird abgebrochen, wenn die Client-Verbindung geschlossen wird, die Anfrage abgebrochen wird oder der Server die Verarbeitung der Anfrage abgeschlossen hat.

Für Webdienste ist dies in der Regel der context, den Sie an den Anwendungscode weitergeben sollten.

Abbruch mit context.WithCancel

Verwenden Sie context.WithCancel, wenn Sie Arbeit explizit stoppen möchten.

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

Die zurückgegebene cancel-Funktion bricht den Kind-context ab und gibt die damit verbundenen Ressourcen frei. Rufen Sie sie immer auf, wenn Sie fertig sind – selbst wenn der context letztendlich ausläuft, vermeidet ein frühzeitiger Aufruf von cancel, dass Ressourcen länger am Leben gehalten werden, als nötig.

Beispiel:

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

Das Muster ist einfach:

  • Ableiten Sie einen Kind-context.
  • Definiere cancel mit defer.
  • Übergeben Sie den Kind-context an Arbeit, die zusammen gestoppt werden sollte.
  • Beobachte ctx.Done().

Timeouts mit context.WithTimeout

Verwenden Sie context.WithTimeout, wenn eine Operation eine maximale Dauer hat.

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

Beispiel mit einem HTTP-Client:

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

Dies macht das Timeout Teil der Operation, nicht eine versteckte globale Einstellung.

Rufen Sie cancel immer auf

Wenn Sie WithCancel, WithTimeout oder WithDeadline aufrufen, rufen Sie immer die zurückgegebene cancel-Funktion auf – dies ist für die Korrektheit wichtig.

Gut:

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

Schlecht:

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

Das Versäumnis, cancel aufzurufen, kann dazu führen, dass Timer und Kindcontexts länger am Leben gehalten werden, als nötig.

Die Timer-Channels selbst sind auch Teil des Vertrags: Seit Go 1.27 sind die Channels hinter time.After und time.NewTimer immer un gepuffert, und die alte asynctimerchan-Opt-out-Option kompiliert nicht mehr mit ihrem vor-1.23-Wert. Die Go 1.27 Upgrade-Guide listet diese Einstellung neben den anderen entfernten GODEBUG-Werten auf, die den Build zum Scheitern bringen.

Fristen (Deadlines) vs. Timeouts

Ein Timeout ist relativ:

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

Eine Frist ist absolut:

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

Die meisten Anwendungscode verwenden Timeouts. Fristen sind nützlich, wenn eine Anfrage eine feste Endzeit hat, die über mehrere Operationen hinweg geteilt werden sollte – zum Beispiel: Wenn einer Anfrage 900 Millisekunden bleiben, geben Sie nicht jedem Downstream-Aufruf ein frisches 1-Sekunden-Timeout; propagieren Sie stattdessen das verbleibende Budget.

Timeout-Budgets über Service-Ebenen hinweg

Ein häufiger Fehler ist das blinde Stapeln von Timeouts.

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

Das sieht harmlos aus, versteckt aber das echte Budget. Die Service-Ebene sollte in der Regel die Frist des Aufrufer einhalten, anstatt den Timer auf denselben Wert zurückzusetzen.

Ein besseres Muster ist:

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 {
		// Fehler behandeln
		return
	}
}

Dann innerhalb des Services:

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

Fügen Sie ein Kind-Timeout nur hinzu, wenn eine Teiloperation ein kleineres Budget benötigt:

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

	return s.repo.Query(queryCtx)
}

Das richtige mentale Modell ist einfach: Die gesamte Anfrage hat ein äußeres Budget, bestimmte Teiloperationen können kleinere Budgets aus diesem Budget heraus haben, und keine Ebene verlängert die Anfrage stillschweigend über das hinaus, was der Aufrufer beabsichtigt hat.

Prüfen Sie ctx.Err(), um Abbruch von Timeout zu unterscheiden

Wenn ein context endet, gibt ctx.Err() den Grund zurück.

In der Regel ist es einer von:

context.Canceled
context.DeadlineExceeded

Beispiel:

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

Dies ermöglicht es Aufrufern, Abbruch von Timeout zu unterscheiden, und diese Unterscheidung ist in der Praxis wichtig. Ein abgebrochene Anfrage bedeutet oft, dass der Client die Verbindung getrennt hat, während ein Deadline-Exceeded-Fehler in der Regel bedeutet, dass Ihr Dienst zu langsam war – sie sollten nicht immer auf die gleiche Weise protokolliert, wiederholt oder gemeldet werden.

Verwenden Sie context.Cause für bessere Abbruchgründe

Modernes Go unterstützt auch verursachensbewussten Abbruch.

Die nützlichen Funktionen umfassen:

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

Einfaches ctx.Err() teilt Ihnen den groben Grund mit: abgebrochen oder Frist überschritten.

context.Cause(ctx) kann Ihnen die spezifischere Ursache mitteilen.

Beispiel:

var ErrShutdown = errors.New("server shutting down")

func Run(ctx context.Context) error {
	ctx, cancel := context.WithCancelCause(ctx)
	defer cancel(nil)

	go func() {
		// Ein Shutdown-Signal ist angekommen.
		cancel(ErrShutdown)
	}()

	<-ctx.Done()

	return context.Cause(ctx)
}

Verwenden Sie verursachensbewussten Abbruch, wenn der Grund für Aufrufer, Logs oder Aufräumverhalten wichtig ist, und vermeiden Sie ihn dort, wo einfaches ctx.Err() ausreicht – der zusätzliche Detailgrad lohnt sich nur, wenn die Diagnose ihn wirklich benötigt.

HTTP-Server-Beispiel

Ein normaler HTTP-Handler sollte von r.Context() starten. Für eine vollständige Anleitung zur Strukturierung von Go-HTTP-Diensten, siehe Bauen von REST-APIs in Go. Gin, Echo und Fiber kapseln context.Context jeweils in ihrem eigenen Request-Context-Typ, und die Unterschiede zwischen den Stacks werden verglichen in Go Web Frameworks in 2026: net/http, chi, Gin, Echo, Fiber im Vergleich.

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

Der Service sollte den context akzeptieren und weitergeben:

type UserService struct {
	repo *UserRepository
}

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

Das Repository sollte context-bewusste Datenbankmethoden verwenden:

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
}

Das Wichtige ist die Kette – jede Ebene gibt denselben context an die nächste weiter:

flowchart TD A[HTTP request context] --> B[Handler] B --> C[Service] C --> D[Repository] D --> E[Database query]

Brechen Sie die Kette nicht, indem Sie context.Background() in der Mitte erstellen.

Der context.Background()-Fehler: Brechen der Abbruchkette

Dies ist ein häufiger Bug:

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

Dies verwirft alle Abbruch- und Fristinformationen vom Aufrufer. Wenn der Client die Verbindung trennt, läuft die Datenbankabfrage weiter. Wenn die Anfrage ein Timeout hat, kann die Downstream-Arbeit möglicherweise noch in Fahrt sein. Wenn der Server herunterfährt, ignoriert dieser Code es vollständig. Ersetzen Sie den empfangenen context mit context.Background() innerhalb der Geschäftslogik fast immer falsch.

Verwenden Sie den context, der Ihnen gegeben wurde:

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

Verwenden Sie context.Background() nur am Rand, wo kein Elterncontext existiert.

HTTP-Client-Beispiel

Für ausgehende HTTP-Anfragen, hängen Sie den context an die Anfrage.

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

Machen Sie das nicht:

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

Das erstellt eine Anfrage ohne den Operations-context.

Vermeiden Sie auch, sich nur auf http.Client.Timeout zu verlassen. Es kann als Sicherheitslimit nützlich sein, aber Request-contexts bieten Ihnen eine bessere Propagation über die Aufrufkette hinweg.

Ein häufiges Muster ist:

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

Verwenden Sie dies, wenn der Downstream-API-Aufruf ein spezifisches Budget innerhalb einer größeren Anfrage hat.

Datenbank-Beispiel

Die meisten Go-Datenbank-APIs haben context-bewusste Methoden. Für einen breiteren Blick darauf, wie Go-Datenzugriffsbibliotheken context behandeln – einschließlich GORM, Ent, Bun und sqlc – siehe Vergleich von Go ORMs für PostgreSQL.

Verwenden Sie sie.

Gut:

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

Gut:

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

Gut:

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

Schlecht:

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

Die context-bewussten Formen ermöglichen es Datenbankoperationen, aufzuhören, wenn die Anfrage abgebrochen wird oder ausläuft, was besonders wichtig ist für langsame Abfragen, überlastete Datenbanken und nutzerseitige APIs, bei denen die Latenz direkt die Benutzererfahrung beeinflusst.

Transaktionen und context

Transaktionen benötigen sorgfältige context-Verwaltung.

Eine Transaktion sollte in der Regel mit dem Operations-context beginnen:

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

Verwenden Sie dann denselben context für Transaktionsoperationen:

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

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

Seien Sie vorsichtig mit Timeouts rund um Transaktionen. Wenn der context vor Commit abgebrochen wird, kann die Transaktion zurückgerollt werden. Das mag das sein, was Sie wollen, aber es sollte absichtlich sein.

Für lange Transaktionen ist die bessere Antwort in der Regel kein längeres Timeout – es ist eine kürzere Transaktion, die weniger Arbeit pro Einheit erledigt.

Hintergrundworker und context

Hintergrundworker sollten einen context erhalten, der ihre Lebensdauer repräsentiert.

Beispiel:

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

Dieser Worker stoppt sauber, wenn der context abgebrochen wird, und sein Ticker wird ordnungsgemäß über defer ticker.Stop() aufgeräumt. In main würden Sie einen Wurzelcontext erstellen, der an OS-Signale gebunden ist:

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

Dies ist der context, der korrekt verwendet wird: Er beschreibt die Lebensdauer der Prozessarbeit, und wenn das OS ein Signal sendet, wird der gesamte Baum von Goroutines, die diesen context teilen, zusammen gestoppt.

Verhindern von Goroutine-Leaks mit context-Abbruch

Ein Goroutine-Leak tritt auf, wenn eine Goroutine nach dem Zeitpunkt, an dem sie nicht mehr nützlich ist, weiterhin blockiert bleibt.

Context hilft, dies zu verhindern.

Schlecht:

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

Diese Goroutine hat keinen Shutdown-Pfad.

Besser:

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

Jede Goroutine, die schleift, sollte fast immer einen Abbruchpfad haben.

Das bedeutet nicht, dass jede Goroutine direkt context erhalten muss, aber das System sollte einen klaren Weg haben, sie zu stoppen.

context.AfterFunc

context.AfterFunc führt eine Funktion aus, nachdem ein context abgebrochen wurde.

Es kann nützlich sein für Aufräumen, das Enblocken von Operationen oder das Brücken von APIs, die context nicht nativ unterstützen.

Beispiel:

func waitWithContext(ctx context.Context, ch <-chan struct{}) error {
	stop := context.AfterFunc(ctx, func() {
		// Wecken oder aufräumen, wenn nötig.
	})
	defer stop()

	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-ch:
		return nil
	}
}

Verwenden Sie AfterFunc vorsichtig – es startet Logik, wenn der Abbruch erfolgt, was den Kontrollfluss schwerer nachvollziehbar machen kann. Für die meisten Anwendungscode ist ein normales select auf ctx.Done() klarer und leichter zu verstehen. AfterFunc ist am wertvollsten, wenn Sie context-Abbruch an eine API anpassen müssen, die context nicht bereits akzeptiert.

context.WithoutCancel

context.WithoutCancel erstellt einen context, der nicht abgebrochen wird, wenn der Elterncontext abgebrochen wird.

Dies ist nützlich, aber auch leicht zu missbrauchen.

Beispiel Anwendungsfall:

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

		// Anfrage verarbeiten...
		_ = ctx

		auditCtx := context.WithoutCancel(ctx)

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

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

Die Idee ist, dass der Audit-Write möglicherweise kurz fortsetzen muss, nachdem der Request-context abgebrochen wurde. Dies sollte selten und absichtlich sein – verwenden Sie WithoutCancel nicht als Möglichkeit, sich dem Umgang mit Abbruch zu entziehen. Verwenden Sie es nur, wenn die Kindarbeit wirklich das Eltern-Abbruch überleben muss, und fügen Sie immer ein neues Timeout hinzu: Ein context, der Abbruch ignoriert, aber keine Frist trägt, kann leicht Hintergrundgoroutine-Leaks verursachen.

Context-Werte richtig gemacht

Context-Werte sind für an die Anfrage gebundene Daten, die API-Grenzen überschreiten.

Gute Beispiele:

  • Request-ID
  • Trace-ID
  • authentisierter Benutzer-ID
  • Tenant-ID
  • Locale
  • Security Principal
  • Korrelations-Metadaten

Schlechte Beispiele:

  • Datenbankverbindung
  • Logger als versteckte Abhängigkeit
  • Feature Flags für den normalen Kontrollfluss
  • optionale Funktionsparameter
  • Konfiguration
  • Service-Clients

Eine nützliche Regel: Wenn der Wert Teil der Identität oder des Observability-Kontexts der Anfrage ist, kann er in context gehören. Wenn es eine Abhängigkeit ist, die Ihr Code für seine Arbeit benötigt, übergeben Sie sie explizit.

Verwenden Sie typisierte Schlüssel für context-Werte

Verwenden Sie keine einfachen Strings als context-Schlüssel.

Schlecht:

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

Dies kann mit anderen Paketen kollidieren.

Verwenden Sie einen nicht exportierten benutzerdefinierten Schlüsseltyp:

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
}

Dieses Muster gibt Ihnen Typsicherheit an der Paketgrenze, vermeidet Schlüsselkollisionen mit anderen Paketen und hält die context-API-Oberfläche mit typisierten Accessor-Funktionen sauber.

Verwenden Sie keine context-Werte für optionale Parameter

Das ist schlecht:

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

Dies versteckt den Funktionsvertrag.

Bevorzugen Sie explizite Parameter:

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

Context-Werte sollten Funktionsargumente nicht ersetzen. Versteckter Input macht Code schwieriger zu verstehen, zu testen und zu revidieren – und jeder, der die Funktionsunterschrift liest, hat keine Ahnung, dass der Parameter überhaupt existiert.

Logging und context

Es gibt zwei übliche Ansätze zum Logging mit context. Die Beispiele hier verwenden Go’s log/slog-Paket – für einen tieferen Einblick in strukturiertes Logging mit slog in Produktionsdiensten, siehe Strukturiertes Logging in Go mit slog.

Ansatz 1: Werte extrahieren und an Logs anhängen

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

Dies hält den Logger explizit als geeignete Abhängigkeit und verwendet context nur für an die Anfrage gebundene Werte, die legitimerweise API-Grenzen überschreiten müssen.

Ansatz 2: Logger in context speichern

Einige Codebasen speichern einen Logger in context.

Das kann bequem sein, aber ich empfehle es nicht als Standard. Es verwandelt context in einen Abhängigkeitscontainer.

Meine Präferenz:

  • Logger-Abhängigkeiten explizit übergeben.
  • Trace-IDs und Request-IDs in context speichern.
  • Diese Werte an Logs an Grenzen oder im Middleware hinzufügen.

Dies hält Abhängigkeiten sichtbar.

Context und Tracing

Tracing ist einer der stärksten Anwendungsfälle für context-Werte und ist ein wirklich guter Match. OpenTelemetry und ähnliche Systeme verwenden context, um Trace-Spans über Funktionsaufrufe und Prozessgrenzen hinweg zu propagieren, weil Trace-Daten genau die Art von an die Anfrage gebundenen Metadaten sind, die context designed wurde zu tragen.

Ein typisches Muster sieht so aus:

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

	return s.repo.Query(ctx)
}

Der context trägt den aktiven Trace-Span, und das Repository kann daraus einen Kindspan erstellen. Jede Ebene fügt ihren eigenen Span hinzu, ohne explizites Übergeben von Tracer-Objekten – der context erledigt diese Arbeit transparent über den gesamten Aufrufbaum hinweg.

Fehlerbehandlung mit context

Wenn eine Operation wegen context-Abbruchs stoppt, erhalten Sie diese Information. Die Muster hier ergänzen die breiteren Fehlerdesignstrategien, die in Go Fehlerbehandlungs-Architektur abgedeckt werden.

Beispiel:

err := svc.DoWork(ctx)
if err != nil {
	if errors.Is(err, context.Canceled) {
		// Client hat abgebrochen oder Aufrufer hat die Arbeit gestoppt.
		return err
	}

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

	return err
}

Verpacken Sie context-Fehler nicht blind, so dass sie versteckt werden.

Wrapping mit %w erhält errors.Is, sodass Aufrufer weiterhin Abbruch oder Timeout erkennen können:

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

Das vollständige Ersetzen des Fehlers verwirft diese Information und bricht jeden Aufrufer, der auf spezifische context-Fehlertypen prüft:

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

Abbilden von context-Fehlern auf HTTP-Antworten

Context-Fehler werden oft auf verschiedene HTTP-Ergebnisse abgebildet.

Beispiel:

func writeError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, context.Canceled):
		// Der Client ist wahrscheinlich gegangen.
		// Einige Systeme protokollieren dies als "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
	}
}

Behandeln Sie Client-Abbruch nicht als Anwendungsfehler – wenn der Benutzer den Browser-Tab geschlossen hat, ist das kein Fehlverhalten Ihres Dienstes, und es als Fehler zu protokollieren fügt Rauschen hinzu, ohne Signal.

Context in Middleware

HTTP-Middleware ist ein üblicher Ort, um an die Anfrage gebundene Werte hinzuzufügen.

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

Dies ist eine gute Verwendung von context. Die Request-ID gehört zur Anfrage, sie sollte durch die vollständige Aufrufkette reisen, und das Anhängen an Logs und Traces in jeder Ebene ist genau die Art von übergreifendem Observability-Besorgnis, das context-Werte unterstützen sollen.

Context in Tests

In Tests, vermeiden Sie, context.Background() blind zu verwenden.

Bevorzugen Sie t.Context(), wenn die Arbeit zur Testlebensdauer gehört:

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

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

Für Timeout-Verhalten, testen Sie mit einem echten Timeout nur, wenn das Timeout klein und sinnvoll ist.

Für konkurrenten und zeitabhängigen Code, erwägen Sie die Verwendung von testing/synctest – Testen von konkurrentem Go-Code mit synctest deckt dieses Tool im Detail ab:

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

Dies ermöglicht es Ihnen, echte Timeout-Werte zu testen, ohne auf echte Zeit zu warten.

Context und errgroup

Für Gruppen von Goroutines, die zusammen abgebrochen werden sollten, ist errgroup oft ein guter Fit.

errgroup koordiniert Abbruch, begrenzt aber nichts von selbst: Ohne ein Limit startet eine Schleife mit tausend Elementen tausend Goroutines. Für die Kapazitätsseite – SetLimit, Semaphore-Channels, Worker-Pools und die Leckform, die Wait überlebt – siehe Begrenzung von Konkurrenz in Go.

Beispiel:

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

Wenn eine Goroutine einen Fehler zurückgibt, wird der Group-context abgebrochen und andere Goroutines, die ctx.Done() respektieren, können frühzeitig stoppen. Das ist viel sauberer als das manuelle Management mehrerer Goroutines, Channels und Abbruchpfade. Der Schlüsselbegriff hier ist “den context respektieren” – errgroup kann Arbeit nicht stoppen, die ctx.Done() ignoriert.

Graceful Shutdown

Context ist zentral für den Graceful Shutdown.

Ein typisches Server-Setup hat:

  • einen Wurzelcontext, der durch OS-Signale abgebrochen wird
  • einen HTTP-Server
  • Hintergrundworker
  • ein Shutdown-Timeout
  • Aufräumlogik

Beispiel:

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

Beachten Sie, dass der Shutdown-context nicht derselbe wie der Wurzelcontext ist – der Wurzel ist bereits abgebrochen, wenn das OS-Signal eintrifft. Ein separater Timeout-context gibt dem Shutdown-Prozess eine begrenzte Zeit, um eingehende Anfragen zu leeren, bevor er zwangsweise beendet wird, was die subtile, aber wichtige Unterscheidung ist, die den Graceful Shutdown tatsächlich funktionieren lässt.

Häufige Anti-Muster

Anti-Muster 1: Context als Abhängigkeitscontainer verwenden

Schlecht:

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

Übergeben Sie Abhängigkeiten explizit.

Anti-Muster 2: context.Background innerhalb der Geschäftslogik erstellen

Schlecht:

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

Das bricht die Abbruchpropagation.

Anti-Muster 3: cancel vergessen

Schlecht:

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

Gut:

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

Anti-Muster 4: Optionale Parameter in context legen

Schlecht:

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

Verwenden Sie explizite Options-Strukt.

Anti-Muster 5: Context zu tief in reinen Code übergeben

Schlecht:

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

Reine Berechnung benötigt keinen context, es sei denn, sie ist langlaufend oder abrebbbar.

Anti-Muster 6: Abbruch in Schleifen ignorieren

Schlecht:

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

Besser:

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

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

Anti-Muster 7: Context-Fehler schlucken

Schlecht:

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

Gut:

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

Erhalten Sie Abbruch- und Deadline-Fehler.

Eine praktische context-Checkliste

Verwenden Sie diese Checkliste für Go-Backend-Code.

Funktionsunterschriften

  • Context ist der erste Parameter.
  • Context ist nicht in langlebigen Structs gespeichert.
  • Context wird nicht an reine Hilfsfunktionen übergeben, es sei denn, es wird benötigt.
  • Nil context wird niemals verwendet.

Abbruch

  • Langlaufende Schleifen prüfen ctx.Done().
  • Goroutines haben einen Shutdown-Pfad.
  • Worker-Lebensdauern sind an einen Elterncontext gebunden.
  • Context-Abbruch wird an Downstream-Aufrufe propagiert.

Timeouts

  • Äußere Request-Timeouts werden an der Grenze gesetzt.
  • Sub-Operation-Timeouts sind kleiner als das äußere Budget.
  • Cancel-Funktionen werden immer aufgerufen.
  • Timeouts werden nicht blind in jeder Ebene gestapelt.

Werte

  • Context-Werte sind an die Anfrage gebunden.
  • Schlüssel verwenden benutzerdefinierte Typen, keine einfachen Strings.
  • Abhängigkeiten sind nicht in context gespeichert.
  • Optionale Parameter sind nicht in context gespeichert.

Fehler

  • context.Canceled und context.DeadlineExceeded werden erhalten.
  • Context-Fehler werden korrekt an API-Grenzen abgebildet.
  • Verursachensbewusster Abbruch wird nur verwendet, wenn der Grund wichtig ist.

Tests

  • Tests verwenden t.Context(), wo angemessen.
  • Timeout-Tests vermeiden langsame echte Sleeps.
  • Konkurrentes Timeout-Verhalten wird mit testing/synctest getestet, wenn nützlich.
  • Goroutine-Lecks werden überprüft, indem sichergestellt wird, dass Shutdown-Pfade existieren.

Wie Sie context-Nutzung in einer Go-Codebase auditieren

Suchen Sie nach diesen Mustern:

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

Dann fragen Sie:

  • Wird context.Background() nur an Top-Level-Grenzen verwendet?
  • Werden Cancel-Funktionen immer aufgerufen?
  • Sind Timeouts an sinnvollen Grenzen platziert?
  • Sind Context-Werte wirklich an die Anfrage gebunden?
  • Sind Abhängigkeiten in Context-Werten versteckt?
  • Sind Goroutines stoppbar?
  • Sind Context-Fehler erhalten?

Das ist eine gute Code-Review-Gewohnheit, weil viele context-Bugs keine Syntax-Bugs sind – sie sind Lebensdauern-Bugs, die nur unter Abbruch, Last oder Shutdown-Bedingungen auftauchen.

Meine meinungsstarken Regeln

Diese Regeln sind langweilig, aber sie funktionieren.

Regel 1: Context ist Kontrollfluss

Verwenden Sie context, um Abbruch, Fristen und Request-Metadaten zu steuern.

Verwenden Sie es nicht, um Abhängigkeiten zu schmuggeln.

Regel 2: Der Aufrufer besitzt das Budget

Eine Funktion sollte in der Regel den empfangenen context respektieren.

Erstellen Sie nur ein kürzeres Kind-Timeout, wenn die Teiloperation ein spezifisches kleineres Budget benötigt.

Regel 3: Background gehört an den Rand

Verwenden Sie context.Background() in main, Tests und Top-Level-Setup.

Verwenden Sie es nicht innerhalb von Service- und Repository-Methoden, um sich dem Abbruch zu entziehen.

Regel 4: Werte sollten langweilig sein

Request-ID, Trace-ID, Benutzer-ID und Tenant-ID gehören in context. Datenbankverbindungen, Logger, Konfigurationsstrukt und Service-Clients nicht – sie sind Abhängigkeiten und sollten explizit übergeben werden.

Regel 5: Jede Goroutine benötigt eine Lebensdauer

Wenn eine Goroutine startet, sollten Sie genau wissen, wie sie stoppt. Context ist oft die richtige Antwort, und wenn es nicht context ist, sollte es einen anderen klaren Mechanismus geben – einen Channel, eine Sync-Primitive oder ein explizites Signal.

Abschließende Gedanken

context.Context ist nicht kompliziert, weil die API groß ist – die API ist klein. Sie ist kompliziert, weil sie Lebensdauer repräsentiert, und Lebensdauer ist Architektur. Jede Entscheidung darüber, wo context fließt, wo er abgeleitet wird und wo er stoppt, ist eine Entscheidung darüber, wie Ihr Dienst Fehler, Last und Shutdown behandelt.

Ein gut verwendeter context macht Go-Dienste leichter abbrechbar, leichter herunterzufahren, leichter zu beobachten und weniger wahrscheinlich Goroutine-Leaks zu verursachen. Ein schlecht verwendeter context versteckt Abhängigkeiten, verwirft Fristen und macht Code schwieriger zu verstehen, wenn er unter Druck steht.

Das praktische Fazit ist einfach:

Geben Sie context weiter.
Speichern Sie ihn nicht.
Ersetzen Sie keine expliziten Parameter durch Werte.
Respektieren Sie Abbruch.
Verwenden Sie Timeouts an Grenzen.
Rufen Sie cancel immer auf.

Das ist Go context richtig gemacht.

Dieser Artikel ist Teil des App Architecture in Production-Clusters, das Code-Struktur, Datenzugriff, Integrationsmuster und Testarchitektur für Produktions-Go- und Python-Systeme abdeckt.

Quellen

Abonnieren

Neue Beiträge zu Systemen, Infrastruktur und KI-Engineering.