Go context.Context – poprawnie: anulowanie, limity czasu i wartości

Kontekst w Go to przepływ sterowania, nie miejsce przechowywania.

Page content

Golangowy context.Context jest wystarczająco prosty, aby używać go źle — i to właśnie jest problemem.

Większość developerów Go szybko opanowuje podstawowe zasady: przekazywanie context jako pierwszego argumentu, sprawdzanie ctx.Done(), używanie context.WithTimeout oraz nigdy nie przekazywanie nil.

func DoSomething(ctx context.Context) error {
	// ...
}

Te zasady są przydatne, ale pokrywają jedynie łatwą część. W usługach produkcyjnych context to nie tylko konwencja parametru — to płaszczyzna sterowania czasem życia żądania.

Go context: anulowanie propaguje się przez łańcuch wywołań

Context informuje pracę, kiedy ma się zatrzymać, ile czasu jej zostało, która ścieżka anulowania została podjęta oraz które wartości w zakresie żądania muszą podróżować przez granice API. Dobrze używany zapobiega wyciekom gorutin, unika marnowania pracy, propaguje terminy (deadlines) i ułatwia zamykanie usług. Złym użyciem staje się workiem ukrytych zależności, fałszywych globalnych, zapomnianych timeoutów, wyciekających timerów i mylącego zachowania przy anulowaniu.

Trochę bardziej zdawkowa wersja brzmiałaby tak: używaj context do anulowania, terminów i metadanych w zakresie żądania, a nie jako kontenera zależności.

Do czego służy context

Pakiet context ma trzy główne zadania — anulowanie, terminy i timeouyty oraz wartości w zakresie żądania — a te trzy zadania obejmują wszystko, do czego jest zaprojektowany.

Context powinien odpowiadać na pytania typu:

Czy to żądanie zostało anulowane?
Ile czasu zostało tej operacji?
Jaki identyfikator żądania powinien zostać dołączony do logów?
Z którym uwierzytelnionym użytkownikiem skojarzone jest to żądanie?

Context nie powinien odpowiadać na pytania typu:

Gdzie jest moje połączenie z bazą danych?
Gdzie jest mój logger?
Gdzie jest moja konfiguracja?
Jaką implementację usługi powinienem użyć?

To są zależności — przekazywaj je jawnie przez parametry funkcji (zobacz Iniekcja zależności w Go. dla wzorców czystego wykonywania tego zadania). Context służy do czasu życia żądania i metadanych żądania, a nie do spiętrzania aplikacji (wiringu).

Podstawowa struktura context

Interfejs rdzeniowy jest mały:

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}

Ważne części to:

  • Done() zamyka się, gdy context zostanie anulowany lub minie jego termin.
  • Err() wyjaśnia, dlaczego context się zakończył.
  • Deadline() informuje, czy context ma termin.
  • Value() przechowuje dane w zakresie żądania.

Większość kodu nie implementuje tego interfejsu. Pobiera context i przekazuje go niżej.

Pierwsza zasada: przekazywaj context jawnie

Dla funkcji wykonujących pracę w zakresie żądania lub anulowalną, przekazywaj context jako pierwszy parametr — to standardowa konwencja Go i tego oczekują wszystkie biblioteki i narzędzia w ekosystemie:

func GetUser(ctx context.Context, id string) (*User, error) {
	// ...
}

Rób tak dla funkcji, które mogą:

  • Wywołać bazę danych
  • Wywołać inną usługę
  • Czekać na kole
  • Rozpocząć pracę w tle
  • Blokować się na I/O
  • Używać timeoutu
  • Potrzebować wartości w zakresie żądania
  • Potrzebować anulowania

Nie dodawaj context do małych, czystych funkcji, które go nie potrzebują.

To jest w porządku:

func NormalizeEmail(email string) string {
	return strings.ToLower(strings.TrimSpace(email))
}

Nie każda funkcja potrzebuje contexta. Dodawanie context wszędzie sprawia, że kod staje się głośny.

Nie przechowuj contextu w strukturach

