Strona główna / Artykuły / Wybór między Promise.all, Promise.race a sekwencyjnymi awaitami

Wybór między Promise.all, Promise.race a sekwencyjnymi awaitami

Dowiedz się, kiedy Promise.all() przyspiesza API Node.js, dlaczego szybko zawodzi przy każdej odrzuceń oraz jak wybrać odpowiedni wzorzec asynchroniczny za pomocą ram decyzyjnych.

2267 słów

Wykonywanie zadań asynchronicznych w Node.js na początku wydaje się prostym rozwiązaniem: można uruchomić kilka operacji jednocześnie zamiast czekać na nie kolejno, co sprawia, że API staje się szybsze. W praktyce jednak traktowanie Promise.all() jako nawyku standardowego, a nie świadomej decyzji, może potajemnie przekształcić wzrost szybkości w problem z niezawodnością. Równie ważne jest znanie momentu, w którym operacje można bezpiecznie paralelizować, tego, co powinno się stać, jeśli jedna z nich zawiedzie, oraz kiedy inne narzędzie będzie lepsze, co nie mniej istotne niż znajomość składni.

1. Podejście sekwencyjne

Załóżmy API, które musi połączyć trzy elementy:

  • Informacje o użytkowniku
  • Zamówienia
  • Płatności

Naiwny pierwszy podejście mogłoby wyglądać tak:

const user = await getUser(userId);
const orders = await getOrders(userId);
const payments = await getPayments(userId);
return {
  user,
  orders,
  payments
};

Ten kod działa poprawnie. Ale przyjrzyj się uważnie kolejności wykonywania:

getUser()
   ↓
getOrders()
   ↓
getPayments()

Każdy krok czeka, aż poprzedni się zakończy. Operacja pobierania zamówień nie może rozpocząć się, dopóki nie zakończy się operacja pobierania użytkowników, a operacja pobierania płatności nie może rozpocząć się, dopóki nie zakończy się operacja pobierania zamówień. Jeśli każde wezwanie trwa mniej więcej tyle samo czasu:

getUser()      = 200ms
getOrders()    = 200ms
getPayments()  = 200ms

wtedy całkowity czas wysyłania żądania wynosi mniej więcej tyle:

200 + 200 + 200 = 600ms

To jest zmarnowany czas, jeśli te trzy wezwania nie mają ze sobą nic wspólnego.

2. Użyj Promise.all()

Gdy operacje nie zależą od siebie, można je uruchomić jednocześnie:

const [user, orders, payments] = await Promise.all([
  getUser(userId),
  getOrders(userId),
  getPayments(userId)
]);
return {
  user,
  orders,
  payments
};

Teraz przepływ wykonywania przypomina coś takiego:

getUser()       ────────┐
                        │
getOrders()     ────────┤
                        ├──→ Promise.all()
getPayments()   ────────┘

Zamiast ponosić koszt:

A + B + C

całkowity czas oczekiwania wynosi mniej więcej tyle:

max(A, B, C)

Zatem jeśli każde z tych trzech wezwań nadal zajmuje około 200 ms:

Sequential:  ~600ms
Parallel:    ~200ms

To jest istotna korzyść. Ale istnieje niuans, który warto pamiętać:

Promise.all() nie przyspiesza żadnej pojedynczej operacji.

Po prostu umożliwia wykonywanie niezależnych operacji równocześnie, zamiast jeden po drugim.

3. Przykład API z rzeczywistego świata

Rozważmy ekran panelu kontrolnego, który musi wyświetlić:

Profile
Orders
Wishlist
Notifications

To mogłoby odpowiadać czterem oddzielnym zapytaniom do bazy danych lub wywołaniom usług. Napisane sekwencyjnie, mogłyby wyglądać w ten sposób:

const profile = await getUserProfile(userId);
const orders = await getUserOrders(userId);const wishlist = await getUserWishlist(userId);const notifications = await getUserNotifications(userId);return {
  profile,
  orders,
  wishlist,
  notifications
};

