Context in Go: Timeout, Cancel und Wertübergabe

1. Übersicht

Wenn du einen Go-Service schreibst, der mehrere Goroutinen verarbeitet, brauchst du eine einheitliche Möglichkeit, den Goroutinen zu sagen: „Stoppt, ich brauche das Ergebnis nicht mehr” oder „Läuft nur 2 Sekunden, dann stellt ein”. context.Context wurde genau dafür entwickelt.

Dieser Artikel behandelt drei Hauptaufgaben, die du mit Context erledigst: manuelles Canceln, Setzen von Timeout/Deadline und die Übergabe von Request-Werten durch mehrere Funktionsaufrufe. Nach dem Lesen kannst du HTTP-Handler, DB-Aufrufe und Funktionen schreiben, die Goroutinen zur richtigen Zeit stoppen und keine Goroutinen auslaufen lassen.

2. Was ist Context und warum brauchst du ihn

context.Context ist ein Interface, das den „Kontext” einer Verarbeitungskette beschreibt (normalerweise ein Request). Es enthält:

  • Cancellation-Signal.
  • Deadline/Timeout.
  • Einige kleine Key-Value-Paare, die an den Request gebunden sind.
type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}
  • Done() gibt einen Channel zurück, der geschlossen wird, wenn der Context abgebrochen oder die Deadline überschritten wird.
  • Err() gibt den Grund an: context.Canceled oder context.DeadlineExceeded.
  • Value() ruft einen Wert ab, der an den Context gebunden ist.

Hinweis: Context wird nicht verwendet, um beliebige optionale Parameter zu übergeben. Es sollte nur Steuersignale und Request-Werte wie Request-ID oder den aktuellen Benutzer enthalten — nicht Config oder wichtige Business-Objekte.

3. Manuelles Canceln mit WithCancel

Verwende dies, wenn du selbst entscheiden möchtest, wann du abbrechen möchtest, z.B. wenn eine von mehreren Goroutinen ein Ergebnis hat, dann brich die anderen ab.

func fetchFromMultipleSources(ctx context.Context) (string, error) {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel() // rufe cancel() immer auf, um Ressourcen freizugeben

	resultCh := make(chan string, 1)

	for _, src := range []string{"cache", "db", "api"} {
		go func(source string) {
			select {
			case <-time.After(100 * time.Millisecond):
				resultCh <- source
				cancel() // Ergebnis vorhanden, brich andere Quellen ab
			case <-ctx.Done():
				return // abgebrochen, beende vorzeitig
			}
		}(src)
	}

	select {
	case res := <-resultCh:
		return res, nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}
  • context.WithCancel(ctx) erstellt einen Child-Context und eine cancel-Funktion, um ihn abzubrechen.
  • defer cancel() ist zwingend erforderlich: Wenn du es nicht aufrufst, „hängt” der Child-Context bis der Parent abgebrochen wird, was zu einem Goroutine-Leak führt, das auf ctx.Done() wartet.
  • Die Goroutine, die zuerst fertig ist, ruft cancel() auf; die anderen Goroutinen erhalten das Signal über ctx.Done() und stoppen.

Häufiger Fehler: defer cancel() vergessen. Wenn die Funktion vorzeitig zurückkehrt (z.B. durch einen Fehler) und den Context nicht abbricht, bleibt die Goroutine bestehen, bis sie auf indirektem Weg vom Garbage Collector entfernt wird — tatsächlich ein Leak.

4. Timeout und Deadline mit WithTimeout, WithDeadline

4.1. WithTimeout für relative Zeitlimits

func callPaymentService(ctx context.Context, orderID string) error {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet,
		"https://payment.local/orders/"+orderID, nil)
	if err != nil {
		return err
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err // könnte context.DeadlineExceeded sein
	}
	defer resp.Body.Close()
	return nil
}
  • context.WithTimeout(ctx, 2*time.Second) bricht den Context automatisch nach 2 Sekunden ab.
  • http.NewRequestWithContext bindet den Context an den Request; wenn die Deadline überschritten wird, bricht http.Client die laufende Anfrage automatisch ab.
  • Wenn über 2 Sekunden vergehen, gibt resp, err := http.DefaultClient.Do(req) einen Fehler zurück, der context.DeadlineExceeded enthält.

