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
}
| Methode | Gibt 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ürmain,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 wieBackground; 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 vetfindet das nicht;staticcheckschon.) - Kapsle den Zugriff in typisierte Hilfsfunktionen wie
WithRequestIDundRequestID, damit Aufrufer nieanyoder 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.Contextist 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
nilals Context. Nimmcontext.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, wennctxabgebrochen 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ührtfin einer eigenen Goroutine aus, sobaldctxfertig ist, und gibt eine Funktionstopzurück, mit der du die Registrierung aufhebst.
Häufige Fehler
cancelnicht aufrufen. Immerdefer cancel()direkt nachWithCancel,WithTimeoutoderWithDeadline.- Eine Goroutine starten, die
ctxignoriert. Blockiert sie, ohne per select aufctx.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 denctxweiter, den du bekommen hast. - Fehler mit
==vergleichen. Nimmerrors.Is(err, context.DeadlineExceeded); die meisten Bibliotheken wrappen ihn. WithValuefü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.