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

Der Go-Kontext dient dem Kontrollfluss, nicht der Datenspeicherung.

Inhaltsverzeichnis

Go’s context.Context ist einfach genug, um schlecht verwendet zu werden – und genau das ist das Problem.

Die meisten Go-Entwickler lernen die Oberflächenregeln schnell: context als erstes Argument ü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 nicht nur eine Parameterkonvention – er ist die Steuerungsfläche für die Lebensdauer einer Anfrage.

Go context: Abbruch breitet sich durch die Aufrufkette aus

Context teilt der Arbeit mit, wann sie stoppen soll, wie viel Zeit noch übrig ist, welcher Abbruchpfad gewählt wurde und welche anfragebezogenen Werte über API-Grenzen hinweg transportiert werden müssen. Bei guter Nutzung verhindert er Goroutine-Lecks, vermeidet verschwendete Arbeit, propagiert Deadlines und macht Dienste einfacher herunterzufahren. Bei schlechter Nutzung wird er zu einem Sack versteckter Abhängigkeiten, gefälschter Globaler, vergessener Timeouts, undenierter Timer und verwirrendem Abbruchverhalten.

Die etwas opinionierte Version lautet so: Verwenden Sie context für Abbruch, Deadlines und anfragebezogene Metadaten, und verwenden Sie ihn nicht als Abhängigkeitscontainer.

Wofür context gedacht ist

Das context-Paket hat drei Hauptaufgaben – Abbruch, Deadlines und Timeouts sowie anfragebezogene Werte – und diese drei Aufgaben decken alles ab, wofür es entwickelt wurde.

Ein context sollte Fragen beantworten wie:

Wurde diese Anfrage abgebrochen?
Wie viel Zeit hat diese Operation noch?
Welche Request-ID sollte den Logs angehängt werden?
Welcher authentifizierter Benutzer ist mit dieser Anfrage verbunden?

Ein context sollte nicht Fragen beantworten wie:

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, wie man das sauber macht). Context ist für Anfrage-Lebensdauer und Anfrage-Metadaten da, nicht für die Anwendungswiring.

Die grundlegende Form von context

Die Kernschnittstelle 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 Deadline abläuft.
  • Err() erklärt, warum der context beendet wurde.
  • Deadline() teilt Ihnen mit, ob der context eine Deadline hat.
  • Value() speichert anfragebezogene Daten.

Die meisten Codes implementieren diese Schnittstelle nicht. Er empfängt einen context und gibt ihn weiter.

Die erste Regel: context explizit übergeben

Für Funktionen, die anfragebezogene oder abbrechbare Arbeit ausführen, ü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) {
	// ...
}

Tun Sie dies für Funktionen, die Folgendes tun können:

  • Eine Datenbank aufrufen
  • Einen anderen Service aufrufen
  • Auf einer Warteschlange warten
  • Hintergrundarbeit starten
  • Auf E/A blockieren
  • Ein Timeout verwenden
  • Anfragebezogene Werte benötigen
  • Abbruch benötigen

Fügen Sie keinen context zu winzigen 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 laut.

Speichern Sie keinen context in structs

Das Speichern eines contexts in einem struct ist einer der häufigsten Fehler in Go-Codebasen, und es lohnt sich, das explizit hervorzuheben. Tun Sie das nicht:

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

Tun Sie stattdessen dies:

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 normalerweise viel länger lebt als jede einzelne Anfrage. Das Mischen dieser Lebensdauern macht den Abbruch unklar und erschwert das Nachvollziehen, zu welcher Operation ein context gehört.

Es gibt seltene Ausnahmen für Typen, die genuinely die Lebensdauer einer einzelnen Operation repräsentieren, aber sie sind selten genug, dass die Standardregel einfach sein sollte:

Übergabe von context. Speichern Sie ihn nicht.

Übergeben Sie niemals 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 nach Möglichkeit den Test-Context:

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