Przechowywanie contextu w strukturze to jeden z najczęstszych błędów w kodach Go i warto to wyraźnie podkreślić. Nie rób tego:

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

Zamiast tego rób tak:

type UserService struct {
	db *sql.DB
}

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

Context należy do żądania, operacji lub zadania, podczas gdy struktura usługi zazwyczaj żyje znacznie dłużej niż jakiekolwiek pojedyncze żądanie. Mieszanie tych czasów życia sprawia, że anulowanie jest niejasne, a rozumowanie o tym, do której operacji należy context, staje się trudne.

Istnieją rzadkie wyjątki dla typów, które naprawdę reprezentują czas życia pojedynczej operacji, ale są one tak rzadkie, że domyślna zasada powinna być prosta:

Przekazuj context. Nie przechowuj go.

Nie przekazuj nil jako context

Nigdy nie przekazuj nil jako context.

Źle:

err := svc.DoWork(nil)

Używaj context.Background(), gdy nie istnieje istniejący context:

err := svc.DoWork(context.Background())

W testach, jeśli to możliwe, używaj contextu testu:

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

Nil context może powodować panikę, gdy kod wywołuje na nim metody. Background context jest jawny i bezpieczny.

Background, TODO i request contexts

Istnieją trzy powszechne punkty startowe.

context.Background

Używaj context.Background() na najwyższym poziomie programu, gdy nie istnieje nadrzędny context — to jest contextem korzeniowym, z którego wyprowadzane są wszystkie dzieci:

func main() {
	ctx := context.Background()
	_ = run(ctx)
}

albo:

func TestSomething(t *testing.T) {
	ctx := context.Background()
	_ = ctx
}

context.TODO

Używaj context.TODO(), gdy wiesz, że powinien zostać użyty context, ale jeszcze nie zdecydowałeś, który.

ctx := context.TODO()

Jest to przydatne podczas migracji, ale nie powinno stać się trwałe, jeśli istnieje realny context.

Request context

W serwerach HTTP używaj contextu żądania:

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	_ = ctx
}

Context żądania jest anulowany, gdy połączenie klienta zamyka się, żądanie jest anulowane lub serwer kończy przetwarzanie żądania.

Dla usług webowych to zazwyczaj ten context, który należy przekazywać w dół do kodu aplikacji.

Anulowanie z context.WithCancel

Używaj context.WithCancel, gdy chcesz wprost zatrzymać pracę.

ctx, cancel := context.WithCancel(parent)
defer cancel()

Zwrócona funkcja cancel anuluje kontekst potomny i zwalnia zasoby z nim powiązane. Zawsze wywołuj ją, gdy skończysz — nawet jeśli context ostatecznie osiągnie timeout, wcześniejsze wywołanie cancel pozwala uniknąć trzymania zasobów dłużej niż to konieczne.

Przykład:

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

Wzorzec jest prosty:

  • Wyprowadź kontekst potomny.
  • Zadeklaruj defer cancel.
  • Przekazuj kontekst potomny do pracy, która powinna się zatrzymać razem.
  • Obserwuj ctx.Done().

Timeouyty z context.WithTimeout

Używaj context.WithTimeout, gdy operacja ma maksymalny czas trwania.

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

Przykład z klientem HTTP:

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

To sprawia, że timeout jest częścią operacji, a nie ukrytym ustawieniem globalnym.

Zawsze wywołuj cancel

Gdy wywołujesz WithCancel, WithTimeout lub WithDeadline, zawsze wywołuj zwróconą funkcję cancel — to ma znaczenie dla poprawności.

Dobrze:

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

Źle:

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

Niewywołanie cancel może utrzymywać timery i konteksty potomne w żywym stanie dłużej, niż to konieczne.

Kanały timerów są również częścią kontraktu: od Go 1.27 kanały za time.After i time.NewTimer są zawsze bez bufora, a stary opt-out asynctimerchan nie kompiluje się już przy wartości przed 1.23. Przewodnik po aktualizacji Go 1.27. wymienia to ustawienie wraz z innymi usuniętymi wartościami GODEBUG, które powodują błędy kompilacji.

