Menu

Golang Context: Abbruch, Timeouts und Werte

Wie context.Context Abbruchsignale, Deadlines und Werte eines Requests durch ein Go-Programm trägt: Background, WithCancel, WithTimeout, WithValue, ctx.Done in select und Context in HTTP-Servern und Clients.

Diese Seite enthält ausführbare Editoren - bearbeiten, ausführen und Ausgabe sofort sehen.

Ein Timeout in zehn Zeilen

Die Aufgabe von context.Context ist, Code zu sagen, wann er aufhören soll. Hier bekommt eine langsame Operation 50 ms und gibt auf, wenn der Context es sagt:

Der erste Aufruf ist nach 10 ms fertig und gibt rows <nil> zurück. Der zweite bräuchte 200 ms, aber der Context läuft nach 50 ms ab (gezählt ab seiner Erzeugung), also gibt er context deadline exceeded zurück.

Nichts wird gewaltsam gestoppt. Go hat keine Möglichkeit, eine Goroutine von außen zu beenden. Ein Context ist ein Signal, und der Code muss es prüfen: per select auf ctx.Done(), indem er zwischen den Schritten ctx.Err() prüft, oder indem er ctx an Bibliotheksaufrufe weitergibt (http.NewRequestWithContext, db.QueryContext, exec.CommandContext), die es für ihn prüfen.

Das Interface Context

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}
MethodeGibt zurück
Done()einen Channel, der geschlossen wird, wenn der Context abgebrochen wird oder abläuft (nil bei einem Context, der nie abgebrochen werden kann)
Err()nil, solange er aktiv ist, danach context.Canceled oder context.DeadlineExceeded
Deadline()die Deadline und true, oder ok == false, wenn es keine gibt
Value(key)den unter key gespeicherten Wert in diesem Context oder einem Vorfahren, oder nil

Contexts sind unveränderlich. Du änderst nie einen; du leitest mit einer der With-Funktionen ein Kind davon ab, und das Kind fügt ein Abbruchsignal, eine Deadline oder einen Wert hinzu.

Woher ein Context kommt

Jeder Context-Baum beginnt bei einer Wurzel:

  • context.Background() für main, init, Tests und das Setup von Servern auf oberster Ebene.
  • context.TODO(), wenn eine Funktion einen Context nehmen sollte, der Aufrufer aber noch keinen hat. Er verhält sich genau wie Background; der Name ist eine Markierung für späteres Refactoring.

In einem HTTP-Handler erzeugst du keine Wurzel. Du nimmst r.Context(), den der Server abbricht, wenn der Client die Verbindung trennt oder der Handler zurückkehrt.

WithCancel: auf Anforderung stoppen

context.WithCancel gibt einen Kind-Context und eine Funktion cancel zurück. Ein Aufruf von cancel schließt den Done-Channel des Kinds und die Done-Channels von allem, was davon abgeleitet ist.

Das Senden des Produzenten steht in einem select neben ctx.Done(). Genau das lässt ihn anhalten: Ein nacktes out <- i würde für immer blockieren, sobald der Konsument aufhört zu lesen, und die Goroutine würde leaken. Das abschließende for range nums wartet, bis der Produzent den Channel geschlossen hat. Während es leert, schafft der Produzent eventuell noch ein oder zwei Werte, denn wenn beide Cases seines select bereit sind, wählt Go zufällig einen aus; der Abbruch kommt zügig, aber nicht augenblicklich.

cancel darf mehrfach und aus jeder Goroutine aufgerufen werden. Nur der erste Aufruf bewirkt etwas.

WithTimeout und WithDeadline

WithTimeout(parent, d) ist WithDeadline(parent, time.Now().Add(d)). Nimm einen Timeout für „höchstens so lange“ und eine Deadline, wenn du einen absoluten Zeitpunkt hast.

Sobald die Zeit abgelaufen ist, schließt Done, und Err gibt context.DeadlineExceeded zurück. Wird vorher cancel aufgerufen, gibt Err context.Canceled zurück. Welcher Fall vorliegt, prüfst du mit errors.Is, weil Bibliotheken den Fehler meist wrappen:

Ruf immer cancel auf, auch bei einem Timeout, der von selbst auslöst. Der Context hält einen Timer und einen Platz in seinem Eltern-Context, bis eins von beidem passiert, und defer cancel() gibt beides frei, sobald die Funktion zurückkehrt. go vet meldet eine verworfene Cancel-Funktion: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak.