A teraz porównaj to z wersją równoległą:

const [
  profile,
  orders,
  wishlist,
  notifications
] = await Promise.all([
  getUserProfile(userId),
  getUserOrders(userId),
  getUserWishlist(userId),
  getUserNotifications(userId)
]);
return {
  profile,
  orders,
  wishlist,
  notifications
}

Przy założeniu, że te cztery wywołania rzeczywiście nie zależą od siebie, ta przepracowanie może znacznie zmniejszyć całkowitą opóźnioność API. Jest to jeden z najprostszych sposobów na poprawę wydajności w Node.js.

Ale to nie koniec historii — istnieje pewien haczyk, który musisz zrozumieć, zanim zastosujesz ten wzorzec wszędzie.

4. Promise.all() zawodzi szybko

Oto to, co sprawia problemy. Weźmy ten przykład:

const results = await Promise.all([
  getUser(),
  getOrders(),
  getPayments()
]);

Co się stanie, jeśli getPayments() wywoła błąd?

Celowe wezwanie Promise.all() zawsze zawiedzie, bez względu na to, jak przebiegły pozostałe wywołania:

getUser()       → SUCCESS
getOrders()     → SUCCESS
getPayments()   → ERROR
                ↓          Promise.all()
                ↓
             REJECT

Nie otrzymujesz częściowego wyniku z dwoma udanymi wywołaniami oraz oznaczenia dla tego, które zawiodło. Otrzymujesz wyjątek, a żadne dane nie trafiają dalej:

try {
  const [user, orders, payments] = await Promise.all([
    getUser(userId),
    getOrders(userId),
    getPayments(userId)
  ]);
} catch (error) {
  console.error(error);
}

To zachowanie typu „wszystko albo nic” ma sens, gdy każda operacja w grupie jest konieczna, aby odpowiedź była ważna. Ale nie zawsze jest to właściwe rozwiązanie.

5. Kiedy Promise.all() to zły wybór

