Funkcja recover() w Go zatrzymuje panikę tylko wtedy, gdy jest wywoływana bezpośrednio w funkcji opóźnionej, która jest wykonywana jako część procesu odwracania wywołanego przez tę panikę. Kiedy wywołujesz recover() w funkcji pomocniczej, która została wywołana wewnątrz zamknięcia opóźnionego, mechanizm uruchamiania wykrywa, że bieżąca ramka wywołania goroutine nie jest najwyższą ramką opóźnioną związaną z aktywną paniką.
// Ten wzorzec NIE UDAJE się odzyskać: func handlePanic() { if r := recover(); r != nil { log.Println("Odzyskano:", r) } } func risky() { defer handlePanic() // recover() zwraca nil tutaj panic("błąd") }
Runtime utrzymuje tę kontrolę poprzez pole g.recover, które przechowuje wskaźnik na ramkę stosu funkcji opóźnionej, która ma prawo do odzyskania. Kiedy recover() jest wywoływane, porównuje bieżący wskaźnik stosu z tą przechowaną wartością; jeśli się nie zgadzają, recover() zwraca nil, a panika nadal się propaguje. To architektoniczne ograniczenie zapewnia, że logika odzyskiwania pozostaje explicite i lokalizowana, zapobiegając przypadkowemu pochłanianiu panik przez głęboko zagnieżdżone funkcje pomocnicze, które powinny być propagowane do wyższych handlerów odzyskiwania.
W mikrousłudze o wysokiej przepustowości, która obsługiwała tysiące równoczesnych goroutines, wdrożyliśmy zcentralizowany mechanizm odzyskiwania paniki, aby zapobiegać awariom serwera z powodu źle sformułowanych żądań. Początkowa implementacja używała funkcji narzędziowej SafeRecover(), która kapsułkowała logowanie i metryki, a deweloperzy opóźniali tę funkcję na początku każdego handlera przy użyciu defer SafeRecover(). Jednak podczas incydentu produkcyjnego związanego z błędem dzielenia przez zero w handlerze żądania, usługa zawiodła mimo oczywistego mechanizmu odzyskiwania, co wskazywało, że panika nie była przechwytywana, ponieważ recover() była zagnieżdżona w funkcji pomocniczej, a nie wywoływana bezpośrednio.
Najpierw rozważaliśmy nakazanie deweloperom ręcznego pisania defer func() { if r := recover(); r != nil { ... } }() w każdym punkcie wejścia funkcji. Podejście to zapewniało bezpośredni dostęp do recover(), zapewniając zgodność runtime, ale wprowadzało znaczący „boilerplate” i polegało na ludzkiej konsekwencji, co czyniło je podatnym na błędy dla dużego zespołu i trudnym do egzekwowania podczas przeglądów kodu.
Drugie podejście polegało na modyfikacji SafeRecover(), aby przyjmowała zamknięcie jako argument i wykonywała recover() w tej przekazanej funkcji przed wywołaniem logiki pomocniczej. Chociaż technicznie spełniało wymagania, umieszczając recover() w ramce opóźnionej, stworzyło to niezdarną API, w której handlerzy musieli przekazywać swoją logikę odzyskiwania jako zwrotne wywołania, co skomplikowało przepływ sterowania i obniżyło czytelność przy dodaniu niepotrzebnych pośredników.
Ostatecznie wybraliśmy trzecie podejście: wdrożenie wrappera middleware na poziomie HTTP routera, który wykonywał defer func() { if r := recover(); r != nil { logAndMetrics(r) } }() bezpośrednio w zamknięciu opóźnionym middleware'a. To rozwiązanie zapewniło, że recover() zostało wywołane na odpowiedniej głębokości stosu, zachowując czysty podział obowiązków, co skutkowało 100% wskaźnikiem przechwytywania paniki podczas kolejnych testów chaosu i zerowymi pętlami awarii w następnym kwartale.
Dlaczego recover() zwraca nil, gdy jest wywoływane poza funkcją opóźnioną, nawet gdy żadna panika nie jest aktywna?
Poza kontekstem wykonywania opóźnionego, recover() sprawdza stan paniki bieżącej goroutine i nie znajduje aktywnego rekordu paniki, co powoduje, że zwraca nil natychmiast. Subtelność polega na tym, że recover() sprawdza, czy obecna funkcja wykonuje się jako część odwracania stosu defer, a nie tylko czy gdzieś w programie występuje panika. Kiedy jest wywoływana z normalnych ścieżek wykonania, runtime znajduje pole _panic w strukturze goroutine jako nil i zwraca nil bez efektów ubocznych, zapobiegając przypadkowemu niewłaściwemu użyciu, gdzie normalne przetwarzanie błędów mogłoby uruchomić mechanizmy odzyskiwania.
Co się dzieje, gdy wiele funkcji opóźnionych w tej samej goroutine wywołuje recover(), i dlaczego tylko pierwsza z nich ma sukces?
Gdy występuje panika, Go wykonuje opóźnione funkcje w kolejności LIFO, a pierwsza opóźniona funkcja, która wywołuje recover(), atomowo czyści aktywny stan paniki z wewnętrznej listy powiązanej _panic goroutine. Kolejne odroczone funkcje, które wywołują recover(), znajdują, że panika została już rozwiązana, co powoduje, że otrzymują nil zamiast pierwotnej wartości paniki. Ten projekt zapewnia deterministyczne przetwarzanie paniki, w którym najgłębsza przestrzeń odzyskiwania ma pierwszeństwo, i zapobiega redundantnym próbom odzyskiwania, które mogą mylić logikę propagacji błędów, gdy stos wznawia normalne wykonanie.
Jak zachowuje się panic(nil) w porównaniu do panic("nil") lub panic(0), i dlaczego Go 1.21 zmieniło to zachowanie?
Przed Go 1.21, wywołanie panic(nil) powodowało, że runtime traktował wartość paniki jako specjalnego sentinel, który recover() zwróciłby jako nil, co czyniło to nieodróżnialnym od wezwania recover(), które nie znalazło paniki do obsłużenia, a tworzyło niebezpieczną niejednoznaczność. W Go 1.21 i późniejszych, runtime automatycznie przekształca wartość paniki nil w nie-nil błąd uruchomieniowy zawierający napis "błąd uruchomieniowy: wywołano panikę z argumentem nil", zapewniając, że recover() zawsze zwraca wartość nie-nil, gdy skutecznie przechwytuje panikę. Ta zmiana wyeliminowała niejednoznaczność w kodzie obsługi błędów, pozwalając deweloperom pewnie sprawdzać if r := recover(); r != nil, wiedząc, że zwrócone nil rzeczywiście wskazuje, że panika nie wystąpiła.