Ein nil-context kann einen Panic auslösen, wenn Code Methoden darauf aufruft. Ein Background-Context ist explizit und sicher.

Background, TODO und Request-Contexts

Es gibt drei gängige Startpunkte.

context.Background

Verwenden Sie context.Background() auf der Top-Level-Ebene eines Programms, wenn kein Parent-Context existiert – es ist der Root-Context, aus dem alle Child-Contexts 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 Migrationen nützlich, sollte aber nicht dauerhaft sein, 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 Bearbeitung der Anfrage beendet.

Für Webdienste ist dies normalerweise 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 Child-Context ab und gibt die damit verbundenen Ressourcen frei. Rufen Sie sie immer auf, wenn Sie fertig sind – auch wenn der context schließlich abläuft, vermeidet das vorzeitige Aufrufen von cancel, dass Ressourcen länger als nötig am Leben bleiben.

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:

  • Einen Child-Context ableiten.
  • Cancel deferieren.
  • Den Child-Context an Arbeit weitergeben, die gemeinsam stoppen soll.
  • ctx.Done() beobachten.

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.

Immer cancel aufrufen

Wenn Sie WithCancel, WithTimeout oder WithDeadline aufrufen, rufen Sie immer die zurückgegebene cancel-Funktion auf – das 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 Unterlassen des Aufrufs von cancel kann dazu führen, dass Timer und Child-Contexts länger als nötig am Leben bleiben.

Deadlines vs. Timeouts

Ein Timeout ist relativ:

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

Eine Deadline ist absolut:

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

Die meisten Anwendungscodes verwenden Timeouts. Deadlines sind nützlich, wenn eine Anfrage eine feste Endzeit hat, die über mehrere Operationen hinweg geteilt werden soll – zum Beispiel: Wenn eine Anfrage noch 900 Millisekunden hat, geben Sie jedem downstream-Aufruf kein frisches 1-Sekunden-Timeout; propagieren Sie stattdessen das verbleibende Budget.

Timeout-Budgets über Service-Schichten 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-Schicht sollte normalerweise die Deadline des Aufrufers respektieren, 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 {
		// handle error
		return
	}
}

Dann innerhalb des Services:

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

Fügen Sie ein Child-Timeout nur hinzu, wenn eine Sub-Operation 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 unkompliziert: Die gesamte Anfrage hat ein äußeres Budget, spezifische Sub-Operationen können kleinere Budgets aus diesem Budget herausgeschnitten haben, und keine Schicht erweitert 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.

Normalerweise 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 Aufrubern, Abbruch von Timeout zu unterscheiden, und diese Unterscheidung ist in der Praxis wichtig. Eine abgebrochene Anfrage bedeutet oft, dass der Client die Verbindung getrennt hat, während ein Deadline-Exceeded-Fehler normalerweise bedeutet, dass Ihr Service zu langsam war – sie sollten nicht immer gleich geloggt, erneut versucht oder gemeldet werden.

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

Modernes Go unterstützt auch cause-bewussten Abbruch.

Die nützlichen Funktionen umfassen:

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

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

context.Cause(ctx) kann Ihnen den spezifischeren Grund nennen.

Beispiel:

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

Verwenden Sie cause-bewussten Abbruch, wenn der Grund für Aufrufer, Logs oder Bereinigungsverhalten wichtig ist, und vermeiden Sie ihn, wenn ein einfaches ctx.Err() ausreicht – die zusätzlichen Details lohnen sich nur, wenn die Diagnose sie wirklich erfordert.

HTTP-Server-Beispiel

Ein normaler HTTP-Handler sollte von r.Context() ausgehen. Für eine vollständige Durchstrukturierung von Go-HTTP-Diensten siehe Building REST APIs in 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)
	}
}

