Menu

Context w Go (Golang): anulowanie, timeouty i wartości

Jak context.Context przenosi anulowanie, terminy i wartości związane z żądaniem przez program w Go: Background, WithCancel, WithTimeout, WithValue, ctx.Done w select oraz context w serwerach i klientach HTTP.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

Timeout w dziesięciu linijkach

Zadaniem context.Context jest powiedzieć kodowi, kiedy przestać. Tutaj wolna operacja dostaje 50 ms i odpuszcza, gdy context tak zdecyduje:

Pierwsze wywołanie kończy się po 10 ms i zwraca rows <nil>. Drugie potrzebowałoby 200 ms, ale context wygasa po 50 ms (licząc od chwili jego utworzenia), więc zwraca context deadline exceeded.

Nic nie jest zatrzymywane siłą. Go nie ma sposobu, żeby zabić gorutynę z zewnątrz. Context to sygnał, a kod musi go sprawdzać: przez select na ctx.Done(), przez sprawdzanie ctx.Err() między krokami albo przez przekazanie ctx do wywołań bibliotek (http.NewRequestWithContext, db.QueryContext, exec.CommandContext), które sprawdzają go za ciebie.

Interfejs Context

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}
MetodaZwraca
Done()kanał, który zostaje zamknięty, gdy context zostanie anulowany lub przekroczy czas (nil dla contextu, którego nie da się anulować)
Err()nil, dopóki context jest aktywny, potem context.Canceled lub context.DeadlineExceeded
Deadline()termin i true albo ok == false, jeśli terminu nie ma
Value(key)wartość zapisaną pod key w tym contextcie lub u przodka albo nil

Contexty są niezmienne. Nigdy nie zmieniasz istniejącego; tworzysz z niego dziecko jedną z funkcji With, a dziecko dodaje sygnał anulowania, termin albo wartość.

Skąd bierze się context

Każde drzewo contextów zaczyna się od korzenia:

  • context.Background() dla main, init, testów i konfiguracji serwerów na najwyższym poziomie.
  • context.TODO(), gdy funkcja powinna przyjmować context, ale wywołujący jeszcze go nie ma. Zachowuje się dokładnie jak Background; nazwa to znacznik do późniejszego refaktoringu.

W handlerze HTTP nie tworzysz korzenia. Używasz r.Context(), który serwer anuluje, gdy klient się rozłączy albo handler zwróci wynik.

WithCancel: zatrzymanie na żądanie

context.WithCancel zwraca context potomny i funkcję cancel. Wywołanie cancel zamyka kanał Done dziecka i kanały Done wszystkiego, co z niego wyprowadzono.

Wysyłanie w producencie siedzi w select obok ctx.Done(). To właśnie pozwala mu się zatrzymać: gołe out <- i blokowałoby w nieskończoność, gdy konsument przestał czytać, a gorutyna by wyciekła. Końcowe for range nums czeka, aż producent zamknie kanał. Podczas opróżniania producent może jeszcze zdążyć wysłać wartość czy dwie, bo gdy obie gałęzie jego select są gotowe, Go wybiera jedną losowo; anulowanie jest szybkie, ale nie natychmiastowe.

cancel można bezpiecznie wywołać więcej niż raz i z dowolnej gorutyny. Coś robi tylko pierwsze wywołanie.

WithTimeout i WithDeadline

WithTimeout(parent, d) to WithDeadline(parent, time.Now().Add(d)). Używaj timeoutu w sensie "najwyżej tyle czasu", a terminu, gdy masz konkretną godzinę.

Gdy czas minie, Done zostaje zamknięty, a Err zwraca context.DeadlineExceeded. Jeśli wcześniej zostanie wywołane cancel, Err zwraca context.Canceled. Sprawdzaj, który to przypadek, przez errors.Is, bo biblioteki zwykle opakowują błąd:

Zawsze wywołuj cancel, nawet przy timeoucie, który sam się uruchomi. Context trzyma timer i miejsce u rodzica, dopóki jedno z tych zdarzeń nie nastąpi, a defer cancel() zwalnia oba, gdy tylko funkcja zwróci wynik. go vet zgłasza porzuconą funkcję cancel: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak.