Terminy (Deadlines) vs Timeouity

Timeout jest względny:

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

Termin (deadline) jest absolutny:

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

Większość kodu aplikacji używa timeoutów. Terminy są przydatne, gdy żądanie ma stały czas końcowy, który powinien być współdzielony między wieloma operacjami — na przykład, jeśli żądaniu zostało 900 milisekund, nie dawaj każdemu wywołaniu podrzędnemu świeżego timeoutu 1 sekundy; propaguj zamiast tego pozostały budżet.

Budżety timeoutów przez warstwy serwisowe

Powszechnym błędem jest ślepe nakładanie timeoutów.

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

Wygląda to niegroźnie, ale ukrywa rzeczywisty budżet. Warstwa serwisowa powinna zwykle respektować termin wywołującego, zamiast resetować timer do tej samej wartości.

Lepszym wzorcem jest:

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

A następnie wewnątrz serwisu:

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

Dodawaj timeout potomny tylko wtedy, gdy operacja podrzędna wymaga mniejszego budżetu:

func (s *Service) DoWork(ctx context.Context) error {
	queryCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
	defer cancel()

	return s.repo.Query(queryCtx)
}

Prawidłowy model myślowy jest prosty: całe żądanie ma jeden zewnętrzny budżet, konkretne operacje podrzędne mogą mieć mniejsze budżety wydzielone z tego budżetu, a żadna warstwa nie przedłuża żądania poza to, o co prosił wywołujący.

Sprawdzaj ctx.Err(), aby rozróżnić anulowanie od timeoutu

Gdy context się kończy, ctx.Err() zwraca powód.

Zazwyczaj jest to jedno z:

context.Canceled
context.DeadlineExceeded

Przykład:

select {
case <-ctx.Done():
	return ctx.Err()
case result := <-resultCh:
	return handle(result)
}

Pozwala to wywołującym rozróżnić anulowanie od timeoutu, a to rozróżnienie ma znaczenie w praktyce. Anulowane żądanie często oznacza, że klient rozłączył się, podczas gdy błąd przekroczenia terminu zwykle oznacza, że Twoja usługa była zbyt wolna — nie powinny być one zawsze logowane, ponawiane lub raportowane w ten sam sposób.

Używaj context.Cause dla lepszych powodów anulowania

Nowoczesny Go obsługuje również anulowanie z uwzględnieniem przyczyny (cause-aware cancellation).

Przydatne funkcje obejmują:

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

Zwykły ctx.Err() informuje o szerokim powodzie: anulowano lub przekroczono termin.

context.Cause(ctx) może podać bardziej szczegółową przyczynę.

Przykład:

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

Używaj anulowania z uwzględnieniem przyczyny, gdy powód ma znaczenie dla wywołujących, logów lub zachowania czyszczenia, i unikaj go tam, gdzie zwykły ctx.Err() wystarcza — dodatkowe szczegóły są warte tego tylko wtedy, gdy diagnostyka rzeczywiście tego wymaga.

Przykład serwera HTTP

Zwykły handler HTTP powinien zaczynać od r.Context(). Pełny przegląd struktury serwisów Go HTTP znajdziesz w Budowanie API REST w Go. Gin, Echo i Fiber każda zawijają context.Context wewnątrz własnego typu contextu żądania, a różnice między stackami są porównywane w Go Web Frameworks in 2026: net/http, chi, Gin, Echo, Fiber Compared.

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

Serwis powinien przyjmować i propagować context:

type UserService struct {
	repo *UserRepository
}

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

Repozytorium powinno używać metod bazy danych świadomych contextu:

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
}

Ważna rzecz to łańcuch — każda warstwa przekazuje ten sam context w dół do następnej:

flowchart TD A[HTTP request context] --> B[Handler] B --> C[Service] C --> D[Repository] D --> E[Database query]

Nie przerwij łańcucha, tworząc context.Background() w połowie.

Błąd z context.Background(): przerywanie łańcucha anulowania