Der Service sollte den context akzeptieren und propagieren:

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 Wichtigste ist die Kette – jede Schicht 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: Durchbrechen 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 Deadline-Informationen des Aufrufers. Wenn der Client die Verbindung trennt, läuft die Datenbankabfrage weiter. Wenn die Anfrage abläuft, kann die downstream-Arbeit noch im Gange sein. Wenn der Server heruntergefahren wird, ignoriert dieser Code dies vollständig. Das Ersetzen des empfangenen contexts mit context.Background() innerhalb der Geschäftslogik ist fast immer falsch.

Verwenden Sie den context, den Sie erhalten haben:

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

Verwenden Sie context.Background() nur an der Kante, wo kein Parent-Context existiert.

HTTP-Client-Beispiel

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

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

Tun 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 geben 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.

Datenbankbeispiel

Die meisten Go-Datenbank-APIs haben context-bewusste Methoden. Für einen breiteren Blick darauf, wie Go-Datenzugriffsbibliotheken context handhaben – einschließlich GORM, Ent, Bun und sqlc – siehe Comparing Go ORMs for 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, zu stoppen, wenn die Anfrage abgebrochen wird oder abläuft, was besonders wichtig für langsame Abfragen, überlastete Datenbanken und benutzerausgerichtete APIs ist, bei denen Latenz die Benutzererfahrung direkt beeinflusst.

Transaktionen und context

Transaktionen benötigen sorgfältige context-Handhabung.

Eine Transaktion sollte normalerweise 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 um Transaktionen herum. Wenn der context vor Commit abgebrochen wird, kann die Transaktion zurückgesetzt werden. Das kann sein, was Sie wollen, aber es sollte beabsichtigt sein.

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

Hintergrundarbeiter und context

Hintergrundarbeiter 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 Arbeiter stoppt sauber, wenn der context abgebrochen wird, und sein Ticker wird über defer ticker.Stop() ordnungsgemäß bereinigt. In main würden Sie einen Root-Context 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)
	}
}

Das ist context richtig verwendet: Er beschreibt die Lebensdauer der Prozessarbeit, und wenn das OS ein Signal sendet, stoppt der gesamte Baum von Goroutinen, die diesen context teilen, gemeinsam.

Verhindern von Goroutine-Lecks mit context-Abbruch

Ein Goroutine-Leck tritt auf, wenn eine Goroutine für immer blockiert bleibt, nachdem sie nicht mehr nützlich ist.

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 in einer Schleife läuft, sollte fast immer einen Abbruchpfad haben.

Das bedeutet nicht, dass jede Goroutine context direkt 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 Bereinigung, Entblockierung 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() {
		// Wake up or clean up if needed.
	})
	defer stop()

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

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

context.WithoutCancel

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

Das ist nützlich, aber es ist auch leicht zu missbrauchen.

Beispielnutzungsfall:

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

Die Idee ist, dass das Audit-Write möglicherweise weiterlaufen muss, kurz nachdem der Request-Context abgebrochen wurde. Dies sollte selten und gezielt sein – verwenden Sie WithoutCancel nicht als Weg, um Abbruch zu vermeiden. Verwenden Sie es nur, wenn die Child-Arbeit genuinely die Parent-Abbruch-Überlebensfähigkeit haben muss, und fügen Sie immer ein neues Timeout hinzu: Ein context, der Abbruch ignoriert, aber keine Deadline trägt, kann leicht Hintergrund-Goroutine-Lecks erstellen.

Context-Werte richtig gemacht

Context-Werte sind für anfragebezogene Daten, die API-Grenzen überschreiten.

Gute Beispiele:

  • Request-ID
  • Trace-ID
  • Authentifizierungs-Benutzer-ID
  • Tenant-ID
  • Locale
  • Sicherheitsprincipal
  • Korrelationsmetadaten

Schlechte Beispiele:

  • Datenbankverbindung
  • Logger als versteckte Abhängigkeit
  • Feature-Flags für gewöhnliche Kontrollfluss
  • Optionale Funktionsparameter
  • Konfiguration
  • Service-Clients

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

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

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

