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
}
| Metoda | Zwraca |
|---|---|
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()dlamain,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 jakBackground; 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 vettego nie wyłapie;staticchecktak.) - Opakuj dostęp w typowane funkcje pomocnicze, takie jak
WithRequestIDiRequestID, żeby wywołujący nigdy nie widzielianyani 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.Contextto 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
niljako contextu. Jeśli nie masz nic lepszego, użyjcontext.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 zctx. Używaj go do pracy, która musi się dokończyć po zakończeniu żądania, jak zapis logu audytowego.context.AfterFunc(ctx, f)uruchamiafwe własnej gorutynie, gdyctxsię zakończy, i zwraca funkcjęstop, która wyrejestrowuje to wywołanie.
Typowe błędy
- Brak wywołania
cancel. Zawsze piszdefer cancel()zaraz poWithCancel,WithTimeoutlubWithDeadline. - Uruchomienie gorutyny, która ignoruje
ctx. Jeśli blokuje bezselectnactx.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. Przekazujctx, który dostajesz. - Porównywanie błędów przez
==. Użyjerrors.Is(err, context.DeadlineExceeded); większość bibliotek opakowuje ten błąd. - Używanie
WithValuedo 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.