To powszechny błąd:

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

To porzuca wszystkie informacje o anulowaniu i terminach od wywołującego. Jeśli klient rozłączy się, zapytanie do bazy danych nadal będzie działać. Jeśli żądanie osiągnie timeout, praca podrzędna może nadal być w toku. Jeśli serwer jest zamykany, ten kod całkowicie to ignoruje. Zastępowanie otrzymanego contextu context.Background() wewnątrz logiki biznesowej jest niemal zawsze błędem.

Używaj otrzymanego contextu:

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

Używaj context.Background() tylko na krawędzi, gdzie nie istnieje context nadrzędny.

Przykład klienta HTTP

Dla wychodzących żądań HTTP dołączaj context do żądania.

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

Nie rób tak:

req, err := http.NewRequest(http.MethodGet, endpoint, nil)

To tworzy żądanie bez contextu operacji.

Unikaj również polegania wyłącznie na http.Client.Timeout. Może być przydatne jako limit bezpieczeństwa, ale konteksty żądań dają lepszą propagację przez łańcuch wywołań.

Powszechnym wzorcem jest:

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

Używaj tego, gdy wywołanie API podrzędnego ma konkretny budżet wewnątrz większego żądania.

Przykład bazy danych

Większość API baz danych w Go ma metody świadome contextu. W szerszym ujęciu tego, jak biblioteki dostępu do danych Go obsługują context — w tym GORM, Ent, Bun i sqlc — zobacz Porównanie ORM-ów Go dla PostgreSQL.

Używaj ich.

Dobrze:

rows, err := db.QueryContext(ctx, query, args...)

Dobrze:

err := db.QueryRowContext(ctx, query, id).Scan(&name)

Dobrze:

result, err := db.ExecContext(ctx, query, args...)

Źle:

rows, err := db.Query(query, args...)

Formy świadome contextu pozwalają operacjom bazy danych na zatrzymanie się, gdy żądanie zostanie anulowane lub osiągnie timeout, co jest szczególnie ważne dla powolnych zapytań, przeciążonych baz danych i API skierowanych do użytkownika, gdzie opóźnienia bezpośrednio wpływają na doświadczenie użytkownika.

Transakcje i context

Transakcje wymagają ostrożnego obsługi contextu.

Transakcja powinna zazwyczaj rozpoczynać się z contextem operacji:

tx, err := db.BeginTx(ctx, nil)
if err != nil {
	return err
}
defer tx.Rollback()

Następnie używaj tego samego contextu dla operacji transakcji:

if _, err := tx.ExecContext(ctx, query, args...); err != nil {
	return err
}

if err := tx.Commit(); err != nil {
	return err
}

Bądź ostrożny z timeoutami wokół transakcji. Jeśli context zostanie anulowany przed Commit, transakcja może zostać cofnięta. Może to być to, czego chcesz, ale powinno to być celowe.

Dla długich transakcji lepszą odpowiedzią jest zwykle nie dłuższy timeout — ale krótsza transakcja wykonująca mniej pracy na jednostkę.

Background workers i context

Pracownicy tła (background workers) powinni otrzymywać context reprezentujący ich czas życia.

Przykład:

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

Ten worker zatrzymuje się czysto, gdy context zostanie anulowany, a jego ticker jest odpowiednio czyszczony przez defer ticker.Stop(). W main utworzyłbyś context korzeniowy powiązany z sygnałami systemu operacyjnego:

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

To jest poprawne użycie contextu: opisuje on czas życia pracy procesu, a gdy system operacyjny wyśle sygnał, całe drzewo gorutin dzielących ten context zatrzyma się razem.

Zapobieganie wyciekom gorutin za pomocą anulowania context

Wyciek gorutiny (goroutine leak) występuje, gdy gorutina pozostaje zablokowana na zawsze, po tym, gdy nie jest już użyteczna.

Context pomaga temu zapobiec.

Źle:

func StartWorker() {
	go func() {
		for {
			doWork()
			time.Sleep(time.Second)
		}
	}()
}