Schlecht:

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

Das 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 sauber mit typisierten Zugriffsfunktionen.

Verwenden Sie context-Werte nicht für optionale Parameter

Das ist schlecht:

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

Das versteckt den Funktionsvertrag.

Bevorzugen Sie explizite Parameter:

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

Context-Werte sollten Funktionsargumente nicht ersetzen. Versteckte Eingaben machen Code schwerer zu verstehen, zu testen und zu überprüfen – und jeder, der die FunktionsSignatur liest, wird keine Ahnung haben, dass der Parameter überhaupt existiert.

Logging und context

Es gibt zwei gängige Ansätze zum Logging mit context. Die Beispiele hier verwenden Go’s log/slog-Paket – für einen tieferen Einstieg in strukturiertes Logging mit slog in Produktionsdiensten siehe Structured Logging in Go with 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 richtige Abhängigkeit und verwendet context nur für anfragebezogene Werte, die legitim API-Grenzen überschreiten müssen.

Ansatz 2: Logger im context speichern

Einige Codebasen speichern einen Logger im context.

Das kann praktisch 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 im context speichern.
  • Diese Werte an Logs an Grenzen oder Middleware hinzufügen.

Dies hält Abhängigkeiten sichtbar.

Context und Tracing

Tracing ist einer der stärkten Use Cases für context-Werte, und es ist eine genuinely gute Passform. OpenTelemetry und ähnliche Systeme verwenden context, um Trace-Spans über Funktionsaufrufe und Prozessgrenzen hinweg zu propagieren, weil Trace-Daten genau die Art von anfragebezogenen Metadaten sind, die context tragen sollte.

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 einen Child-Span daraus erstellen. Jede Schicht 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-Abbruch stoppt, bewahren Sie diese Information auf. Die Muster hier ergänzen die breiteren Fehlerdesignstrategien, die in Go Error Handling Architecture behandelt werden.

Beispiel:

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
}

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

Wrapping mit %w bewahrt errors.Is, sodass Aufrufer immer noch Abbruch oder Timeout erkennen können:

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

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

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

Mapping von context-Errors auf HTTP-Antworten

Context-Errors mappen oft auf verschiedene HTTP-Ergebnisse.

Beispiel:

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

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

Context in Middleware

HTTP-Middleware ist ein häufiger Ort, um anfragebezogene 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))
	})
}

Das 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 Schicht ist genau die Art von querliegenden Observability-Anliegen, die context-Werte unterstützen sollen.

Context in Tests

Vermeiden Sie in Tests die blinde Verwendung von context.Background().

Bevorzugen Sie t.Context(), wenn die Arbeit zur Test-Lebensdauer 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 koncurrenten und zeitabhängigen Code betrachten Sie testing/synctestTesting Concurrent Go Code with 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 Goroutinen, die gemeinsam abgebrochen werden sollen, ist errgroup oft eine gute Passform.

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 Error zurückgibt, wird der Group-Context abgebrochen und andere Goroutinen, die ctx.Done() respektieren, können frühzeitig stoppen. Das ist viel sauberer als das manuelle Verwalten mehrerer Goroutinen, Channels und Abbruchpfade. Der Schlüsselbegriff hier ist “respektiere den context” – errgroup kann Arbeit nicht stoppen, die ctx.Done() ignoriert.

Graceful Shutdown

Context ist zentral für graceful Shutdown.

Ein typisches Server-Setup hat:

  • einen Root-Context, der durch OS-Signale abgebrochen wird
  • einen HTTP-Server
  • Hintergrundarbeiter
  • ein Shutdown-Timeout
  • Bereinigungslogik

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 ist wie der Root-Context – der Root ist bereits abgebrochen, wenn das OS-Signal eintrifft. Ein separates Timeout-Context gibt dem Shutdown-Prozess eine begrenzte Zeit, um in-flight-Anfragen zu drainen, bevor er force-quits, was die subtile, aber wichtige Unterscheidung ist, die graceful Shutdown tatsächlich funktionieren lässt.