Kinder können ihre Eltern nicht überleben

Contexts bilden einen Baum. Einen Eltern-Context abzubrechen bricht jeden Nachfahren ab. Ein Kind kann eine kürzere Deadline haben als sein Eltern-Context, nie eine längere: Die frühere Deadline gewinnt immer.

Das macht Contexts über Schichten hinweg nützlich. Ein HTTP-Handler bekommt einen Context, der mit dem Request stirbt; ein Datenbankaufruf drei Schichten tiefer leitet davon einen Timeout von 2 Sekunden ab. Legt der Client nach 100 ms auf, wird die Abfrage dann abgebrochen, nicht zwei Sekunden später.

Beim Blockieren immer auf ctx.Done() warten

Jede Goroutine, die wartet (auf ein Senden, ein Empfangen, einen Timer), sollte gleichzeitig auf ctx.Done() warten. Bei CPU-lastigen Schleifen, die nie blockieren, prüfst du ab und zu ctx.Err():

for i, item := range items {
	if i%1000 == 0 {
		if err := ctx.Err(); err != nil {
			return err
		}
	}
	process(item)
}

Nimm time.After in einem select für ein einfaches Warten, aber bevorzuge einen Timer, den du stoppen kannst (oder einen Context-Timeout), wenn das Warten oft abgebrochen wird.

Gründe für den Abbruch (Go 1.20 und 1.21)

ctx.Err() sagt nur canceled oder deadline exceeded. Um den Grund festzuhalten, nimm die Cause-Varianten:

WithCancelCause kam mit Go 1.20, WithTimeoutCause und WithDeadlineCause mit Go 1.21. Err gibt weiterhin die Standardwerte zurück, damit bestehende Prüfungen funktionieren; context.Cause liefert das Detail.

WithValue, sparsam

context.WithValue(parent, key, value) hängt einen Wert an. ctx.Value(key) sucht ihn über die Kette der Eltern.

Regeln für Werte:

  • Nimm einen nicht exportierten Typ für Schlüssel, nie einen schlichten string. Zwei Pakete, die beide "user" verwenden, würden sich gegenseitig überschreiben. (go vet findet das nicht; staticcheck schon.)
  • Kapsle den Zugriff in typisierte Hilfsfunktionen wie WithRequestID und RequestID, damit Aufrufer nie any oder den Schlüssel sehen.
  • Speichere nur Daten eines Requests, die durch APIs hindurchwandern: Trace- und Request-IDs, den authentifizierten Nutzer, einen Logger. Nie optionale Parameter, Datenbank-Handles oder Konfiguration. Die gehören in Funktionsargumente oder Struct-Felder, wo der Compiler sie prüfen und Leser sie sehen können.
  • Das Nachschlagen läuft die Kette Eltern für Eltern entlang, also macht jeder hinzugefügte Wert das Nachschlagen der anderen einen Schritt länger.

Konventionen

  • ctx context.Context ist der erste Parameter jeder Funktion, die I/O macht, blockiert oder etwas aufruft, das das tut: func Fetch(ctx context.Context, url string) error.
  • Speichere keinen Context in einem Struct. Übergib ihn jedem Methodenaufruf. Ein Context gehört zu einer Operation, und ein Struct lebt meist länger. (Die Ausnahme ist ein Typ, der eine einzelne Operation darstellt, wie http.Request.)
  • Übergib nie nil als Context. Nimm context.TODO(), wenn du nichts Besseres hast.
  • Gib ctx.Err() zurück oder wrappe ihn mit %w, wenn du wegen des Context aufhörst, damit Aufrufer einen Timeout von einem echten Fehlschlag unterscheiden können.

Context in HTTP-Servern und Clients

Die Serverseite: r.Context() wird abgebrochen, wenn der Client die Verbindung trennt, wenn der Handler zurückkehrt oder wenn ein HTTP/2-Stream zurückgesetzt wird. Die Clientseite: http.NewRequestWithContext sorgt dafür, dass der Request einen Timeout oder Abbruch beachtet. Dieses Programm betreibt beide Enden über httptest:

Der Client gibt nach 50 ms auf und schließt die Verbindung. Der Server bemerkt das, sein Request-Context wird abgebrochen, und der Handler hört auf, statt noch 450 Millisekunden in einen Bericht zu stecken, den niemand lesen wird. In einem echten Handler reichst du r.Context() an jeden Datenbank- und HTTP-Aufruf weiter, und sie hören alle gemeinsam auf.

Weitere Helfer (Go 1.21)

  • context.WithoutCancel(ctx) gibt einen Context mit denselben Werten zurück, der nicht abgebrochen wird, wenn ctx abgebrochen wird. Nimm ihn für Arbeit, die nach dem Ende des Requests fertig werden muss, etwa das Schreiben eines Audit-Logs.
  • context.AfterFunc(ctx, f) führt f in einer eigenen Goroutine aus, sobald ctx fertig ist, und gibt eine Funktion stop zurück, mit der du die Registrierung aufhebst.

Häufige Fehler

  • cancel nicht aufrufen. Immer defer cancel() direkt nach WithCancel, WithTimeout oder WithDeadline.
  • Eine Goroutine starten, die ctx ignoriert. Blockiert sie, ohne per select auf ctx.Done() zu warten, bewirkt der Abbruch nichts, und die Goroutine leakt.
  • Tief in einer Aufrufkette ein neues context.Background() erzeugen. Das kappt die Verbindung zu Deadline und Abbruch des Aufrufers. Reich den ctx weiter, den du bekommen hast.
  • Fehler mit == vergleichen. Nimm errors.Is(err, context.DeadlineExceeded); die meisten Bibliotheken wrappen ihn.
  • WithValue für Abhängigkeiten nutzen. Ein Datenbank-Handle, versteckt in einem Context, ist ein Parameter, den der Compiler nicht mehr prüfen kann.
  • Einen sofortigen Abbruch erwarten. Code bemerkt ihn erst bei seiner nächsten Prüfung. Eine lange Schleife ohne Prüfung läuft weiter.

Häufig gestellte Fragen

Wofür wird context in Go verwendet?

Ein context.Context sagt einer Funktion und allem, was sie aufruft, wann sie aufgeben soll: weil der Aufrufer abgebrochen hat, weil eine Deadline verstrichen ist oder weil der Client die Verbindung getrennt hat. Er kann außerdem Werte tragen, die zu einem Request gehören, etwa eine Request-ID. Per Konvention ist er der erste Parameter und heißt ctx.

Was ist der Unterschied zwischen context.Background und context.TODO?

Beide geben einen leeren Context zurück, der nie abgebrochen wird und weder Deadline noch Werte hat. Sie verhalten sich identisch. Background() ist die Wurzel für main, Tests und das Setup auf oberster Ebene. TODO() markiert eine Stelle, an der ein echter Context übergeben werden sollte, der umgebende Code aber noch keinen hat. So findest du sie später leicht wieder.

Warum muss ich nach context.WithTimeout cancel aufrufen?

WithTimeout, WithDeadline und WithCancel registrieren den neuen Context bei seinem Eltern-Context und starten eventuell einen Timer. Der Aufruf von cancel gibt diese Ressourcen frei, sobald du fertig bist, statt erst, wenn der Timeout auslöst oder der Eltern-Context abgebrochen wird. Schreib defer cancel() direkt nach dem Erzeugen; go vet warnt, wenn eine Cancel-Funktion verworfen wird.

Was bedeutet „context deadline exceeded“ in Go?

Das ist der Text von context.DeadlineExceeded, dem Fehler, den ctx.Err() zurückgibt, sobald die Deadline eines Context verstrichen ist. Funktionen, die den Context beachten, etwa HTTP-Clients und Datenbanktreiber, geben ihn (oft gewrappt) zurück, wenn ihnen die Zeit ausgeht. Prüf darauf mit errors.Is(err, context.DeadlineExceeded).

Sollte ich context.WithValue verwenden, um Parameter zu übergeben?

Nein. Nimm es nur für Daten eines Requests, die API-Grenzen überqueren und von denen die Funktionen dazwischen nichts wissen müssen, etwa eine Trace-ID oder den authentifizierten Nutzer. Alles, was eine Funktion für ihre Arbeit braucht, gehört in ihre Parameter, wo der Compiler es prüft.

Coddy programming languages illustration

Lerne mit Coddy zu programmieren

LOS GEHT'S