Ta gorutina nie ma ścieżki zamknięcia.

Lepiej:

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

Dowolna gorutina, która pętli, powinna niemal zawsze mieć ścieżkę anulowania.

Nie oznacza to, że każda gorutina musi otrzymywać context bezpośrednio, ale system powinien mieć jasny sposób na jej zatrzymanie.

context.AfterFunc

context.AfterFunc uruchamia funkcję po anulowaniu context.

Może być przydatne do czyszczenia, odblokowywania operacji lub łączenia API, które nie obsługują natywnie context.

Przykład:

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

Używaj AfterFunc ostrożnie — uruchamia logikę, gdy następuje anulowanie, co może uczynić przepływ sterowania trudniejszym do podążenia. Dla większości kodu aplikacji zwykły select na ctx.Done() jest czytelniejszy i łatwiejszy do rozumowania. AfterFunc jest najbardziej wartościowy, gdy trzeba zaadaptować anulowanie contextu do API, które nie przyjmuje jeszcze contextu.

context.WithoutCancel

context.WithoutCancel tworzy context, który nie jest anulowany, gdy rodzic zostaje anulowany.

Jest to przydatne, ale też łatwe do błędnego użycia.

Przykład użycia:

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

Idea polega na tym, że zapis audytu może potrzebować krótko trwać dalej, nawet po anulowaniu contextu żądania. Powinno to być rzadkie i celowe — nie używaj WithoutCancel jako sposobu na unikanie zajmowania się anulowaniem. Używaj go tylko wtedy, gdy praca potomna naprawdę musi przetrwać anulowanie rodzica, i zawsze dodawaj nowy timeout: context ignorujący anulowanie, ale nie niosący żadnego terminu, może łatwo stworzyć wycieki gorutin tła.

Wartości contextu zrobione dobrze

Wartości contextu są przeznaczone dla danych w zakresie żądania, które przekraczają granice API.

Dobre przykłady:

  • identyfikator żądania (request ID)
  • identyfikator śledzenia (trace ID)
  • identyfikator uwierzytelnionego użytkownika
  • identyfikator najemcy (tenant ID)
  • lokalizacja (locale)
  • podmiot bezpieczeństwa (security principal)
  • metadane korelacyjne

Złe przykłady:

  • połączenie z bazą danych
  • logger jako ukryta zależność
  • flagi funkcjonalne do zwykłego sterowania przepływem
  • opcjonalne parametry funkcji
  • konfiguracja
  • klienci usług

Przydatna zasada: jeśli wartość jest częścią tożsamości żądania lub kontekstu obserwowalności, może należeć do context. Jeśli jest to zależność, której kod potrzebuje, aby wykonać swoją pracę, przekazuj ją jawnie.

Używaj typowanych kluczy dla wartości context

Nie używaj zwykłych stringów jako kluczy context.

Źle:

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

Może to kolidować z innymi pakietami.

Używaj nieeksportowanego, własnego typu klucza:

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
}

Ten wzorzec daje bezpieczeństwo typów na granicy pakietu, unika kolizji kluczy z innymi pakietami i utrzymuje czystą powierzchnię API contextu z typowanymi funkcjami dostępowymi.

Nie używaj wartości context dla opcjonalnych parametrów

To jest źle:

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

To ukrywa kontrakt funkcji.

Wol jawnie parametry:

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

Wartości contextu nie powinny zastępować argumentów funkcji. Ukryty input sprawia, że kod jest trudniejszy do zrozumienia, testowania i przeglądu — a ktokolwiek czytający sygnaturę funkcji nie będzie miał pojęcia, że taki parametr w ogóle istnieje.

Logowanie i context

Istnieją dwa powszechne podejścia do logowania z context. Przykłady tutaj używają pakietu log/slog Go — po głębsze wejście w strukturalne logowanie z slog w usługach produkcyjnych, zobacz Strukturalne logowanie w Go z slog.

Podejście 1: Wyodrębnij wartości i dołącz je do logów

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