Häufige Anti-Patterns

Anti-Pattern 1: Verwenden von context als Abhängigkeitscontainer

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-Pattern 2: Erstellen von context.Background innerhalb der Geschäftslogik

Schlecht:

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

Das bricht die Abbruchpropagation.

Anti-Pattern 3: Vergessen von cancel

Schlecht:

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

Gut:

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

Anti-Pattern 4: Legen von optionalen Parametern in context

Schlecht:

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

Verwenden Sie explizite Options-Structs.

Anti-Pattern 5: Übergabe von context zu tief in reinen Code

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 abbrechbar.

Anti-Pattern 6: Ignorieren von Abbruch in Schleifen

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-Pattern 7: Verschlucken von context-Errors

Schlecht:

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

Gut:

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

Bewahren Sie Abbruch- und Deadline-Errors auf.

Eine praktische context-Checkliste

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

Funktions Signaturen

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

Abbruch

  • Langlaufende Schleifen prüfen ctx.Done().
  • Goroutinen haben einen Shutdown-Pfad.
  • Worker-Lebensdauern sind an einen Parent-Context 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 Schicht gestapelt.

Werte

  • Context-Werte sind anfragebezogen.
  • Schlüssel verwenden benutzerdefinierte Typen, keine reinen Strings.
  • Abhängigkeiten sind nicht im context gespeichert.
  • Optionale Parameter sind nicht im context gespeichert.

Errors

  • context.Canceled und context.DeadlineExceeded werden bewahrt.
  • Context-Errors werden korrekt an API-Grenzen gemappt.
  • Cause-bewusster Abbruch wird nur verwendet, wenn der Grund wichtig ist.

Tests

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

So auditieren Sie context-Nutzung in einer Go-Codebase

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" .

Und fragen Sie dann:

  • Wird context.Background() nur an Top-Level-Grenzen verwendet?
  • Werden cancel-Funktionen immer aufgerufen?
  • Werden Timeouts an sinnvollen Grenzen platziert?
  • Sind context-Werte wirklich anfragebezogen?
  • Sind Abhängigkeiten in context-Werten versteckt?
  • Sind Goroutinen stoppbar?
  • Werden context-Errors bewahrt?

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

Meine opinionierten Regeln

Diese Regeln sind langweilig, aber sie funktionieren.

Regel 1: Context ist Kontrollfluss

Verwenden Sie context, um Abbruch, Deadlines und Anfrage-Metadaten zu steuern.

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

Regel 2: Der Aufrufer besitzt das Budget

Eine Funktion sollte normalerweise den context respektieren, den sie erhält.

Erstellen Sie nur ein kürzeres Child-Timeout, wenn die Sub-Operation ein spezifisches kleineres Budget benötigt.

Regel 3: Background gehört an die Kante

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

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

Regel 4: Werte sollten langweilig sein

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

Regel 5: Jede Goroutine braucht 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 – ein Channel, ein Sync-Primitiv 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 Service Fehler, Last und Shutdown handhabt.

Ein gut genutzter context macht Go-Dienste einfacher zu brechen, einfacher herunterzufahren, einfacher zu beobachten und weniger anfällig für Goroutine-Lecks. Ein schlecht genutzter context versteckt Abhängigkeiten, verwirft Deadlines und macht Code unter Druck schwerer nachvollziehbar.

Das praktische Fazit ist einfach:

Übergabe von context weiter.
Speichern Sie ihn nicht.
Ersetzen Sie explizite Parameter nicht durch Werte.
Respektieren Sie Abbruch.
Verwenden Sie Timeouts an Grenzen.
Rufen Sie immer cancel auf.

Das ist Go-context richtig gemacht.

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

Quellen

Abonnieren

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