Dzieci nie mogą przeżyć rodziców

Contexty tworzą drzewo. Anulowanie rodzica anuluje wszystkich potomków. Dziecko może mieć krótszy termin niż rodzic, nigdy dłuższy: zawsze wygrywa wcześniejszy termin.

To sprawia, że contexty są przydatne między warstwami. Handler HTTP dostaje context, który umiera razem z żądaniem; wywołanie bazy danych trzy warstwy niżej wyprowadza z niego 2-sekundowy timeout. Jeśli klient rozłączy się po 100 ms, zapytanie zostanie anulowane w tym momencie, a nie dwie sekundy później.

Przy blokowaniu zawsze rób select na ctx.Done()

Każda gorutyna, która czeka (na wysłanie do kanału, odbiór, timer), powinna jednocześnie czekać na ctx.Done(). W pętlach obciążających procesor, które nigdy nie blokują, sprawdzaj co jakiś czas ctx.Err():

for i, item := range items {
	if i%1000 == 0 {
		if err := ctx.Err(); err != nil {
			return err
		}
	}
	process(item)
}

Do prostego czekania w select używaj time.After, ale gdy czekanie może być często anulowane, wybierz timer, który da się zatrzymać (albo timeout contextu).

Przyczyny anulowania (Go 1.20 i 1.21)

ctx.Err() mówi tylko canceled albo deadline exceeded. Żeby zapisać powód, użyj wariantów Cause:

WithCancelCause pojawiło się w Go 1.20, a WithTimeoutCause i WithDeadlineCause w Go 1.21. Err nadal zwraca standardowe wartości, więc istniejące sprawdzenia dalej działają; szczegóły daje context.Cause.

WithValue, z umiarem

context.WithValue(parent, key, value) dołącza jedną wartość. ctx.Value(key) szuka jej w łańcuchu rodziców.

Zasady dotyczące wartości:

  • Jako kluczy używaj nieeksportowanego typu, nigdy zwykłego string. Dwa pakiety, które oba używają "user", nadpisywałyby sobie nawzajem wartości. (go vet tego nie wyłapie; staticcheck tak.)
  • Opakuj dostęp w typowane funkcje pomocnicze, takie jak WithRequestID i RequestID, żeby wywołujący nigdy nie widzieli any ani klucza.
  • Przechowuj tylko dane związane z żądaniem, które przechodzą przez API: identyfikatory śledzenia i żądań, uwierzytelnionego użytkownika, logger. Nigdy parametrów opcjonalnych, uchwytów bazy danych ani konfiguracji. Te należą do argumentów funkcji lub pól struktur, gdzie sprawdza je kompilator i widzą je czytelnicy.
  • Wyszukiwanie przechodzi łańcuch rodzic po rodzicu, więc każda dodana wartość wydłuża o krok wyszukiwanie pozostałych.

Konwencje

  • ctx context.Context to pierwszy parametr każdej funkcji, która wykonuje I/O, blokuje albo wywołuje coś, co to robi: func Fetch(ctx context.Context, url string) error.
  • Nie przechowuj contextu w strukturze. Przekazuj go do każdego wywołania metody. Context należy do jednej operacji, a struktura zwykle żyje dłużej. (Wyjątkiem jest typ reprezentujący pojedynczą operację, jak http.Request.)
  • Nigdy nie przekazuj nil jako contextu. Jeśli nie masz nic lepszego, użyj context.TODO().
  • Zwracaj ctx.Err() albo opakuj go przez %w, gdy przerywasz z powodu contextu, żeby wywołujący mogli odróżnić timeout od prawdziwej awarii.

Context w serwerach i klientach HTTP

Po stronie serwera: r.Context() zostaje anulowany, gdy klient się rozłączy, gdy handler zwróci wynik albo gdy strumień HTTP/2 zostanie zresetowany. Po stronie klienta: http.NewRequestWithContext sprawia, że żądanie respektuje timeout lub anulowanie. Ten program uruchamia obie strony przez httptest:

Klient odpuszcza po 50 ms i zamyka połączenie. Serwer to zauważa, context jego żądania zostaje anulowany, a handler przestaje pracować, zamiast spędzić kolejne 450 milisekund na raporcie, którego nikt nie przeczyta. W prawdziwym handlerze przekazujesz r.Context() w dół do każdego wywołania bazy danych i HTTP, i wszystkie zatrzymują się razem.

Inne funkcje pomocnicze (Go 1.21)

  • context.WithoutCancel(ctx) zwraca context z tymi samymi wartościami, który nie zostaje anulowany razem z ctx. Używaj go do pracy, która musi się dokończyć po zakończeniu żądania, jak zapis logu audytowego.
  • context.AfterFunc(ctx, f) uruchamia f we własnej gorutynie, gdy ctx się zakończy, i zwraca funkcję stop, która wyrejestrowuje to wywołanie.

Typowe błędy

  • Brak wywołania cancel. Zawsze pisz defer cancel() zaraz po WithCancel, WithTimeout lub WithDeadline.
  • Uruchomienie gorutyny, która ignoruje ctx. Jeśli blokuje bez select na ctx.Done(), anulowanie nic nie daje, a gorutyna wycieka.
  • Tworzenie nowego context.Background() głęboko w łańcuchu wywołań. Przecina to połączenie z terminem i anulowaniem wywołującego. Przekazuj ctx, który dostajesz.
  • Porównywanie błędów przez ==. Użyj errors.Is(err, context.DeadlineExceeded); większość bibliotek opakowuje ten błąd.
  • Używanie WithValue do zależności. Uchwyt bazy danych schowany w contextcie to parametr, którego kompilator nie może już sprawdzić.
  • Oczekiwanie, że anulowanie będzie natychmiastowe. Kod zauważa je dopiero przy następnym sprawdzeniu. Długa pętla bez sprawdzania dalej działa.

Najczęściej zadawane pytania

Do czego służy context w Go?

context.Context mówi funkcji i wszystkiemu, co ona wywołuje, kiedy odpuścić: bo wywołujący anulował operację, bo minął termin albo bo klient się rozłączył. Może też przenosić wartości związane z żądaniem, na przykład identyfikator żądania. Zgodnie z konwencją jest to pierwszy parametr o nazwie ctx.

Czym różni się context.Background od context.TODO?

Oba zwracają pusty context, który nigdy nie zostaje anulowany i nie ma terminu ani wartości. Zachowują się identycznie. Background() to korzeń dla main, testów i konfiguracji na najwyższym poziomie. TODO() oznacza miejsce, w którym powinien trafić prawdziwy context, ale otaczający kod jeszcze go nie ma, dzięki czemu łatwo je później znaleźć.

Dlaczego po context.WithTimeout trzeba wywołać cancel?

WithTimeout, WithDeadline i WithCancel rejestrują nowy context u rodzica i mogą uruchomić timer. Wywołanie cancel zwalnia te zasoby od razu po zakończeniu pracy, a nie dopiero wtedy, gdy minie timeout albo rodzic zostanie anulowany. Napisz defer cancel() zaraz po utworzeniu; go vet ostrzega, gdy funkcja cancel zostaje porzucona.

Co oznacza "context deadline exceeded" w Go?

To tekst context.DeadlineExceeded, czyli błędu, który ctx.Err() zwraca po upływie terminu contextu. Funkcje respektujące context, takie jak klienci HTTP i sterowniki baz danych, zwracają go (często opakowanego), gdy skończy im się czas. Sprawdzisz go przez errors.Is(err, context.DeadlineExceeded).

Czy używać context.WithValue do przekazywania parametrów?

Nie. Używaj go tylko do danych związanych z żądaniem, które przechodzą przez granice API i o których funkcje po drodze nie muszą wiedzieć, jak identyfikator śledzenia albo uwierzytelniony użytkownik. Wszystko, czego funkcja potrzebuje do pracy, należy do jej parametrów, gdzie sprawdza to kompilator.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