Powiedzmy, że panel sterowania musi pokazywać:

  • Profil
  • Zalecenia
  • Powiadomienia
  • Jeśli usługa zaleceń jest tymczasowo niedostępna, czy cała karta sterująca powinna przestać się ładować? Prawie na pewno nie. Lepszym rozwiązaniem byłoby coś w rodzaju:

    Profile          → Available
    Notifications    → Available
    Recommendations  → Unavailable
    

    To jest dokładnie sytuacja, do której została stworzona funkcja Promise.allSettled():

    const results = await Promise.allSettled([
      getUserProfile(userId),
      getRecommendations(userId),
      getNotifications(userId)
    ]);
    

    Zamiast zatrzymać się przy pierwszej odmowie, czeka ona, aż wszystkie obietnice zostaną rozstrzygnięte, i zgłasza informacje o nich wszystkich:

    [
      {
        status: "fulfilled",
        value: profile
      },
      {
        status: "rejected",
        reason: error
      },
      {
        status: "fulfilled",
        value: notifications
      }
    ]
    

    Następnie logika aplikacji może zdecydować, jak postąpić z każdym pojedynczym wynikiem. Różnica sprowadza się do tego:

    Promise.all()
    
    One fails
       ↓
    Everything rejects
    

    w porównaniu z:

    Promise.allSettled()
    
    One fails
       ↓
    You still receive every result
    

    Żaden z tych podejść nie jest z natury lepszy — zostały one zaprojektowane do rozwiązywania różnych problemów, a wybór odpowiedniego zależy od tego, czy pojedynczy błąd powinien być traktowany jako fatalny dla całej grupy.

    6. Operacje łańcuchowe nie powinny być przymuszane do wykonywania równolegle

    Istnieje jeszcze jedna pułapka, na którą warto zwrócić uwagę.

    Załóżmy, że twój proces pracy wymaga od ciebie:

    1. Stworzenia użytkownika
    2. Pobrania ID tego użytkownika
    3. Stworzenia zamówienia powiązanego z tym użytkownikiem

    Te kroki są od siebie zależne.

    Fizycznie nie można stworzyć zamówienia przed istnieniem rekordu użytkownika.

    To oznacza, że pisanie czegoś w tym stylu jest błędne:

    await Promise.all([
      createUser(),
      createOrder()
    ]);
    

    Krok tworzenia zamówienia prawdopodobnie wymaga ID użytkownika jako wejścia.

    Prawidłowym podejściem jest wykonywanie tych kroków jeden po drugim:

    const user = await createUser();
    
    const order = await createOrder(user.id);
    

    Zasada kierująca jest tutaj prosta:

    Operacje, które nie są ze sobą powiązane, nadają się do wykonywania równolegle.

    Operacje, które polegają na wynikach innych operacji, muszą być wykonywane sekwencyjnie.

    Nie stosuj paralelizmu tylko dlatego, że język na to pozwala.

    7. Nieograniczony paralelizm może przytłoczyć twój system

    Istnieje subtelniejszy problem, który łatwo przeoczyć.

    Rozważmy ten fragment kodu:

    await Promise.all(
      users.map(user => sendEmail(user.email))
    );
    

    Przy 10 użytkownikach raczej nie powinno to sprawić żadnych problemów.

    Przy 10 000 użytkownikach uruchamiasz tysiące operacji jednocześnie.

    Możesz napotkać:

    • Ograniczenia dotyczące połączeń z bazą danych
    • Limity szybkości API
    • Nawantrzenie pamięci
    • Zatory w sieci
  • Ograniczenia narzucane przez usługi third-party
  • Nagły wzrost wskaźników błędów
  • Zamiast nieograniczonej równoległej eksploatacji, często potrzebna jest ograniczona konkurencja.

    Jednym ze sposobów na jej osiągnięcie jest użycie biblioteki ograniczającej konkurencję:

    import pLimit from "p-limit";
    
    const limit = pLimit(5);const results = await Promise.all(
      users.map(user =>
        limit(() => sendEmail(user.email))
      )
    );
    

    W takim ustawieniu jednocześnie może być wykonywanych maksymalnie pięć operacji.

    Wizualnie różnica wygląda tak:

    1000 tasks
    
         ↓Concurrency limit = 5     ↓5 tasks
    5 tasks
    5 tasks
    5 tasks
    ...
    

    To jest wolniejsze niż uruchomienie wszystkich 1000 zadań naraz.

    Jednak daje znacznie większą kontrolę.

    A w warunkach rzeczywistych kontrolowana eksploatacja często daje lepsze wyniki ogólne, ponieważ unika przeciążenia zasobów, od których zależą Twoje operacje.

    8. Promise.race() rozwiązuje inne problemy

    Istnieje jeszcze jedna metoda, którą myli się z Promise.all():

    Promise.race()
    

    Promise.race() ustala się – rozwiązując lub odrzucając – w momencie, gdy pierwsza obietnica w grupie się ustali.

    Na przykład:

    const result = await Promise.race([
      serverA(),
      serverB()
    ]);
    

    Wizualnie:

    Server A ───────────────→ 500ms
    
    Server B ───────→ 200ms                    ↓
                   Promise.race()
                        ↓
                     Result
    

    Ten wzorzec ma uzasadnione zastosowania, takie jak porównywanie ze sobą zbędnych żądań lub implementacja zachowania timeoutu.

    Jednak pamiętaj o tym:

    Promise.race() nie zatrzymuje automatycznie operacji, które przegrywają tę rywalizację.

    Jeśli musisz anulować przegrywające operacje, musisz to zrobić samodzielnie, zazwyczaj za pomocą czegoś takiego jak AbortController.

    9. Nie zapominaj o zachowaniu ponawiania prób

    Załóżmy, że wysyłasz żądanie do zewnętrznej usługi:

    const result = await paymentService();
    

    Żądanie zawodzi z powodu chwilowego problemu sieciowego.

    Jeśli otoczysz wszystko dużym Promise.all() i ślepo będziesz próbował ponownie po niepowodzeniu, możesz spowodować nowy problem.

    Ryzykujesz wywołanie burzy ponownych prób.

    Zanim spróbujesz ponownie, rozważ:

    • Które operacje są faktycznie bezpieczne do ponownej próby?
    • Ile prób powtórzenia powinno być dozwolonych?
    • Jak długo należy czekać między próbami?
    • Czy operacja jest idempotentna?
    • A co, jeśli usługa zewnętrzna już ma trudności pod obciążeniem?

    Powszechnym rozwiązaniem na błędy tymczasowe jest eksponencjalne opóźnienie.

    Koncepcyjnie:

    Attempt 1 → fail
         ↓
       wait
         ↓
    Attempt 2 → fail
         ↓
      wait longer
         ↓
    Attempt 3 → success
    

    Szybkość ma niewielkie znaczenie, jeśli podważa niezawodność.

    10. Zastosuj to samo rozumowanie do zapytań bazodanowych

    Kuszące jest myślenie, że ponieważ JavaScript obsługuje równoczesne obietnice, zapytania bazodanowe powinny zawsze być wysyłane równolegle.

    To nie zawsze jest prawdą.

    Weźmy ten przykład:

    await Promise.all([
      database.users.findMany(),
      database.orders.findMany(),
      database.products.findMany(),
      database.payments.findMany(),
      database.notifications.findMany()
    ]);
    

    To uruchamia mniej więcej pięć operacji bazy danych jednocześnie.

    To może być zupełnie w porządku.

    Lub może to przeciążyć twoją bazę danych podczas szczytu ruchu.

    Czynniki, które warto wziąć pod uwagę, to:

    • Jak złożona jest każda zapytanie
    • Rozmiar puli połączeń do bazy danych
    • Ile instancji API jest uruchomionych
    • Ogólny wolumen ruchu
    • Czy istnieją odpowiednie indeksy
    • Ile czasu zajmuje wykonanie każdego zapytania
    • Zapas mocy obliczeniowej i pamięci na serwerze bazy danych

    Optymalizacja wydajności nie może odbywać się w próżni.

    11. Ramy decyzyjne dotyczące użycia Promise.all()

    Zanim zastosujesz Promise.all(), pomocne jest rozważenie trzech pytań.

    Pytanie 1: Czy operacje są niezależne?

    Jeśli tak, ich wykonywanie równolegle może być opłacalne.

    Jeśli nie, należy zachować kolejność, od której zależą.

    Pytanie 2: Co powinno się stać, jeśli jedna operacja zawiedzie?

    Jeśli pojedyncza awaria powinna unieważnić całą partię:

    Promise.all()
    

    prawdopodobnie jest to odpowiedni narzędzie.

    Jeśli wolisz zebrać wszystkie wyniki uzyskane pomyślnie:

    Promise.allSettled()
    

    zazwyczaj jest to lepsze rozwiązanie.

    Pytanie 3: Ile operacji jest uruchamianych jednocześnie?

    Trzy równoległe wywołania?

    To zazwyczaj da się obsłużyć.

    Dziesięć tysięcy?

    To zupełnie inny wyzwanie.

    Powinieneś rozważyć wprowadzenie:

    • Limitów równoległości
    • Grupowania operacji
    • Kolejkowania zadań
    • Paginacji
    • Ograniczania szybkości wykonywania

    12. Krótka przewodnik do wyboru podejścia

    Sytuacja Lepsze podejście
    Operacje niezależne, wszystkie muszą się udać Promise.all()
    Operacje niezależne, częściowy sukces jest do przyjęcia Promise.allSettled()
    Operacje zależą od siebie Sekwencyjne await
    Potrzebny jest tylko ten, który skończy się pierwszy Promise.race()
    Liczne zadania wymagające ograniczonej równoczesności p-limit lub grupowanie zadań
    Wolne, nieważne zadania w tle Kolejka lub proces roboczy
    Zawodne połączenia z zewnętrznymi serwerami Logika ponawiania prób z opóźnieniami

    Nie chodzi o zapamiętanie tej tabeli.

    Chodzi o zrozumienie uzasadnienia każdego wyboru.

    13. Główny wniosek

    Gdy po raz pierwszy spotykamy się z Promise.all(), naturalne jest założenie:

    "Wykonywanie zadań równolegle jest zawsze szybsze."

    To założenie nie jest prawdziwe.

    Bardziej dokładny sposób patrzenia na to jest następujący:

    Równoległe wykonywanie jest opłacalne tylko wtedy, gdy system pod nim faktycznie może obsłużyć taką liczbę zadań jednocześnie.

    Jeśli masz trzy niezależne operacje, z których każda trwa 100 ms:

    Sequential → ~300ms
    Parallel   → ~100ms
    

    Poprawa jest oczywista.

    Jednak jeśli zwiększysz to do 10 000 operacji przeciwko bazie danych ograniczonej do 100 jednoczesnych połączeń, niekontrolowany paralelizm może pogorszyć wydajność zamiast ją poprawić.

    Lepiej postawić sobie takie pytanie:

    "Can I run these in parallel?"Ask:"Should I run these in parallel?"
    

    Taka zmiana sposobu myślenia odróżnia znajomość składni JavaScript od rzeczywistego zrozumienia tego, jak systemy backendowe zachowują się pod obciążeniem.

    Ostateczny wniosek

    Promise.all() pozostaje jednym z najcenniejszych narzędzi w Node.js do równoczesnego wykonywania niezależnych zadań asynchronicznych.

    Niemniej jednak nie gwarantuje to przyspieszenia.

    Użyj go wtedy, gdy:

    • Operacje nie są od siebie zależne
    • Rzeczywiście potrzebujesz wszystkich wyników
    • Twój system może obsłużyć dodatkową równoczesność

    Użyj Promise.allSettled(), gdy dopuszczalne jest, aby niektóre operacje zawiodły bez zakłócenia pozostałych.

    Użyj sekwencyjnych wywołań await, gdy operacje zależą od wyników siebie nawzajem.

    Użyj ograniczeń równoczesności, gdy obsługujesz dużą liczbę zadań jednocześnie.

    I uciekaj się do kolejek, gdy praca nie musi zostać zakończona w czasie trwania żądania HTTP.

    Celem nie jest tylko skrócenie czasu wykonywania kodu o kilka milisekund.

    Celem jest sprawienie, by twój system działał szybciej, nie stając się przy tym kruchy.

    Literatura pokrewna

  • Naprawianie błędów w obsłudze błędów Async/Await w kodzie produkcyjnym Node.js — Poznaj pięć powszechnych błędów w obsłudze błędów async/await w JavaScript i Node.js, które powodują ciche awarie i sytuacje konkurencyjne, oraz konkretne sposoby ich naprawy.
  • Wyjaśnienie konkurencji w Node.js: libuv, pętla zdarzeń i zbiór nici — Dowiedz się, jak Node.js wykorzystuje prymitywy systemu operacyjnego libuv oraz zbiór nici roboczych do obsługi asynchronicznego I/O, a także o powszechnych problemach związanych z zbiorem nici i wskazówkach dotyczących jego optymalizacji.
  • Wybór między EC2, ECS a EKS dla zadań Node.js — Porównuje sposób, w jaki EC2, ECS z Fargate oraz EKS zarządzają aplikacjami Node.js pod względem operacyjnym, pomagając wybrać odpowiednią usługę obliczeniową AWS dostosowaną do skali i umiejętności zespołu.
  • Korelacja logów pomiędzy asynchronicznymi wywołaniami za pomocą AsyncLocalStorage — Dowiedz się, jak Node.js AsyncLocalStorage śledzi kontekst każdej żądania, takiego jak requestId, pomiędzy blokami await, bez konieczności ręcznego przenoszenia go przez każdą funkcję.