context.Context ist Gos Antwort auf eine zentrale Frage nebenläufiger Systeme: Wie sage ich einer laufenden Operation, dass sie aufhören soll? Eine HTTP-Anfrage bricht ab, ein Timeout schlägt zu, der Benutzer drückt Strg+C — in all diesen Fällen müssen Dutzende Goroutinen, offene Datenbankverbindungen und blockierende Reads koordiniert beendet werden. Vor Go 1.7 hat jedes Projekt dafür sein eigenes done chan struct{} durch die Funktionssignaturen gereicht, oft inkonsistent und fehleranfällig.
Mit Go 1.7 wanderte das von Sameer Ajmani entworfene context-Paket aus golang.org/x/net/context in die Standardbibliothek. Seitdem ist context.Context der De-facto-Standard für Cancellation, Deadlines und Request-scoped Values — vom net/http-Server, der bei jedem Request einen Context erzeugt, bis zu jedem ernstzunehmenden Datenbank-, RPC- und Cloud-SDK.
Dieser Artikel legt das Fundament: Wie das Interface aussieht, wie der Context-Tree entsteht, warum Background und TODO semantisch verschieden sind, und wie WithCancel zusammen mit ctx.Done() und ctx.Err() ein robustes Abbruch-Protokoll bildet. Deadlines, Timeouts und Best Practices für Funktionssignaturen folgen in den nächsten Artikeln.
Referenz: pkg.go.dev/context, Hintergrund: go.dev/blog/context.
Das Context-Interface
context.Context ist ein bewusst kleines Interface mit vier Methoden. Jede laufende Operation bekommt eine solche Schnittstelle übergeben und kann darüber prüfen, ob sie noch arbeiten soll.
type Context interface {
Done() <-chan struct{}
Err() error
Deadline() (deadline time.Time, ok bool)
Value(key any) any
}Die einzelnen Methoden verteilen klar getrennte Aufgaben: Done() liefert einen Receive-Only-Channel, der geschlossen wird, sobald der Context abgebrochen wurde — das Schließen eines Channels ist in Go ein Broadcast, alle Empfänger werden gleichzeitig entblockt. Err() ist nil, solange der Context aktiv ist, und liefert nach Abbruch einen von zwei Sentinel-Werten: context.Canceled (jemand hat explizit cancel() aufgerufen) oder context.DeadlineExceeded (eine Deadline ist verstrichen). Deadline() verrät, ob und wann der Context automatisch ablaufen wird. Value(key) trägt Request-scoped Werte (Trace-IDs, Auth-Token) durch den Call-Stack — diese Methode ist im Praxisalltag heikel und bekommt im Verträge-Artikel ihre eigene Behandlung.
Wichtig ist die Unterscheidung zwischen Signal und Status: Done() ist der Channel, auf dem man im select lauscht. Err() ist die Statusabfrage danach, um zu wissen, warum der Channel geschlossen wurde.
context.Background() vs context.TODO()
Ein Context-Tree braucht eine Wurzel. Das Paket bietet dafür zwei Konstruktoren, die intern dasselbe zurückliefern (einen nie-cancelnden, leeren Context) — sich aber semantisch klar unterscheiden.
// Background: bewusste Wurzel des Context-Trees
func main() {
ctx := context.Background()
runServer(ctx)
}
// TODO: Platzhalter, wenn unklar ist, welcher Context hier hingehört
func legacyFunction() {
ctx := context.TODO() // "Hier sollte später ein echter Context rein"
doWork(ctx)
}context.Background() ist die idiomatische Wurzel in main, in init-Funktionen, in Tests (sofern kein Test-Context aus dem Framework kommt) und am Eingang eingehender Requests, bevor der HTTP-Server einen eigenen abgeleiteten Context erzeugt. context.TODO() signalisiert dagegen Lesern und statischer Analyse: „An dieser Stelle bin ich mir noch nicht sicher, woher der richtige Context kommen soll." Typischerweise taucht es in Code auf, der gerade auf Context-Awareness umgestellt wird. Linter wie staticcheck und contextcheck können nach TODO-Aufrufen Ausschau halten und Hinweise geben.
Eine Faustregel: Wenn du es kommentieren würdest mit „hier müsste der Request-Context rein, kommt später" — schreib stattdessen context.TODO() und der Code dokumentiert sich selbst.
WithCancel — der manuelle Abbruch
Aus einer Wurzel oder einem bestehenden Context erzeugt context.WithCancel ein Kind, das man explizit abbrechen kann. Der Aufruf gibt zwei Werte zurück: den neuen Context und eine CancelFunc.
ctx, cancel := context.WithCancel(parent)
defer cancel() // PFLICHT — auch im Erfolgsfall
go worker(ctx)
// ... irgendwann später, oder bei Fehler:
// cancel() schließt ctx.Done() und alle abgeleiteten Done()-ChannelsDrei Punkte zu diesem Pattern, die in der Praxis immer wieder bluten: Erstens muss cancel() aufgerufen werden — auch wenn alles geklappt hat. Die CancelFunc ist nicht nur ein Abbruch-Knopf, sie gibt interne Ressourcen frei: Der Parent-Context hält den Kind-Context in einer Map, um Cancellation propagieren zu können. Ohne cancel() bleibt der Kind-Eintrag dort hängen, bis der Parent cancelt — was bei einem Background-Parent niemals passiert. Ergebnis: Memory-Leak, den go vet zum Glück erkennt und meldet (the cancel function returned by context.WithCancel should be called).
Zweitens ist defer cancel() idiomatisch. Es deckt sowohl Erfolg als auch alle Fehlerpfade und Panics ab. Selbst wenn die Funktion cancel() schon manuell aufgerufen hat, ist ein zweiter Aufruf safe und no-op — CancelFunc ist explizit als idempotent dokumentiert. Drittens ist cancel() nicht-blockierend. Es schließt nur den Channel und propagiert das Signal. Die Goroutinen, die ctx.Done() lauschen, beenden sich asynchron. Wer auf den vollständigen Shutdown warten will, braucht zusätzlich sync.WaitGroup oder einen eigenen Done-Channel.
Der Context-Tree
Jeder Aufruf von WithCancel, WithDeadline, WithTimeout oder WithValue erzeugt einen neuen Kind-Context, der seinen Parent kennt. So entsteht ein gerichteter Baum — die Wurzel ist meist Background(), die Blätter sind die innersten Operationen.
context.Background() ← main()
└── serverCtx (WithCancel) ← graceful shutdown
└── requestCtx (vom http.Server) ← pro Request, automatisch
├── dbQueryCtx (WithTimeout) ← 2s für die DB
└── rpcCtx (WithTimeout) ← 500ms für externen Call
└── retryCtx (WithCancel) ← interner Retry-WrapperDie zentrale Regel: Cancellation fließt von der Wurzel zu den Blättern, niemals umgekehrt. Wird serverCtx abgebrochen (z. B. SIGTERM), kaskadiert das Signal abwärts: alle Request-, DB-, RPC- und Retry-Contexte schließen ihr Done(). Eine einzelne cancel()-Aktion an der Wurzel beendet so den gesamten Subtree. Wird umgekehrt retryCtx abgebrochen (z. B. weil ein Versuch geklappt hat und der Wrapper sauber aufräumt), passiert nichts mit rpcCtx, requestCtx oder darüber. Ein Kind kann seinen Parent nicht beeinflussen.
Diese Asymmetrie ist gewollt: Sie erlaubt jedem Funktionsaufrufer, engere Bedingungen zu setzen (kürzere Deadline, eigener Cancel) — niemals weitere. Eine Funktion kann den Context, den sie bekommt, niemals „verlängern", nur einschränken. Praktische Konsequenz: Wenn dein Code denkt, er müsse eine längere Deadline durchsetzen als sein Aufrufer erlaubt, läuft etwas konzeptionell falsch.
ctx.Done() und ctx.Err() im Detail
Das Zusammenspiel aus Channel und Error ist das Herzstück der Cancellation-Semantik. Schauen wir es uns an einer typischen Worker-Loop an.
func worker(ctx context.Context, jobs <-chan Job) {
for {
select {
case <-ctx.Done():
// Channel ist geschlossen — wir sollen aufhören
log.Printf("worker stopping: %v", ctx.Err())
return
case j, ok := <-jobs:
if !ok {
return
}
process(j)
}
}
}Was hier passiert: ctx.Done() liefert bei jedem Aufruf denselben Channel zurück. Solange der Context aktiv ist, ist der Channel offen und ein <-ctx.Done() blockiert. Sobald irgendwo im Tree (an diesem Context oder einem Vorfahren) cancel() oder ein Deadline-Ablauf ausgelöst wird, schließt das Paket den Channel genau einmal. Ein geschlossener Channel ist sofort lesbar — <-ctx.Done() kehrt unverzüglich zurück und liefert den Zero-Value struct{}{}. Nach dem Schließen liefert ctx.Err() einen Non-Nil-Error: context.Canceled, wenn cancel() aufgerufen wurde, context.DeadlineExceeded, wenn eine Deadline überschritten wurde. Vor dem Schließen ist ctx.Err() garantiert nil.
Diese Garantie ist nützlich für Defensive Checks: if ctx.Err() != nil { return ctx.Err() } am Anfang einer Funktion erspart unnötige Arbeit, wenn der Caller bereits abgebrochen hat. In Logging und Fehlerketten gibt man üblicherweise ctx.Err() weiter — entweder direkt als Return-Wert oder mit fmt.Errorf("db query: %w", ctx.Err()), damit errors.Is(err, context.Canceled) weiter oben funktioniert.
Cancellation propagiert nicht magisch
Ein häufiges Missverständnis: „Ich habe einen Context, also bricht meine Goroutine automatisch ab." Falsch. Context ist ein Protokoll, kein Mechanismus, der laufenden Code unterbricht. Go kennt keine Thread.interrupt()-Semantik wie Java; eine Goroutine läuft, bis sie selbst entscheidet aufzuhören.
// Anti-Pattern: ignoriert den Context komplett
func leakyWorker(ctx context.Context) {
for {
time.Sleep(1 * time.Second) // blockiert stur 1s, egal was ctx sagt
doWork()
}
}
// Cancellation-aware Variante
func goodWorker(ctx context.Context) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
doWork()
}
}
}Selbst wenn der Parent canceltt, schläft der naive Worker weiter. Korrekt ist das select mit ctx.Done(). Zweitens: Verwende context-aware Bibliotheks-APIs, wo es sie gibt — http.NewRequestWithContext, (*http.Client).Do, (*sql.DB).QueryContext, ExecContext, BeginTx, (*net.Dialer).DialContext, (*os/exec.Cmd).CommandContext, gRPC, NATS, Kafka-Clients — alle nehmen Context. Diese APIs hängen intern an ctx.Done() und brechen die zugrundeliegende Operation ab (TCP-Verbindung schließen, DB-Cancellation senden). Naive Calls wie time.Sleep, io.Copy (ohne Context-Reader) oder ioutil.ReadAll ignorieren Context komplett — das sind die Stellen, an denen Goroutinen leaken.
context.AfterFunc (Go 1.21+)
Bis Go 1.20 brauchte man eine eigene kleine Watcher-Goroutine, um etwas auszulösen, wenn ein Context cancelt — typischerweise um Ressourcen zu schließen, die selbst kein Context-Argument akzeptieren. Das Pattern funktioniert, kostet aber eine permanent blockierte Goroutine pro Watcher und liefert keinen sauberen Weg, den Watcher wieder abzubestellen, falls die eigentliche Operation schon vorher fertig ist.
// Klassischer Watcher (vor 1.21)
go func() {
<-ctx.Done()
conn.Close()
}()
// AfterFunc (ab 1.21) — Goroutine-frei und stoppbar
stop := context.AfterFunc(ctx, func() {
conn.Close()
})
defer stop() // bestellt den Callback ab, wenn wir vorher fertig sindDer Callback wird in einer eigenen Goroutine ausgeführt, sobald ctx cancelt — aber nur dann, ohne dass dauerhaft eine Watcher-Goroutine parkt. Die zurückgegebene stop-Funktion entfernt den Eintrag wieder; stop() liefert true, wenn der Callback dadurch verhindert wurde, false, wenn er bereits angelaufen ist oder der Context schon canceltt war. Für Cleanup-Hooks, die nur greifen sollen, wenn etwas schiefläuft, ist das das aktuell sauberste Pattern.
Worker-Pool mit kontrolliertem Shutdown
Ein vollständiges, lauffähiges Beispiel: Drei Worker arbeiten an einem geteilten Job-Channel. Nach zwei Sekunden ruft main cancel() auf, alle Worker beenden sich sauber.
package main
import (
"context"
"fmt"
"sync"
"time"
)
func worker(ctx context.Context, id int, jobs <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-ctx.Done():
fmt.Printf("worker %d stopping: %v\n", id, ctx.Err())
return
case j, ok := <-jobs:
if !ok {
return
}
time.Sleep(300 * time.Millisecond)
fmt.Printf("worker %d done job %d\n", id, j)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
jobs := make(chan int)
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(ctx, i, jobs, &wg)
}
go func() {
for j := 1; ; j++ {
select {
case <-ctx.Done():
close(jobs)
return
case jobs <- j:
}
}
}()
time.Sleep(2 * time.Second)
fmt.Println("main: requesting shutdown")
cancel()
wg.Wait()
fmt.Println("main: all workers done")
}Wichtige Beobachtungen am Beispiel: Die Worker prüfen ctx.Done() zwischen den Jobs, nicht währenddessen. Ein Job, der gerade läuft, wird also zu Ende geführt — das ist meist gewünschtes Verhalten (Atomicity einzelner Arbeitseinheiten). Der Producer schließt jobs aktiv bei Cancel, sodass Worker, die gerade auf einen neuen Job warten, sowohl das Cancel-Signal als auch den Channel-Close sehen — beides führt zum sauberen Return. sync.WaitGroup wartet auf den physischen Abschluss aller Goroutinen. cancel() allein garantiert nur das Signal, nicht das Ende. Diese Trennung ist fundamental.
HTTP-Handler propagiert Cancellation zur DB
Der zweite Praxisfall zeigt, warum Context im echten Server-Code allgegenwärtig ist: Ein Client schließt seine TCP-Verbindung mitten in einer langsamen Query. Ohne Context-Weiterreichung blockiert die DB-Goroutine trotzdem bis zum Query-Ende und hält Pool-Connections fest. Mit Context bricht der DB-Treiber die Query aktiv ab.
package main
import (
"context"
"database/sql"
"errors"
"net/http"
)
type Server struct {
db *sql.DB
}
func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var total int
err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM events WHERE created_at > NOW() - INTERVAL '7 days'
`).Scan(&total)
switch {
case errors.Is(err, context.Canceled):
return
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "timeout", http.StatusGatewayTimeout)
return
case err != nil:
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Write([]byte(formatReport(total)))
}Was hier zusammenspielt: net/http erzeugt für jeden eingehenden Request einen Context, der abbricht, sobald http.Request.Body geschlossen wird oder die zugrundeliegende TCP-Verbindung wegfällt. (*sql.DB).QueryRowContext gibt diesen Context an den Treiber weiter. Pg- und MySQL-Treiber senden bei Cancel ein KILL QUERY bzw. pg_cancel_backend an die Datenbank, sodass die Query tatsächlich auf DB-Seite endet — nicht erst der Treiber lokal aufgibt. Die errors.Is-Verzweigung trennt erwartete Abbrüche von echten Fehlern. context.Canceled bedeutet meistens: Der Client ist weg, also schreib gar nichts mehr. context.DeadlineExceeded würde von einem WithTimeout weiter außen kommen — Details dazu im nächsten Artikel.
Dieses Pattern ist der Grund, warum jede ernsthafte Go-Library Context als ersten Parameter nimmt: Es ist die einzige Möglichkeit, End-to-End-Cancellation vom Client durch alle Schichten bis zur externen Ressource sauber durchzuziehen.
Erkenntnisse
Interessantes
defer cancel() ist Pflicht
Nach jedem WithCancel/WithTimeout/WithDeadline gehört unmittelbar defer cancel() — auch im Erfolgsfall. Sonst leakt der Kind-Eintrag im Parent, bis dieser cancelt; go vet warnt zu Recht.
TODO ist ehrlicher als Background
Wenn unklar ist, welcher Context an dieser Stelle eigentlich richtig wäre, signalisiert context.TODO() genau das. Funktional identisch zu Background, aber dokumentiert die Unsicherheit für Leser und Linter.
Context immer als erster Parameter
Idiomatisch heißt der Parameter ctx context.Context und steht ganz vorne in der Signatur — vor allen anderen Argumenten. So lesen sich Aufrufe konsistent und der Context wandert nicht im Funktionskopf.
ctx.Err() unterscheidet Ursachen
context.Canceled vs context.DeadlineExceeded ist eine wichtige Unterscheidung: Ersteres ist meist „Client weg, kein Fehler", letzteres ist ein echter Timeout, der Logging oder 504 verdient.
Done() ist Broadcast
Der Done-Channel schließt sich genau einmal und entblockt alle Wartenden gleichzeitig. Beliebig viele Goroutinen können dasselbe ctx.Done() lauschen, ein einziger cancel() weckt sie alle.
Cancellation muss aktiv geprüft werden
Go unterbricht keine laufende Goroutine. Jede eigene Schleife braucht select { case <-ctx.Done(): ... }, oder du verwendest context-aware Bibliotheks-APIs wie QueryContext, DialContext, CommandContext.
Naive Goroutinen leaken bei Cancel
Eine Goroutine ohne ctx.Done()-Check beendet sich nicht, wenn der Context cancelt — sie läuft, bis ihre Arbeit von selbst fertig ist, und blockiert solange Speicher und Ressourcen. Standard-Quelle von Goroutine-Leaks.
AfterFunc ersetzt Watcher-Goroutinen
context.AfterFunc(ctx, fn) (Go 1.21+) registriert einen Callback für den Cancel-Moment, ohne eine dauerhaft blockierte Watcher-Goroutine. Die zurückgegebene stop-Funktion bestellt den Callback wieder ab, wenn die eigentliche Operation früher fertig wird.
Weiterführende Ressourcen
Externe Quellen
context— Go Documentation- Go Concurrency Patterns: Context (Go Blog)
context.AfterFunc(Go 1.21+)- Go 1.7 Release Notes — context in stdlib