Powszechne błędne wyobrażenia na temat async/await, które powodują błędy w produkcji
Wyjaśnia dziewięć subtelnych nieporozumień związanych z async/await – od warunków wyścigowych po nierozpatrzone odrzucenia – które potajemnie psują aplikacje JavaScript w praktyce.
async zawsze zwraca obietnicę, umiejętność umieszczania await przed wywołaniem dotyczącym połączenia z siecią oraz świadomość, że struktura try/catch ułatwia radzenie sobie z awariami asynchronicznymi, mogą wydawać się wystarczające. W porównaniu z kodem opartym na callbackach styl ten wygląda uporządkowanie i jest niemal tak prosty jak zwykły synchroniczny JavaScript: pobiera dane użytkownika, ładuje jego konto, odświeża interfejs i łapie wszystkie błędy, które mogą wystąpić w trakcie. Składnia jest tak intuicyjna, że łatwo o tym zapomnieć i nie zastanowić się nad modelem myślowym, który za nią stoi.Prawdziwym problemem jest to, że ta powierzchowna biegłość obejmuje jedynie strukturę async/await, a nie podstawowe zasady asynchronicznego zarządzania stanem. Powszechnym błędem jest traktowanie await tak, jakby zatrzymywało ono cały program, zakładanie, że wywołanie funkcji asynchronicznej automatycznie łączy jej wynik z osobą, która ją wywołała, oraz przekonanie, że kod napisany w liniowym, od góry do dołu stylu nie może mieć konfliktów z samym sobą. Takie założenia rzadko powodują widoczne problemy w małych demonstracjach, ponieważ jednocześnie wykonuje się tylko jedną operację, odpowiedzi przychodzą szybko, a symulowane dane są przetwarzane w przewidywalnej kolejności. Prawdziwe systemy produkcyjne są o wiele mniej wyrozumiałe.
Błędy, które ostatecznie się pojawiają, nie przypominają typowych błędów asynchronicznych opisanych w podręcznikach. Funkcja zwraca wartość, zanim jakikolwiek efekt uboczny faktycznie się zakończy. Stary żądanie przykrywa stan ustawiony przez nowsze. Blok try/catch nie pozwala na wykrycie odrzucenia. Zadanie zbiorcze uruchamia więcej równoczesnych operacji, niż system jest w stanie realistycznie obsłużyć. Każda obietnica, rozpatrywana osobno, wydaje się w pełni poprawna — jednak cały proces pracy zachowuje się zupełnie inaczej, niż pierwotnie planowano.
Pełne zrozumienie tej kwestii może zajść sporo czasu: async/await nie eliminuje konkurencji w JavaScript. Jego zadaniem jest jedynie zapewnienie pojedynczej funkcji asynchronicznej bardziej czytelnego sposobu na pauzowanie i wznowienie działania. Wszystko inne nadal zależy od tego, które obietnice zostaną zwrócone, które będą oczekiwane, które zostaną cicho odrzucone, które zostaną anulowane lub spróbowane ponownie, a które będą mogły modyfikować wspólny stan w trakcie wykonywania.
Sądziłem, że await wstrzymuje więcej niż jedną funkcję
Częsty pierwszy błąd postrzegania jest dość prosty. Gdy tylko pojawi się await, łatwo jest mentalnie traktować to tak, jakby wstrzymywało ono wszystko wokół siebie. JavaScript nigdy nie blokuje karty przeglądarki ani procesu Node.js, ale nadal łatwo jest założyć, że otaczająca go logika w jakiś sposób poczeka na swoją kolej.
To nie tak to działa. await wstrzymuje jedynie konkretną funkcję asynchroniczną, w której się znajduje. Kontrola wraca do środowiska wykonawczego, podczas gdy oczekiwana obietnica jest nadal w toku, a JavaScript kontynuuje obsługę wszystkiego innego, co jest gotowe do uruchomienia. Słuchacze zdarzeń mogą zostać aktywowani, timerzy mogą wybuchnąć, niepowiązane żądania mogą zostać zrealizowane, a nawet kolejne wywołanie tej samej funkcji może zostać rozpoczęte w międzyczasie.
async function loadProfile() {
console.log("Loading profile");
const profile = await fetchProfile();
console.log("Profile loaded");
return profile;
}
console.log("Before");
loadProfile();
console.log("After");
Żaden element kodu zewnętrznego nie czeka tylko dlatego, że loadProfile zawiera await. Wywołanie tej funkcji natychmiast ją uruchamia i zwraca obietnicę. Chyba że osoba, która ją wywołała, zdecyduje się na oczekiwanie lub zwrócenie tej obietnicy, wykonanie po prostu idzie dalej bez przerwy.
To może brzmieć jak drobna kwestia techniczna, ale jej skutki wykraczają daleko poza nieuporządkowane logi konsoli. Obsługa żądania może wysłać swoją odpowiedź, podczas gdy zapis do logu audytowego jest jeszcze w trakcie. Narzędzie CLI może się zamknąć, gdy zapis pliku wciąż jest w toku. Test może poinformować o sukcesie, zanim zostaną wykonyane żądania zawarte w asynchronicznym callbacku. Funkcja asynchroniczna zachowuje się dokładnie tak, jak została zaprojektowana — prawdziwym problemem jest to, że wywołujący nigdy nie powiązał swojego zakończenia z zakończeniem tej asynchronicznej funkcji.
Dlatego pytanie, które warto zadać, to nie to, czy funkcja używa wewnętrznie await, lecz to, czy obietnica reprezentująca całą operację jest faktycznie połączona z kodem, który musi wiedzieć, gdy operacja zostanie zakończona.
Wywoływałem funkcje asynchroniczne, nie decydując, kto będzie odpowiadał za ich zakończenie
Jednym z błędów, które pojawiają się ciągle, jest wywoływanie funkcji asynchronicznej bez oczekiwania na jej zakończenie lub zwrotu wyniku. Czasami jest to prosta nieuwaga, a innym razem zadanie wydaje się na tyle nieważne, że można po prostu pozwolić mu działać w tle.
Prawdziwym problemem nie jest koniecznie to, że zadanie wykonuje się niezależnie — chodzi o to, że nikt tak naprawdę nie określił, kto jest odpowiedzialny za jego wynik.
Wyobraźmy sobie zapisanie zamówienia, a następnie wysłanie powiadomienia. Jeśli to powiadomienie musi bezwzględnie zostać wysłane, zanim cała operacja będzie uważana za zakończoną, osoba wywołująca funkcję musi na nie odczekać. Jeśli jest to faktycznie opcjonalne, pozwolenie mu na samodzielne działanie może być w porządku, ale odrzucenie tego powiadomienia nadal musi gdzieś trafić. Proste odrzucenie zwróconej obietnicy nie zamienia automatycznie wywołania w niezawodne zadanie w tle — po prostu pozbawia to osobę wywołującą możliwości dowiedzenia się, co stało się później.
To staje się szczególnie ryzykowne w kodzie backendowym. Funkcja może odesłać odpowiedź o sukcesie, mimo że jakiś późniejszy asynchroniczny efekt uboczny się nie powiódł. Użytkownik będzie sądził, że operacja zakończyła się pomyślnie, podczas gdy system nie ma trwałego zapisu wskazującego na to, że praca pozostała niedokończona. Jeśli w tym momencie proces zostanie ponownie uruchomiony, ta praca w toku po prostu zniknie.
Pomaga traktować każdą niewyczekiwaną obietnicę jako decyzję architektoniczną, a nie rozwiązanie dodane później. Albo bieżąca operacja jest odpowiedzialna za wynik i musi na niego poczekać, albo inna warstwa jest nią zarządzana i potrzebuje odpowiedniego mechanizmu do śledzenia ukończenia, ponownych prób i błędów. Nie ma nic złego w celowej pracy w tle – ale nie powinna powstawać tylko dlatego, że ktoś zapomniał napisać await.
Obietnica, której nikt nie obserwuje, z definicji nie jest zadaniem w tle. To po prostu praca bez właściciela.
Traktowanie forEach tak, jakby rozumiało obietnice
Jednym z pierwszych wzorców asynchronicznych, które po cichu łamą standardowy model myślowy, jest łączenie forEach z asynchronicznym callbackiem. Składnia wygląda zupełnie rozsądnie, co utrudnia zrozumienie, dlaczego funkcja nadrzędna kończy swoją pracę, zanim faktycznie zostanie wykonana jakakolwiek rzeczywista operacja.
async function saveUsers(users) {
users.forEach(async (user) => {
await saveUser(user);
});
console.log("All users saved");
}
Wiadomość o uzupełnieniu może pojawić się przed zapisaniem choćby jednej rekordu użytkownika. Powodem jest to, że forEach wywołuje funkcję zwrotną, ale odrzuca wszystko, co ta zwraca. Funkcja zwrotna asynchroniczna zawsze zwraca obietnicę, więc każde takie wywołanie w tle tworzy obietnicę, do której nikt nie przechowuje referencji. Gdy nie ma już nic, na co można czekać, funkcja zewnętrzna rozwiązuje się natychmiast, niezależnie od tego, co nadal robią jej wewnętrzne wywołania.
To nie jest dokładnie błąd specyficzny dla forEach. Wynika on z założenia, że przekazanie funkcji asynchronicznej do dowolnej metody tablicy automatycznie ulepsza jej specyfikację tak, by rozumiała obietnice. Tak nie jest. Pomocnik do iteracji synchronicznej pozostaje synchroniczny bez względu na to, jaki rodzaj funkcji zostanie do niego przekazany, chyba że ten pomocnik został specjalnie stworzony do koordynacji pracy asynchronicznej.
To samo problem występuje przy użyciu map, filter i reduce. Użycie map z asynchronicznym callbackiem zwraca tablicę pełną oczekujących na realizację obietnic, a nie tablicę wartości końcowych. filter nigdy nie czeka na wynik asynchronicznego predykatu, więc ewaluuje same obiekty obietnic — które zawsze są prawdziwe — zamiast wyników, które ostatecznie przynoszą. Możliwe jest stworzenie asynchronicznej wersji reduce, która będzie działać poprawnie, ale jej zrozumienie jest trudne, ponieważ akumulator to również obietnica, którą należy ostrożnie rozwijać przy każdej iteracji.
Gdy to stanie się jasne, pisanie poprawnego kodu przestaje polegać na zapamiętywaniu, który metodę tablicy jest „dozwolona”, a zaczyna się od wyboru modelu wykonania, który faktycznie jest wymagany przez zadanie. Gdy każdy krok rzeczywiście zależy od ukończenia poprzedniego, pętla for...of z await w środku bezpośrednio wyraża tę intencję. Gdy kroki są niezależne i mogą wystąpić jednocześnie, mapowanie ich na tablicę obietnic i oczekiwanie na nie wszystkie razem za pomocą Promise.all jest często właściwym rozwiązaniem. A gdy konieczne jest ograniczenie równoczesności, ani zwykła pętla, ani nieograniczony Promise.all nie sprawdzą się w tym zadaniu.
Syntakta musi wynikać z potrzeb operacyjnych, a nie odwrotnie. Kuszące jest wybranie pierwszego wzorca, który wydaje się idiomatyczny, i mieć nadzieję, że z niego wyniknie pożądane zachowanie, ale taka kolejność działań jest błędna.
Założenie, że Promise.all przyspiesza działanie bez ryzyka
Gdy odkryjemy, że forEach nigdy nie czeka, naturalnym następnym krokiem jest użycie Promise.all, który zazwyczaj jest odpowiednim narzędziem: przekształcamy elementy na operacje asynchroniczne, przekazujemy powstałe obietnice Promise.all i czekamy, aż cała grupa zakończy się jednocześnie.
Taki model sprawdza się, gdy poszczególne operacje nie są od siebie zależne, rozmiar partii pozostaje rozsądny i jedno niepowodzenie ma unieważnić cały wynik. Problemy pojawiają się, gdy jest on używany poza tymi granicami.
Promise.all sam w sobie nic nie ogranicza. Większość podstawowych operacji rozpoczyna się w momencie utworzenia obietnic, więc przetwarzanie kilku tysięcy rekordów może wywołać niemal jednocześnie kilka tysięcy zapytań do bazy danych lub żądań wysyłanych na zewnątrz. Taki kod może wydawać się poprawny przy małych lokalnych zbiorach danych, ale potem może sparaliżować zasoby połączeń, przekroczyć limity liczby prób lub wyczerpać pamięć, gdy musi obsłużyć dane na skalie produkcyjnej.
Jego obsługa błędów może również być zaskakująca. Gdy jedna z obietnic w grupie zostanie odrzucona, pozostałe nie są automatycznie zatrzymywane. Kontynuują działanie, co oznacza, że nadal mogą zapisywać rekordy, wysyłać wiadomości lub modyfikować stan zewnętrzny, nawet po tym, jak kod wywołujący już wszedł do bloku catch. Stwierdzenie „zestaw operacji się nie udał” jest technicznie dokładne, ale nie oznacza, że nie wystąpiły żadne skutki uboczne.
Ponowna próba przetworzenia całej partii pozwala wtedy przepracować to, co już udało się zrobić przy pierwszej próbie. W tym przypadku problemem nie są już mechanizmy obietnic — chodzi o idempotencję oraz radzenie sobie z częściowym ukończeniem zadania.
Zanim sięgnie się po Promise.all, pomocne jest postawienie innych pytań. Ile operacji można bezpiecznie wykonać jednocześnie? Czy są one rzeczywiście od siebie niezależne? Co powinien oznaczać jeden błąd dla reszty partii? Czy istnieje sposób na zatrzymanie pracy, która już się rozpoczęła? Jeśli dojdzie do ponownej próby, czy elementy, które już zostały zakończone, mogą zostać skopiowane? Czy wywołujący naprawdę potrzebuje każdego wyniku, czy akceptowalny byłby uczciwy częściowy sukces?
Czasami Promise.all nadal jest właściwą odpowiedzią. Czasami lepiej sprawdza się Promise.allSettled. Innym razem sytuacja wymaga pętli sekwencyjnej, ogranicznika współbieżności, kolejki lub transakcji obejmującej całą operację. To, które rozwiązanie jest właściwe, zależy od gwarancji, których musi dostarczyć operacja, a nie od tego, jak szybki wydaje się kod na pierwszy rzut oka.
Założenie, że kod czytany od góry do dołu nie może wpadać w sytuacje współbieżności
Pośrednio najdroższe nieporozumienie polega na przekonaniu, że await chroni kod przed sytuacjami współbieżności. W obrębie jednej funkcji instrukcje po await są wykonywane kolejno, co sprawia wrażenie ciągłej sekwencji. Jednak przy kilku jednoczesnych wywołaniach tej samej funkcji kilka jej wykonania może łatwo się nakładać.
Załóżmy dwa żądania, z których każde odczytuje bieżącą kwotę, oblicza nową kwotę i zapisuje ją. Oba żądania czekają na odczyt, a następnie oba czekają na zapis. Każda linia w ramach każdego wywołania jest wykonywana w kolejności, jakiej można się spodziewać. Warunek wyścigu i tak występuje, ponieważ oba żądania mogą odczytać tę samą początkową kwotę, zanim któreś z nich zapisze swoją aktualizację.
Ta sama forma błędu pojawia się w interfejsie użytkownika. Najpierw zostaje wysłane zapytanie dotyczące starszych danych, a potem drugie zapytanie dotyczące nowszych danych. Nowsze żądanie akurat zostaje zrealizowane pierwsze i poprawnie aktualizuje interfejs. Starsze żądanie kończy się później i zastępuje ten poprawny stan przestarzałym wynikiem. Każde wywołanie czekało na swoje dane dokładnie tak, jak zaplanowano – brakuje jednak reguły określającej, które wywołanie nadal ma uprawnienia do aktualizacji interfejsu.
To należy mieć na uwadze przy każdym wyrażeniu await znajdującym się pomiędzy odczytem stanu a podjęciem działań na jego podstawie. Ta pauza nie jest bezpieczną przerwą w czasie – to okno, w którym mogą być wykonywane inne zadania, które mogą unieważnić założenia poczynione przez funkcję tuż przed jej zatrzymaniem.
Weryfikacje przeprowadzane na poziomie aplikacji nie są automatycznie odpornicze na to okno czasowe. Sprawdzenie, czy nazwa użytkownika jest wolna, a następnie jej użycie nie zapewnia ochrony przed dwoma żądaniami wykonującymi tę samą weryfikację niemal jednocześnie. Sprawdzenie, czy rekord nadal jest w oczekiwaniu przed jego zatwierdzeniem, nie gwarantuje, że jakiś inny proces nie zmienił już jego stanu w tym czasie.
Rzeczywista ochrona zazwyczaj musi pochodzić z niższego poziomu: ograniczenie unikalności na poziomie bazy danych, aktualizacja warunkowa, blokowanie optymistyczne, klucz idempotencji, transakcja lub wyraźne zasady własności wdrożone w interfejsie użytkownika. await zarządza jedynie zakończeniem jednej konkretnej obietnicy. Nie robi nic, aby zablokować wspólny stan ani zagwarantować, że wartość odczytana wcześniej nadal będzie prawdziwa w momencie podjęcia działań na jej podstawie.
Oczekiwanie, że try/catch złapie operację, której nigdy nie czekano
Kolejny powszechny błąd wygląda całkowicie bezpiecznie podczas przeglądania kodu. Wywołanie asynchroniczne znajduje się wewnątrz bloku try/catch, więc wydaje się rozsądne założyć, że każda odrzucenie zostanie tam obsłużone.
try {
sendAnalyticsEvent(event);
} catch (error) {
logError(error);
}
Gdy sendAnalyticsEvent jest funkcją asynchroniczną, która odrzuca obietnicę po tym, jak już ją zwróciła, otaczający blok catch nigdy nie dostrzega tego błędu. Część synchroniczna wywołania zakończyła się pomyślnie w momencie, gdy zwróciła obietnicę. Odrzucenie następuje później, poza ramką stosu, którą monitoruje blok try.
Blok catch działa tylko wtedy, gdy obietnica jest oczekiwana bezpośrednio w jego obrębie:
try {
await sendAnalyticsEvent(event);
} catch (error) {
logError(error);
}
Mówiąc prościej, różnica wydaje się oczywista, ale wcześniejsza wersja wygląda bezpiecznie właśnie dlatego, że wywołanie asynchroniczne znajduje się wizualnie wewnątrz obsługi błędów. Takie ułożenie sugeruje relację, której kod w rzeczywistości nigdy nie nawiązuje.
To samo problemy występują wewnątrz funkcji zwrotnych i obsługi wydarzeń. Funkcja zwrotna asynchroniczna może zostać odrzucona wewnętrznie, ale jeśli kod ją wywołujący nigdy nie czeka na wynik ani nie sprawdza obiecania, które zwraca, to odrzucenie nie ma dokąd pójść. Otoczenie miejsca wywołania funkcji zwrotnej instrukcjami try/catch nie pomaga, ponieważ odrzucenie dotyczy obiecania, którego nic w danym zakresie nie monitoruje.
Zasada polega na tym, że błędy w kodzie asynchronicznym przemieszczają się razem z obiecaniami, a nie wraz z formatowaniem kodu. Aby przechwycić odrzucenie, należy bezpośrednio czekać na obiecanie, zwrócić je do łańcucha już posiadającego obsługę lub dodać wyraźną funkcję zwrotną do obsługi odrzuceń. Jeśli obiecanie zostanie wyrzucone, jego ścieżka błędu jest również usuwana razem z nim.
To ma również znaczenie dla bloków obsługi błędów, które pochłaniają zbyt wiele zasobów. Przekształcanie każdego błędu w wartość null tworzy oddzielny problem: osoby wywołujące funkcję nie mogą już odróżnić prawdziwego błędu od legalnie pustego wyniku. Samo łapanie błędów nie jest celem – kod musi zachować sens tego błędu dla osoby, która będzie nim dalej korzystać.
Pomylenie anulowania z odwróceniem
Gdy zespół po raz pierwszy wprowadza AbortController, jest kuszące założenie, że anulowanie żądania oznacza, iż podstawowa operacja faktycznie się zatrzymała. W przypadku żądań wysyłanych z przeglądarki anulowanie rzeczywiście powstrzymuje klienta przed dalszym oczekiwaniem i często zapobiega niepotrzebnemu dalszemu przetwarzaniu na samym klientzie. Jest to rzeczywiście przydatne w takich sytuacjach jak wyszukiwanie na żywo, opuszczanie strony lub odrzucenie danych, które już nie są istotne.
To, czego nie gwarantuje, to to, że prace już przekazane serwerowi zostaną cofnięte.
Jeśli żądanie zapisu zostanie przerwane po tym, jak serwer je już otrzymał, backend może mimo to dokończyć aktualizację bazy danych lub zewnętrzny wywołanie. Klient wie jedynie, że przestał oczekiwać odpowiedzi. Ponowne wysyłanie tego żądania bez jakiejkolwiek ochrony idempotencji niesie ryzyko powtórzenia operacji, która już zakończyła się pomyślnie po stronie serwera.
To rozróżnienie istnieje, ponieważ anulowanie po stronie klienta i stan serwera znajdują się po przeciwnych stronach granicy sieci. Klient może zrezygnować z oczekiwania na wynik, ale nie ma sposobu, by przebić się przez tę granicę i cofnąć efekty, które już wystąpiły.
Anulowanie należy traktować raczej jako sygnał dotyczący istotności danej operacji niż gwarancję jej statusu. Informuje ono resztę aplikacji, że osoba wywołująca nie jest już zainteresowana określonym wynikiem, więc ten wynik nie powinien mieć możliwości nadpisania obecnego stanu. W przypadku operacji odczytu przerwanie żądania jest rozsądną optymalizacją. W przypadku operacji zapisu nadal potrzebny jest oddzielny mechanizm, aby ustalić, czy operacja została zakończona, nadal jest w trakcie wykonywania, czy można ją ponowić bez powielania jej efektu.
Anulowanie odpowiada na pytanie, czy dany wynik jest nadal pożądany. Nie decyduje ono o tym, czy podstawowa akcja nadal jest spójna.
Zapisywanie składni asynchronicznej przed zdefiniowaniem przepływu pracy
Głównym mitem przewijającym się we wszystkich tych błędach jest traktowanie projektowania asynchronicznego jakby była to wyłącznie kwestia składni – decydowanie, czy użyć await, Promise.all czy funkcji callback asynchronicznej, zanim ustali się, jakie gwarancje faktycznie wymaga dana operacja.
Bardziej przydatne pytania pojawiają się wcześniej. Czy wywołujący musi czekać, aż operacja się zakończy, zanim przejdzie dalej? Czy wiele zadań może bezpiecznie działać jednocześnie? Czy ich względny porządek ma znaczenie? Jaka jest właściwa reakcja, gdy niektóre operacje się udają, a inne nie? Czy bezpieczne jest ponowne wykonanie tej operacji? Kto jest odpowiedzialny za obsługę błędu? Czy przestarzały wynik może nadal zastąpić wspólny stan? A gdy coś się timeoutuje, czy oznacza to porażkę operacji, czy tylko to, że dany wywołujący zrezygnował z czekania?
Gdy te pytania zostaną odpowiedziane, sam kod JavaScript zazwyczaj wpisuje się w strukturę bez większych trudności.
W sekwencjach, gdzie kolejność ma rzeczywiście znaczenie, można użyć pętli, która kolejno oczekuje na każdy krok. Niezależne zadania o znanej, ograniczonej liczbie mogą być wykonywane równocześnie. Większe zbiory danych można przetwarzać przy zachowaniu ograniczeń dotyczących równoczesności. Zadania, które nie muszą blokować wywołującego, można przekazać do trwałej kolejki. Aktualizacje wspólnego stanu mogą być kontrolowane za pomocą wyraźnych sprawdzeń prawa własności. Zapisy do bazy danych mogą atomowo zapewnić zachowanie swoich niezmienników na poziomie warstwy przechowywania. Operacje, w których powstanie duplikatu byłoby kosztowne, mogą wykorzystywać klucze idempotencji, aby ponowne wykonanie nie tworzyło drugiej kopii tego samego efektu.
Najtrudniejsze jest właściwe umieszczenie instrukcji await. Chodzi o ustalenie, co dokładnie oznacza „zakończenie” na każdym etapie, który operacja przechodzi.
Znajomość słowa kluczowego, ale nie umowy
Błędne używanie async/await przez lata ma mniej związek z zapomnieniem, jak działa ta składnia, a bardziej z faktem, że sprawia ono, iż kod asynchroniczny wygląda o wiele bardziej sekwencyjnie, lokalnie i przewidywalnie, niż w rzeczywistości jest.
Widok słowa await skłania do założenia, że wszystko wokół niego również zostało wstrzymane. Łatwo przeoczyć wywołanie funkcji asynchronicznej bez określenia, kto będzie odpowiedzialny za jej ostateczne zakończenie. Wykonywanie synchronicznych metod tablic z asynchronicznymi callbackami traktuje je tak, jakby te dwa elementy się rozumiały, podczas gdy w rzeczywistości tak nie jest. Używanie Promise.all bez zastanowienia się nad obciążeniem lub tym, co dzieje się, gdy tylko niektóre obietnice się powiodą, to naturalny, ale ryzykowny nawyk. Zaufanie do kodu, który jest czytany od góry do dołu, nawet gdy kilka wywołań może rzeczywiście zachodzić jednocześnie, ukrywa problemy związane z konkurencją. Oczekiwanie, że try/catch złapie błędy pochodzące z obietnic, których nigdy nie czekano, oraz mylenie anulowanej prośby z dowodem na to, że nic się nie stało na serwerze – oba te przypadki wynikają z tego samego braku świadomości.
Każdy z tych błędów wynika z tego samego problemu: różnicy pomiędzy tym, jak wygląda kod, a tym, co faktycznie obiecuje.
Dobre zrozumienie kodu asynchronicznego oznacza śledzenie obietnic pomiędzy granicami funkcji, a nie tylko czytanie od góry do dołu. Oznacza to sprawdzanie, kto je tworzy, kto na nie czeka, kto jest odpowiedzialny za obsługę odrzuceń oraz który element stanu nadal ma władzę nad wynikiem po zakończeniu wszystkich operacji. Oznacza to również uważne obserwowanie każdego miejsca, gdzie await powoduje pauzę, ponieważ to właśnie wtedy inne operacje mogą zmienić założenia, na których opierała się funkcja.
Kod może nadal wyglądać sekwencyjnie na stronie. Taki wygląd nie oznacza już jednak, że system zachowuje się w ten sposób.
Ta zmiana przekształca asynchroniczny JavaScript z zestawu słów kluczowych w model oparty na koncepcjach własności, synchronizacji czasowej i radzenia sobie z błędami. Wyjaśnia również, dlaczego kod, który przez lata wydawał się poprawny, może nadal zachowywać się niewłaściwie w środowisku produkcyjnym, mimo że przechodzi wszystkie testy.
Nauka czekania na realizację obietnicy to najłatwiejsza część.
Zrozumienie tego, co robi reszta programu podczas tego oczekiwania, wymaga znacznie więcej czasu.
Literatura pokrewna
- Pięć oszukańczych błędów w Node.js, które przechodzą przegląd kodu niezauważone — Przykłady pięciu rzeczywistych błędów w Node.js związanych z metodą forEach, obietnicami o zmiennej wartości i płytkimi kopiiami, aby zrozumieć, dlaczego kod działający poprawnie może nadal zawodzić w środowisku produkcyjnym.