To utrzymuje loggera jako jawną, prawidłową zależność i używa context tylko dla wartości w zakresie żądania, które legalnie muszą przekraczać granice API.

Podejście 2: Przechowuj loggera w context

Niektóre bazy kodu przechowują loggera w context.

Może to być wygodne, ale nie zalecam tego jako domyślnego. Zamienia context w kontener zależności.

Moja preferencja:

  • Przekazuj zależności loggera jawnie.
  • Przechowuj identyfikatory trace i identyfikatory żądań w context.
  • Dodawaj te wartości do logów na granicach lub w middleware.

To utrzymuje zależności widzialne.

Context i śledzenie (tracing)

Śledzenie jest jednym z najsilniejszych przypadków użycia wartości context i jest to naprawdę dobre dopasowanie. OpenTelemetry i podobne systemy używają context do propagacji spanów trace przez wywołania funkcji i granice procesów, ponieważ dane trace to dokładnie ten rodzaj metadanych w zakresie żądania, do przenoszenia których context był zaprojektowany.

Typowy wzorzec wygląda tak:

func (s *Service) DoWork(ctx context.Context) error {
	ctx, span := s.tracer.Start(ctx, "Service.DoWork")
	defer span.End()

	return s.repo.Query(ctx)
}

Context niesie aktywny span trace, a repozytorium może utworzyć z niego span potomny. Każda warstwa dodaje własny span bez jawnej transmisji obiektów traserów — context wykonuje tę pracę przejrzysto przez całe drzewo wywołań.

Obsługa błędów z context

Gdy operacja przestaje działać z powodu anulowania contextu, zachowaj te informacje. Wzorce tutaj uzupełniają szersze strategie projektowania błędów opisane w Architektura obsługi błędów w Go.

Przykład:

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
}

Nie owijaj ślepo błędów context w sposób, który je ukrywa.

Owijanie z %w zachowuje errors.Is, więc wywołujący nadal mogą wykryć anulowanie lub timeout:

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

Zastąpienie błędu całkowicie porzuca te informacje i łamie dowolnego wywołującego, który sprawdza konkretne typy błędów context:

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

Mapowanie błędów context na odpowiedzi HTTP

Błędy context często mapują się na różne wyniki HTTP.

Przykład:

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

Nie traktuj anulowania klienta jako niepowodzenia aplikacji — jeśli użytkownik zamknął kartę przeglądarki, to nie Twoja usługa źle działa, a logowanie tego jako błędu dodaje szumu bez sygnału.

Context w middleware

Middleware HTTP to powszechne miejsce dodawania wartości w zakresie żądania.

Przykład middleware identyfikatora żądania:

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

To jest dobre użycie context. Identyfikator żądania należy do żądania, powinien podróżować przez pełny łańcuch wywołań, a dołączanie go do logów i trace w każdej warstwie to dokładnie taki rodzaj poprzecznego zmartwienia obserwowalności, na które wartości context są zaprojektowane.

Context w testach

W testach unikaj ślepego używania context.Background().

Wol t.Context(), gdy praca należy do czasu życia testu:

func TestService(t *testing.T) {
	ctx := t.Context()

	err := service.DoWork(ctx)
	if err != nil {
		t.Fatal(err)
	}
}

Dla zachowania timeoutów, testuj z realnym timeoutem tylko wtedy, gdy timeout jest mały i znaczący.

Dla kodu równoległego i zależnego od czasu, rozważ użycie testing/synctest — Testowanie współbieżnego kodu Go z synctest. omawia to narzędzie w głębi:

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

Pozwala to testować rzeczywiste wartości timeoutów bez czekania na rzeczywisty czas.

Context i errgroup

Dla grup gorutin, które powinny zostać anulowane razem, errgroup jest często dobrym dopasowaniem.

errgroup koordynuje anulowanie, ale samo w sobie niczego nie ogranicza: bez limitu pętla tysiąc elementowych uruchomi tysiąc gorutin. Po stronie pojemności — SetLimit, kanały semaforyczne, pulę workerów i kształt wycieku, który przetrwa Wait — zobacz Ograniczanie współbieżności w Go.