Fehler überprüfen:

if err != nil {
	if errors.Is(err, context.DeadlineExceeded) {
		log.Println("Zahlungsservice Timeout")
	}
	return err
}

4.2. WithDeadline für absolute Zeitpunkte

func processBeforeMidnight(ctx context.Context) error {
	deadline := time.Date(2026, 10, 6, 23, 59, 59, 0, time.Local)
	ctx, cancel := context.WithDeadline(ctx, deadline)
	defer cancel()

	select {
	case <-time.After(5 * time.Hour):
		return errors.New("Verarbeitung abgeschlossen")
	case <-ctx.Done():
		return ctx.Err()
	}
}
  • Verwende WithDeadline, wenn du einen spezifischen Zeitpunkt hast (z.B. Ende des Tages); WithTimeout ist dagegen eine relative Zeitdauer von jetzt an.
  • Im Grunde ist WithTimeout(ctx, d) nur ein dünner Wrapper, der WithDeadline(ctx, time.Now().Add(d)) aufruft.

5. Wertübergabe via Context mit WithValue

Verwende dies für Request-Daten, nicht für Business-Parameter.

type requestIDKey struct{}

func WithRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, requestIDKey{}, id)
}

func RequestIDFromContext(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(requestIDKey{}).(string)
	return id, ok
}
func loggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		ctx := WithRequestID(r.Context(), id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func orderHandler(w http.ResponseWriter, r *http.Request) {
	if id, ok := RequestIDFromContext(r.Context()); ok {
		log.Printf("[%s] Bestellung wird verarbeitet", id)
	}
}
  • Verwende type requestIDKey struct{} als eindeutigen Schlüssel — vermeide direkte String-Schlüssel, da sie leicht mit Schlüsseln anderer Pakete kollidieren können.
  • WithRequestID und RequestIDFromContext kapseln das Setzen/Abrufen; andere Pakete müssen nicht wissen, wie der echte Schlüssel aussieht.
  • r.WithContext(ctx) erstellt einen neuen Request mit dem Context, der die Werte trägt; dieser wird durch die Middleware-Kette weitergeleitet.

Hinweis: Verwende nicht context.WithValue, um *sql.DB, Config oder etwas Erforderliches für die Funktionsweise zu übergeben. Diese sollten explizite Parameter oder Struct-Felder sein. Context-Werte eignen sich nur für optionale Daten wie Request-ID, Trace-ID oder den angemeldeten Benutzer.

6. context.TODO und context.Background

func main() {
	ctx := context.Background() // Root-Context, wird nie abgebrochen
	run(ctx)
}

func legacyFunctionBeingRefactored() {
	ctx := context.TODO() // Platzhalter, wird später durch echten Context ersetzt
	_ = ctx
}
  • context.Background() ist der Root-Context; verwende ihn in main(), Tests oder beim Top-Level-Initialisieren.
  • context.TODO() wird verwendet, wenn du gerade refaktorierst und nicht sicher bist, welcher Context übergeben werden sollte — funktional ähnlich wie Background(), aber signalisiert Absicht für Leser und Linter.

7. Context-Propagation durch mehrere Funktionsaufrufe

Konvention: ctx context.Context ist immer der erste Parameter.

func (s *OrderService) CreateOrder(ctx context.Context, order Order) error {
	if err := s.validate(ctx, order); err != nil {
		return err
	}
	return s.repo.Save(ctx, order)
}

func (r *OrderRepository) Save(ctx context.Context, order Order) error {
	_, err := r.db.ExecContext(ctx,
		"INSERT INTO orders (id, total) VALUES (?, ?)", order.ID, order.Total)
	return err
}
  • ExecContext nimmt den Context; wenn die HTTP-Request oben abgebrochen wird (Client schließt Verbindung), bricht der DB-Treiber die laufende Query ab — vermeidet unnötige Connection-Nutzung.
  • Regel: Jede Funktion, die I/O aufruft (HTTP, DB, gRPC), sollte einen Context empfangen und ihn an Context-unterstützende APIs weitergeben (mit *Context-Suffix).

8. Verhalten mit Tests überprüfen

func TestFetchFromMultipleSources_ReturnsFastestSource(t *testing.T) {
	ctx := context.Background()

	result, err := fetchFromMultipleSources(ctx)

	require.NoError(t, err)
	assert.Contains(t, []string{"cache", "db", "api"}, result)
}

func TestCallPaymentService_TimesOutWhenSlow(t *testing.T) {
	slowServer := httptest.NewServer(http.HandlerFunc(
		func(w http.ResponseWriter, r *http.Request) {
			time.Sleep(3 * time.Second)
		}))
	defer slowServer.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()

	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, slowServer.URL, nil)
	_, err := http.DefaultClient.Do(req)

	assert.ErrorIs(t, err, context.DeadlineExceeded)
}
<!-- go.mod: Assertion-Bibliothek hinzufügen (nicht XML, nur Notiz) -->
go get github.com/stretchr/testify
  • Der erste Test verwendet context.Background(), da kein Timeout-Kontrolle in normalen Unit-Tests nötig ist.
  • Der zweite Test setzt absichtlich ein 500ms-Timeout kürzer als die Server-Verarbeitungszeit (3s), um zu bestätigen, dass der zurückgegebene Fehler tatsächlich context.DeadlineExceeded ist.
  • httptest.NewServer erstellt einen Test-Server; kein komplexes Mocking erforderlich.

