Context w Go: Cancellation, Timeouts i Values
Kontekst w Go służy do sterowania przepływem sterowania, a nie do przechowywania danych.
Interfejs context.Context w języku Go jest wystarczająco prosty, by można go było użyć błędnie — i to właśnie jest problem.
Większość programistów Go szybko poznaje podstawowe zasady: przekazywanie kontekstu jako pierwszego argumentu, sprawdzanie ctx.Done(), używanie context.WithTimeout i nigdy nie przekazywanie nil.
func DoSomething(ctx context.Context) error {
// ...
}
Te zasady są przydatne, ale pokrywają tylko prostą część. W usługach produkcyjnych kontekst to nie tylko konwencja parametru — to warstwa sterująca czasem życia żądania.

Kontekst informuje pracę, kiedy ma się zatrzymać, ile czasu jeszcze zostało, która ścieżka anulowania została wybrana oraz które wartości zakreślone żądaniem muszą przekraczać granice API. Przy dobrym użyciu zapobiega wyciekom gorutin, unika marnowania pracy, propaguje terminy i ułatwia wyłączanie usług. Przy złym użyciu staje się zbiorem ukrytych zależności, fałszywych zmiennych globalnych, zapomnianych limitów czasowych, wyciekających timerów i mylącej logiki anulowania.
Nieco subiektywna wersja brzmi tak: używaj kontekstu do anulowania, terminów i metadanych zakreślonych żądaniem, a nie jako kontenera zależności.
Do czego służy kontekst
Pakiet context ma trzy główne zadania — anulowanie, terminy i limity czasowe oraz wartości zakreślone żądaniem — i te trzy zadania obejmują wszystko, do czego został zaprojektowany.
Kontekst powinien odpowiadać na pytania takie jak:
Czy to żądanie zostało anulowane?
Ile czasu zostało na tę operację?
Jaki identyfikator żądania (request ID) powinien być dołączony do logów?
Jaki uwierzytelniony użytkownik jest powiązany z tym żądaniem?
Kontekst nie powinien odpowiadać na pytania takie jak:
Gdzie jest moje połączenie z bazą danych?
Gdzie jest mój logger?
Gdzie jest moja konfiguracja?
Jaki implementacja usługi mam użyć?
Te elementy to zależności — przekazyuj je jawnie przez parametry funkcji (zobacz Dependency Injection in Go w celu poznania wzorców czystego ich stosowania). Kontekst służy do zarządzania czasem życia żądania i metadanymi żądania, a nie do okablowania aplikacji.
Podstawowa struktura kontekstu
Podstawowy interfejs jest mały:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Najważniejsze elementy to:
Done()jest zamykane, gdy kontekst zostanie anulowany lub jego termin wygaśnie.Err()wyjaśnia, dlaczego kontekst się zakończył.Deadline()informuje, czy kontekst ma ustawiony termin.Value()przechowuje dane zakreślone żądaniem.
Większość kodu nie implementuje tego interfejsu. Otrzymuje kontekst i przekazuje go dalej.
Pierwsza zasada: przekazywanie kontekstu jawnie
Dla funkcje wykonujących pracę zakreśloną żądaniem lub możliwą do anulowania, przekazuj kontekst jako pierwszy parametr — jest to standardowa konwencja Go i tego, czego oczekuje każda biblioteka i narzędzie w ekosystemie:
func GetUser(ctx context.Context, id string) (*User, error) {
// ...
}
Rób to dla funkcji, które mogą:
- Wywoływać bazę danych
- Wywoływać inną usługę
- Czekać na kolejkę
- Rozpoczynać pracę w tle
- Blokować się przy I/O
- Używać limitu czasu
- Potrzebować wartości zakreślonych żądaniem
- Potrzebować anulowania
Nie dodawaj kontekstu do małych, czystych funkcji, które go nie potrzebują.
To jest poprawne:
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
Nie każda funkcja potrzebuje kontekstu. Dodawanie kontekstu wszędzie sprawia, że kod jest nieczytelny.
Nie przechowuj kontekstu w strukturach
Przechowywanie kontekstu w strukturze to jeden z najczęstszych błędów w kodzie Go, dlatego warto go wyraźnie wskazać. Nie rób tego:
type UserService struct {
ctx context.Context
db *sql.DB
}
Zamiast tego zrób tak:
type UserService struct {
db *sql.DB
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
// ...
}
Kontekst 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 i utrudnia rozważania nad tym, do której operacji należy kontekst.
Istnieją rzadkie wyjątki dla typów, które naprawdę reprezentują czas życia pojedynczej operacji, ale są one na tyle rzadkie, że domyślna zasada powinna być prosta:
Przekazuj kontekst. Nie przechowuj go.
Nie przekazuj kontekstu nil
Nigdy nie przekazuj nil jako kontekstu.
Źle:
err := svc.DoWork(nil)
Używaj context.Background(), gdy nie ma istniejącego kontekstu:
err := svc.DoWork(context.Background())
W testach używaj kontekstu testowego, gdy to możliwe:
func TestDoWork(t *testing.T) {
err := svc.DoWork(t.Context())
if err != nil {
t.Fatal(err)
}
}
Kontekst nil może powodować panic, gdy kod wywołuje na nim metody. Kontekst tła jest jawny i bezpieczny.
Konteksty Background, TODO i żądania
Istnieją trzy wspólne punkty startowe.
context.Background
Używaj context.Background() na najwyższym poziomie programu, gdy nie istnieje kontekst nadrzędny — jest to kontekst korzeniowy, z którego wyprowadzane są wszystkie konteksty potomne:
func main() {
ctx := context.Background()
_ = run(ctx)
}
lub:
func TestSomething(t *testing.T) {
ctx := context.Background()
_ = ctx
}
context.TODO
Używaj context.TODO(), gdy wiesz, że należy użyć kontekstu, ale jeszcze nie zdecydowałeś, który.
ctx := context.TODO()
Jest to przydatne podczas migracji, ale nie powinno stać się stałym rozwiązaniem, jeśli istnieje prawdziwy kontekst.
Kontekst żądania
W serwerach HTTP używaj kontekstu żądania:
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_ = ctx
}
Kontekst żądania jest anulowany, gdy połączenie klienta zostanie zamknięte, żądanie zostanie anulowane lub serwer zakończy obsługę żądania.
Dla usług webowych jest to zazwyczaj kontekst, który należy przekazywać do kodu aplikacji.
Anulowanie za pomocą context.WithCancel
Używaj context.WithCancel, gdy chcesz wyraźnie zatrzymać pracę.
ctx, cancel := context.WithCancel(parent)
defer cancel()
Zwracana funkcja cancel anuluje kontekst potomny i zwalnia powiązane z nim zasoby. Zawsze wywołuj ją, gdy skończysz — nawet jeśli kontekst ostatecznie wygaśnie, wcześniejsze wywołanie cancel unika trzymania zasobów w pamięci dłużej niż jest 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.
- Odłóż anulowanie (defer cancel).
- Przekazaj kontekst potomny do pracy, która powinna się zatrzymać razem.
- Obserwuj
ctx.Done().
Limity czasu 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 limit czasu jest częścią operacji, a nie ukrytym ustawieniem globalnym.
Zawsze wywołuj cancel
Gdy wywołujesz WithCancel, WithTimeout lub WithDeadline, zawsze wywołuj zwracaną funkcję cancel — ma to znaczenie dla poprawności.
Dobrze:
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
Źle:
ctx, _ := context.WithTimeout(parent, 5*time.Second)
Nie wywołanie cancel może utrzymać timery i konteksty potomne w żywym stanie dłużej niż jest to potrzebne.
Terminy vs limity czasu
Limit czasu (timeout) jest względny:
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
Termin (deadline) jest bezwzględny:
deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()
Większość kodu aplikacji używa limitów czasu. Terminy są przydatne, gdy żądanie ma stały czas zakończenia, który powinien być dzielony między wiele operacji — na przykład, jeśli żądanie ma 900 milisekund, nie nadawaj każdemu wywołaniu w dół świeży limit 1 sekundy; zamiast tego propaguj pozostałą pulę czasu.
Pule limitów czasu warstw usług
Powszechnym błędem jest ślepe nakładanie limitów czasu.
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)
}
To wygląda nieszkodliwie, ale ukrywa prawdziwą pulę czasu. Warstwa usługi powinna zazwyczaj szanować 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
}
}
Następnie wewnątrz usługi:
func (s *Service) DoWork(ctx context.Context) error {
return s.repo.Query(ctx)
}
Dodaj limit czasu potomny tylko wtedy, gdy podoperacja potrzebuje mniejszej puli:
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 umysłowy jest prosty: całe żądanie ma jedną zewnętrzną pulę czasu, określone podoperacje mogą mieć mniejsze pule wydzielone z tej puli, a żadna warstwa nie przedłuża żądania w sposób niezauważalny poza tym, co wywołujący zamierzył.
Sprawdź ctx.Err(), aby rozróżnić anulowanie od przekroczenia limitu czasu
Gdy kontekst się kończy, ctx.Err() zwraca przyczynę.
Zazwyczaj jest to jeden 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 przekroczenia limitu czasu, a to rozróżnienie ma znaczenie w praktyce. Anulowane żądanie często oznacza, że klient rozłączył się, podczas gdy błąd przekroczenia limitu czasu zwykle oznacza, że Twoja usługa była zbyt wolna — nie powinny one zawsze być logowane, ponawiane lub zgłaszane w ten sam sposób.
Użyj context.Cause dla lepszych powodów anulowania
Nowoczesny Go obsługuje również anulowanie świadome przyczyny (cause-aware cancellation).
Przydatne funkcje obejmują:
context.WithCancelCausecontext.WithTimeoutCausecontext.WithDeadlineCausecontext.Cause
Zwykłe ctx.Err() mówi Ci ogólną przyczynę: anulowane lub przekroczony limit czasu.
context.Cause(ctx) może podać bardziej specyficzną 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 świadomego przyczyny, gdy przyczyna ma znaczenie dla wywołujących, logów lub zachowania sprzątania, i unikaj go tam, gdzie zwykłe ctx.Err() wystarczy — dodatkowy szczegół ma sens tylko wtedy, gdy diagnoza naprawdę tego wymaga.
Przykład serwera HTTP
Normalny handler HTTP powinien zaczynać się od r.Context(). Aby zobaczyć pełny przewodnik po strukturze usług HTTP w Go, zobacz Building REST APIs in Go.
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)
}
}
Usługa powinna akceptować i propagować kontekst:
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 kontekstu:
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 jest tu ścieżka — każda warstwa przekazuje ten sam kontekst do następnej:
Nie przerywaj łańcucha, tworząc context.Background() w połowie.
Błąd context.Background(): przerywanie łańcucha anulowania
To częsty błąd:
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(context.Background(), id)
}
To odrzuca wszystkie informacje o anulowaniu i terminach od wywołującego. Jeśli klient rozłączy się, zapytanie do bazy danych będzie nadal działać. Jeśli żądanie przekroczy limit czasu, praca w dół może nadal być w toku. Jeśli serwer się wyłącza, ten kod całkowicie to ignoruje. Zastępowanie otrzymanego kontekstu context.Background() w logice biznesowej jest prawie zawsze błędem.
Używaj kontekstu, który otrzymałeś:
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 kontekst nadrzędny.
Przykład klienta HTTP
Dla wychodzących żądań HTTP, dołącz kontekst 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 tego:
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
To tworzy żądanie bez kontekstu operacji.
Unikaj też polegania tylko na http.Client.Timeout. Może być przydatne jako limit bezpieczeństwa, ale konteksty żądań dają lepszą propagację przez łańcuch wywołań.
Wspólnym 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 w dół ma specyficzną pulę czasu w ramach większego żądania.
Przykład bazy danych
Większość API baz danych w Go ma metody świadome kontekstu. Aby uzyskać szerszy wgląd w to, jak biblioteki dostępu do danych w Go obsługują kontekst — w tym GORM, Ent, Bun i sqlc — zobacz Comparing Go ORMs for 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 kontekstu pozwalają operacjom bazy danych zatrzymać się, gdy żądanie zostanie anulowane lub przekroczy limit czasu, co jest szczególnie ważne dla powolnych zapytań, przeciążonych baz danych i API skierowanych do użytkowników, gdzie opóźnienie bezpośrednio wpływa na doświadczenie użytkownika.
Transakcje i kontekst
Transakcje wymagają ostrożnej obsługi kontekstu.
Transakcja powinna zazwyczaj zaczynać się z kontekstem operacji:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
Następnie używaj tego samego kontekstu dla operacji transakcyjnych:
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
Bądź ostrożny z limitami czasu wokół transakcji. Jeśli kontekst zostanie anulowany przed Commit, transakcja może zostać cofnięta. To może być to, czego chcesz, ale powinno być świadome.
Dla długich transakcji lepszą odpowiedzią jest zwykle nie dłuższy limit czasu — lecz krótsza transakcja wykonująca mniej pracy w jednostce czasu.
Workerzy w tle i kontekst
Workerzy w tle powinni otrzymywać kontekst 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 kontekst zostanie anulowany, a jego ticker jest odpowiednio sprzątany przez defer ticker.Stop(). W main utworzyłbyś kontekst 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 kontekst użyty poprawnie: opisuje czas życia pracy procesu, a gdy system operacyjny wysyła sygnał, całe drzewo gorutin współdzielących ten kontekst zatrzymuje się razem.
Zapobieganie wyciekom gorutin za pomocą anulowania kontekstu
Wyciek gorutiny występuje, gdy gorutyna pozostaje zablokowana na zawsze po tym, jak przestaje być przydatna.
Kontekst pomaga temu zapobiec.
Źle:
func StartWorker() {
go func() {
for {
doWork()
time.Sleep(time.Second)
}
}()
}
Ta gorutyna nie ma ścieżki wyłączania.
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()
}
}
}()
}
Każda gorutyna w pętli powinna prawie zawsze mieć ścieżkę anulowania.
Nie oznacza to, że każda gorutyna musi bezpośrednio otrzymać kontekst, ale system powinien mieć jasny sposób na jej zatrzymanie.
context.AfterFunc
context.AfterFunc uruchamia funkcję po anulowaniu kontekstu.
Może być przydatne do sprzątania, odblokowywania operacji lub mostkowania API, które nie obsługują natywnie kontekstu.
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 utrudnić śledzenie przepływu sterowania. Dla większości kodu aplikacji zwykłe select na ctx.Done() jest czytelniejsze i łatwiejsze do analizy. AfterFunc jest najbardziej wartościowe, gdy potrzebujesz dostosować anulowanie kontekstu do API, które nie akceptuje już kontekstu.
context.WithoutCancel
context.WithoutCancel tworzy kontekst, który nie jest anulowany, gdy rodzic jest anulowany.
Jest to przydatne, ale也容易 do nadużycia.
Przykład przypadku 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")
}()
}
}
Pomysł polega na tym, że zapis audytowy może potrzebować kontynuowania przez krótki czas nawet po anulowaniu kontekstu żądania. Powinno to być rzadkie i świadome — nie używaj WithoutCancel jako sposobu na unikanie radzenia sobie z anulowaniem. Używaj go tylko wtedy, gdy praca potomna musi naprawdę przeżyć anulowanie rodzica, i zawsze dodawaj nowy limit czasu: kontekst ignorujący anulowanie, ale nie przenoszący terminu, może łatwo stworzyć wycieki gorutin w tle.
Wartości kontekstu zrobione dobrze
Wartości kontekstu służą do danych zakreślonych żądaniem, które przekraczają granice API.
Dobre przykłady:
- identyfikator żądania (request ID)
- identyfikator śladu (trace ID)
- identyfikator uwierzytelnionego użytkownika
- identyfikator tenant’a
- lokalizacja (locale)
- podmiot bezpieczeństwa (security principal)
- metadane korelacyjne
Złe przykłady:
- połączenie z bazą danych
- logger jako ukryta zależność
- flagi funkcjonalności do zwykłego przepływu sterowania
- 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 kontekstu. Jeśli jest to zależność, której Twój kod potrzebuje do wykonania pracy, przekazuj ją jawnie.
Używaj typowanych kluczy dla wartości kontekstu
Nie używaj zwykłych stringów jako kluczy kontekstu.
Źle:
ctx = context.WithValue(ctx, "userID", "123")
To może kolidować z innymi pakietami.
Używaj nieeksportowanego, niestandardowego 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 Ci bezpieczeństwo typów na granicy pakietu, unika kolizji kluczy z innymi pakietami i utrzymuje powierzchnię API kontekstu czystą za pomocą typowanych funkcji dostępu.
Nie używaj wartości kontekstu do opcjonalnych parametrów
To jest źle:
ctx = context.WithValue(ctx, "pageSize", 100)
users, err := repo.ListUsers(ctx)
To ukrywa kontrakt funkcji.
Preferuj jawne parametry:
users, err := repo.ListUsers(ctx, ListUsersOptions{
PageSize: 100,
})
Wartości kontekstu nie powinny zastępować argumentów funkcji. Ukryty input sprawia, że kod jest trudniejszy do zrozumienia, testowania i przeglądania — a ktokolwiek czytający sygnaturę funkcji nie będzie miał pojęcia, że parametr w ogóle istnieje.
Logowanie i kontekst
Istnieją dwa wspólne podejścia do logowania z kontekstem. Przykłady tutaj używają pakietu Go log/slog — aby uzyskać głębszy wgląd w strukturalne logowanie ze slog w usługach produkcyjnych, zobacz Structured Logging in Go with slog.
Podejście 1: Wydobądź 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 logger jawnie jako odpowiednią zależność i używa kontekstu tylko do wartości zakreślonych żądaniem, które legitymnie muszą przekraczać granice API.
Podejście 2: Przechowuj logger w kontekście
Niektóre kodobazy przechowują logger w kontekście.
To może być wygodne, ale nie polecam tego jako domyślnego. Przemienia kontekst w kontener zależności.
Moja preferencja:
- Przekazuj zależności loggera jawnie.
- Przechowuj identyfikatory śladów i żądań w kontekście.
- Dodawaj te wartości do logów na granicach lub w middleware.
To utrzymuje zależności widoczne.
Kontekst i tracing
Tracing to jeden z najsilniejszych przypadków użycia dla wartości kontekstu i jest naprawdę dobrym dopasowaniem. OpenTelemetry i podobne systemy używają kontekstu do propagacji spanów śledzenia przez wywołania funkcji i granice procesów, ponieważ dane śledzenia to dokładnie taki rodzaj metadanych zakreślonych żądaniem, do których kontekst został 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)
}
Kontekst przenosi aktywny span śledzenia, a repozytorium może utworzyć z niego span potomny. Każda warstwa dodaje swój własny span bez jawnego przekazywania obiektów tracerów — kontekst wykonuje tę pracę przezroczysto przez całe drzewo wywołań.
Obsługa błędów z kontekstem
Gdy operacja zatrzymuje się z powodu anulowania kontekstu, zachowaj te informacje. Wzorce tutaj uzupełniają szersze strategie projektowania błędów omówione w Go Error Handling Architecture.
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 błędów kontekstu w sposób, który je ukrywa.
Owijanie z %w zachowuje errors.Is, więc wywołujący nadal mogą wykryć anulowanie lub przekroczenie limitu czasu:
if err != nil {
return fmt.Errorf("query user: %w", err)
}
Całkowite zastąpienie błędu odrzuca te informacje i łamie każde wywołanie, które sprawdza konkretne typy błędów kontekstu:
if err != nil {
return errors.New("query user failed")
}
Mapowanie błędów kontekstu na odpowiedzi HTTP
Błędy kontekstu 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 błędu aplikacji — jeśli użytkownik zamknął kartę przeglądarki, to nie jest błąd Twojej usługi, a logowanie tego jako błędu dodaje szum bez sygnału.
Kontekst w middleware
Middleware HTTP to wspólne miejsce na dodawanie wartości zakreślonych żądaniem.
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 kontekstu. Identyfikator żądania należy do żądania, powinien podróżować przez pełny łańcuch wywołań, a dołączanie go do logów i śladów na każdej warstwie to dokładnie ten rodzaj poprzecznej troski o obserwowalność, który wartości kontekstu są zaprojektowane do wspierania.
Kontekst w testach
W testach unikaj ślepego używania context.Background().
Preferuj 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 limitów czasu, testuj z prawdziwym limitem czasu tylko wtedy, gdy limit jest mały i istotny.
Dla kodu współbieżnego i zależnego od czasu, rozważ użycie testing/synctest — Testing Concurrent Go Code with synctest omówi 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ć prawdziwe wartości limitów czasu bez czekania na prawdziwy czas.
Kontekst i errgroup
Dla grup gorutin, które powinny zostać anulowane razem, errgroup jest często dobrym dopasowaniem.
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 gorutyna zwróci błąd, kontekst grupy jest anulowany, a inne gorutyny, które szanują ctx.Done(), mogą zatrzymać się wcześniej. To jest znacznie czystsze niż ręczne zarządzanie wieloma gorutinami, kanałami i ścieżkami anulowania. Kluczową frazą tutaj jest “szanuj kontekst” — errgroup nie może zatrzymać pracy, która ignoruje ctx.Done().
Płynne wyłączanie (Graceful shutdown)
Kontekst jest kluczowy dla płynnego wyłączania.
Typowa konfiguracja serwera ma:
- kontekst korzeniowy anulowany przez sygnały OS
- serwer HTTP
- workerzy w tle
- limit czasu wyłączania
- logikę sprzątania
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 kontekst wyłączania nie jest taki sam jak kontekst korzeniowy — korzeń jest już anulowany, gdy przychodzi sygnał OS. Odrębny kontekst limitu czasu daje procesowi wyłączania ograniczoną ilość czasu na drenaż żądań w toku przed wymuszonym wyjściem, co jest subtelną, ale ważną różnicą, która sprawia, że płynne wyłączanie naprawdę działa.
Wspólne antywzorce
Antywzorzec 1: Używanie kontekstu 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 przerywa 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 kontekście
Źle:
ctx = context.WithValue(ctx, "includeDeleted", true)
Używaj jawnych struktur opcji.
Antywzorzec 5: Przekazywanie kontekstu zbyt głęboko do czystego kodu
Źle:
func Add(ctx context.Context, a, b int) int {
return a + b
}
Czysta arytmetyka nie potrzebuje kontekstu, 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 kontekstu
Ź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 terminów.
Praktyczna lista kontrolna kontekstu
Używaj tej listy kontrolnej dla kodu backendu w Go.
Sygnatury funkcji
- Kontekst jest pierwszym parametrem.
- Kontekst nie jest przechowywany w strukturach o długim czasie życia.
- Kontekst nie jest przekazywany do czystych funkcji pomocniczych, chyba że jest potrzebny.
- Kontekst
nilnigdy nie jest używany.
Anulowanie
- Długotrwałe pętle sprawdzają
ctx.Done(). - Gorutyny mają ścieżkę wyłączania.
- Czasy życia workerzy są powiązane z kontekstem rodzica.
- Anulowanie kontekstu jest propagowane do wywołań w dół.
Limity czasu
- Zewnętrzne limity czasu żądań są ustawiane na granicy.
- Limity czasu podoperacji są mniejsze niż zewnętrzna pula.
- Funkcje cancel są zawsze wywoływane.
- Limity czasu nie są ślepo nakładane na każdej warstwie.
Wartości
- Wartości kontekstu są zakreślone żądaniem.
- Klucze używają niestandardowych typów, a nie zwykłych stringów.
- Zależności nie są przechowywane w kontekście.
- Opcjonalne parametry nie są przechowywane w kontekście.
Błędy
context.Canceledicontext.DeadlineExceededsą zachowywane.- Błędy kontekstu są poprawnie mapowane na granicach API.
- Anulowanie świadome przyczyny jest używane tylko wtedy, gdy przyczyna ma znaczenie.
Testy
- Testy używają
t.Context(), gdzie jest to odpowiednie. - Testy limitów czasu unikają powolnych, prawdziwych snów (sleeps).
- Zachowanie współbieżnych limitów czasu jest testowane z
testing/synctest, gdy jest to przydatne. - Wycieki gorutin są sprawdzane poprzez zapewnienie istnienia ścieżek wyłączania.
Jak audytować użycie kontekstu w kodobazie 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żywane tylko na granicach najwyższego poziomu? - Czy funkcje cancel są zawsze wywoływane?
- Czy limity czasu są umieszczane na rozsądnych granicach?
- Czy wartości kontekstu są naprawdę zakreślone żądaniem?
- Czy zależności są ukryte w wartościach kontekstu?
- Czy gorutyny można zatrzymać?
- Czy błędy kontekstu są zachowywane?
To jest dobra nawyka przeglądania kodu, ponieważ wiele błędów kontekstu to nie błędy składniowe — to błędy czasu życia, które pojawiają się tylko przy anulowaniu, obciążeniu lub warunkach wyłączania.
Moje subiektywne zasady
Te zasady są nudne, ale działają.
Zasada 1: Kontekst to przepływ sterowania
Używaj kontekstu do kontrolowania anulowania, terminów i metadanych żądań.
Nie używaj go do przemykania zależności.
Zasada 2: Wywołujący posiada pulę czasu
Funkcja powinna zazwyczaj szanować otrzymany kontekst.
Twórz krótszy limit czasu potomny tylko wtedy, gdy podoperacja potrzebuje specyficznej, mniejszej puli.
Zasada 3: Background należy do krawędzi
Używaj context.Background() w main, testach i ustawieniach najwyższego poziomu.
Nie używaj go wewnątrz metod usług i repozytoriów, aby uciec przed anulowaniem.
Zasada 4: Wartości powinny być nudne
Identyfikator żądania, identyfikator śladu, identyfikator użytkownika i identyfikator tenant’a należą do kontekstu. 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 gorutyna potrzebuje czasu życia
Jeśli gorutyna się zaczyna, powinieneś dokładnie wiedzieć, jak się zatrzymuje. Kontekst jest często odpowiednią odpowiedzią, a jeśli nie jest to kontekst, powinna istnieć jakaś inna jasna mechanizm — kanał, prymityw synchronizacji lub jawny sygnał.
Ostateczne myśli
context.Context nie jest skomplikowany, ponieważ API jest duże — API jest małe. Jest skomplikowany, ponieważ reprezentuje czas życia, a czas życia to architektura. Każda decyzja o tym, gdzie kontekst płynie, gdzie jest wyprowadzany i gdzie się zatrzymuje, to decyzja o tym, jak Twoja usługa obsługuje awarie, obciążenie i wyłączanie.
Dobrze użyty kontekst sprawia, że usługi Go są łatwiejsze do anulowania, łatwiejsze do wyłączania, łatwiejsze do obserwacji i mniej podatne na wycieki gorutin. Zle użyty kontekst ukrywa zależności, odrzuca terminy i sprawia, że kod jest trudniejszy do analizy pod presją.
Praktyczna wniosk jest prosty:
Pass context down.
Do not store it.
Do not replace explicit parameters with values.
Respect cancellation.
Use timeouts at boundaries.
Always call cancel.
To jest kontekst Go zrobiony dobrze.
Ten artykuł jest częścią klastera App Architecture in Production, który obejmuje strukturę kodu, dostęp do danych, wzorce integracji i architekturę testów dla systemów produkcyjnych Go i Python.