Przykład:

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

Jeśli jedna gorutina zwróci błąd, context grupy zostanie anulowany, a inne gorutiny respektujące ctx.Done() mogą wczesniej się zatrzymać. Jest to znacznie czystsze niż ręczne zarządzanie wieloma gorutinami, kanałami i ścieżkami anulowania. Kluczowym frazą tutaj jest “respektuj context” — errgroup nie może zatrzymać pracy, która ignoruje ctx.Done().

Łagodne wyłączenie (Graceful shutdown)

Context jest centralny dla łagodnego wyłączenia.

Typowa konfiguracja serwera ma:

  • context korzeniowy anulowany przez sygnały OS
  • serwer HTTP
  • pracowników tła
  • timeout wyłączenia
  • logikę czyszczenia

Przykład:

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

Zauważ, że context wyłączenia nie jest ten sam co context korzeniowy — korzeń jest już anulowany, gdy przyjdzie sygnał OS. Osobny context timeoutu daje procesowi wyłączenia ograniczoną ilość czasu na drenowanie żądań w toku przed przymusowym wyjściem, co jest subtelną, ale ważną różnicą, dzięki której łagodne wyłączenie naprawdę działa.

Powszechne antywzorce

Antywzorzec 1: Używanie context jako kontenera zależności

Źle:

ctx = context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "logger", logger)
ctx = context.WithValue(ctx, "config", cfg)

Przekazuj zależności jawnie.

Antywzorzec 2: Tworzenie context.Background wewnątrz logiki biznesowej

Źle:

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

To łamie propagację anulowania.

Antywzorzec 3: Zapominanie o cancel

Źle:

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

Dobrze:

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

Antywzorzec 4: Umieszczanie opcjonalnych parametrów w context

Źle:

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

Używaj jawnych struktur opcji.

Antywzorzec 5: Przekazywanie context zbyt głęboko do czystego kodu

Źle:

func Add(ctx context.Context, a, b int) int {
	return a + b
}

Czysta kalkulacja nie potrzebuje context, chyba że jest długotrwała lub anulowalna.

Antywzorzec 6: Ignorowanie anulowania w pętlach

Źle:

for item := range items {
	process(item)
}

Lepiej:

for item := range items {
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	if err := process(ctx, item); err != nil {
		return err
	}
}

Antywzorzec 7: Połykanie błędów context

Źle:

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

Dobrze:

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

Zachowuj błędy anulowania i deadline’ów.

Praktyczna checklista context

Użyj tej checklisty dla kodu backendowego w Go.

Sygnatury funkcji

  • Context jest pierwszym parametrem.
  • Context nie jest przechowywany w długowiecznych strukturach.
  • Context nie jest przekazywany do czystych funkcji pomocniczych, chyba że to konieczne.
  • Nil context nigdy nie jest używany.

Anulowanie

  • Długotrwałe pętle sprawdzają ctx.Done().
  • Gorutiny mają ścieżkę zamknięcia.
  • Czasy życia workerów są powiązane z contextem nadrzędnym.
  • Anulowanie contextu jest propagowane do wywołań podrzędnych.

Timeouity

  • Zewnętrzne timeouity żądań są ustawiane na granicy.
  • Timeouity operacji podrzędnych są mniejsze niż zewnętrzny budżet.
  • Funkcje cancel są zawsze wywoływane.
  • Timeouity nie są ślepo nakładane w każdej warstwie.

Wartości

  • Wartości context są w zakresie żądania.
  • Klucze używają własnych typów, a nie zwykłych stringów.
  • Zależności nie są przechowywane w context.
  • Opcjonalne parametry nie są przechowywane w context.

Błędy

  • context.Canceled i context.DeadlineExceeded są zachowane.
  • Błędy context są poprawnie mapowane na granicach API.
  • Anulowanie z uwzględnieniem przyczyny jest używane tylko wtedy, gdy powód ma znaczenie.