9. Häufige Fehler

  • cancel() nicht aufrufen: Rufe immer defer cancel() sofort nach WithCancel/WithTimeout/WithDeadline auf, auch wenn der Context von selbst abläuft — Go vet wird dich warnen, wenn du es vergisst.
  • Context in Struct-Feldern speichern: Context sollte als Funktionsparameter übergeben werden, nicht als Struct-Feld gespeichert (außer in Spezialfällen wie http.Request). Speichern im Feld führt leicht zu veralteten Contexts.
  • Context für erforderliche Parameter verwenden: Wenn eine Funktion nicht korrekt läuft, wenn dieser Wert fehlt, übergebe ihn als normalen Parameter, nicht versteckt im Context.
  • Neuen Context statt Parent verwenden: Wenn du context.Background() innerhalb einer Funktion aufrufst, die ctx vom Aufrufer erhält, verlierst du die Möglichkeit, von oben abzubrechen/timeout zu setzen — leite immer vom erhaltenen Context ab: context.WithTimeout(ctx, ...), nicht context.WithTimeout(context.Background(), ...).

Wann Context nicht nötig ist: Bei reinen Funktionen (pure functions) ohne I/O oder Goroutinen brauchst du keinen Context als Parameter — das fügt nur Rauschen hinzu.

10. Fazit

Context löst drei wesentliche Aufgaben in Go Concurrency: Cancellation-Signale, Zeitlimitierung und das Weitergeben einiger Werte durch mehrere Funktionsaufrufe. Die sicherste Regel ist: Empfange ctx immer als ersten Parameter, rufe immer defer cancel() auf und verwende WithValue nur für tatsächlich optionale Request-Werte. Wenn du diese Prinzipien beherrschst, schreibst du Go-Services ohne Goroutine-Leaks, die richtig reagieren, wenn der Client Timeout hat oder die Anfrage abbricht.

golangcontextconcurrencygoroutinetimeout