Testy

  • Testy używają t.Context(), gdzie to stosowne.
  • Testy timeoutów unikają wolnych, rzeczywistych snów.
  • Współbieżne zachowanie timeoutów jest testowane z testing/synctest, gdy to użyteczne.
  • Wycieki gorutin są sprawdzane poprzez zapewnienie istnienia ścieżek zamknięcia.

Jak audytować użycie context w bazie kodu Go

Szukaj tych wzorców:

grep -R "context.Background()" .
grep -R "context.TODO()" .
grep -R "WithTimeout" .
grep -R "WithCancel" .
grep -R "WithValue" .
grep -R "type .* struct" .

Następnie zapytaj:

  • Czy context.Background() jest używany tylko na najwyższych granicach?
  • Czy funkcje cancel są zawsze wywoływane?
  • Czy timeouity są umieszczone w rozsądnych granicach?
  • Czy wartości context są naprawdę w zakresie żądania?
  • Czy zależności są ukryte w wartościach context?
  • Czy gorutiny są zatrzymywane?
  • Czy błędy context są zachowane?

To jest dobry nawyk przeglądania kodu, ponieważ wiele błędów context to nie są błędy składniowe — to są błędy czasu życia, które pojawiają się tylko pod anulowaniem, obciążeniem lub warunkami wyłączenia.

Moje zdawkowe zasady

Te zasady są nudne, ale działają.

Zasada 1: Context jest przepływem sterowania

Używaj context do sterowania anulowaniem, terminami i metadanymi żądań.

Nie używaj go do przemytu zależności.

Zasada 2: Wywołujący posiada budżet

Funkcja powinna zazwyczaj respektować otrzymany context.

Twórz krótszy timeout potomny tylko wtedy, gdy operacja podrzędna wymaga konkretnego mniejszego budżetu.

Zasada 3: Background należy na krawędzi

Używaj context.Background() w main, testach i najwyższym poziomie konfiguracji.

Nie używaj go wewnątrz metod serwisowych i repozytoriów, aby uciec od anulowania.

Zasada 4: Wartości powinny być nudne

Request ID, trace ID, user ID i tenant ID należą do context. Połączenia z bazą danych, loggery, struktury konfiguracji i klienci usług nie — to są zależności i powinny być przekazywane jawnie.

Zasada 5: Każda gorutina potrzebuje czasu życia

Jeśli gorutyna się zaczyna, powinieneś dokładnie wiedzieć, jak się kończy. Context jest często odpowiednią odpowiedzią, a jeśli to nie context, powinien istnieć jakiś inny jasny mechanizm — kanał, prymityw synchronizacji lub jawny sygnał.

Ostatnie myśli

context.Context nie jest skomplikowane, ponieważ API jest duże — API jest małe. Jest skomplikowane, ponieważ reprezentuje czas życia, a czas życia to architektura. Każda decyzja o tym, gdzie context płynie, gdzie jest wyprowadzany i gdzie się zatrzymuje, to decyzja o tym, jak Twoja usługa obsługuje niepowodzenia, obciążenia i wyłączenia.

Dobrze używany context sprawia, że usługi Go są łatwiejsze do anulowania, łatwiejsze do wyłączenia, łatwiejsze do obserwowania i mniej podatne na wycieki gorutin. Źle używany context ukrywa zależności, porzuca terminy i sprawia, że kod jest trudniejszy do zrozumienia pod presją.

Praktyczna wnioskuje jest prosta:

Przekazuj context w dół.
Nie przechowuj go.
Nie zastępuj jawnych parametrów wartościami.
Respektuj anulowanie.
Używaj timeoutów na granicach.
Zawsze wywołuj cancel.

To jest Go context zrobione dobrze.

Ten artykuł jest częścią klastrowej Architektura aplikacji w produkcji, który obejmuje strukturę kodu, dostęp do danych, wzorce integracji i architekturę testowania dla systemów produkcyjnych w Go i Pythonie.

Źródła

Subskrybuj

Otrzymuj nowe wpisy o systemach, infrastrukturze i inżynierii AI.