Головна / Статті / Вибір між Promise.all, Promise.race та послідовними await-операторами

Вибір між Promise.all, Promise.race та послідовними await-операторами

Дізнайтеся, коли Promise.all() прискорює роботу API Node.js, чому він швидко зазнає невдачі при будь-якому відхиленні, та яка система прийняття рішень для вибору правильного асинхронного паттерну.

2267 слів

Виконання асинхронних завдань у Node.js спочатку здається простим рішенням: можна виконувати кілька операцій одночасно замість того, щоб чекати на них по черзі, і так API стає швидшим. Однак на практиці використання Promise.all() як звички замість свідомого вибору може тихо перетворити підвищення швидкості на проблему надійності. Так само важливо знати, коли операції можна паралелізувати, що має статися, якщо одна з них зламається, та коли підходить інший інструмент, як і знання синтаксису.

1. Послідовний підхід

  • Інформацію про користувача
  • Замовлення
  • Оплати

Наївний перший підхід може виглядати так:

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

Цей код працює без проблем. Але уважно подивіться на порядок виконання:

getUser()
   ↓
getOrders()
   ↓
getPayments()

Кожен крок чекає, поки завершиться попередній. Операція отримання замовлень не може розпочатися, поки не завершиться операція отримання користувача, а операція отримання платежів — поки не завершиться операція отримання замовлень. Якщо кожен виклик триває приблизно однаково довго:

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

тоді загальний час виконання запиту становить приблизно:

200 + 200 + 200 = 600ms

Це марна трата часу, якщо ці три виклики не мають між собою жодного зв’язку.

2. Використання Promise.all()

Коли операції не залежать одна від одної, їх можна запускати одночасно:

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

Тепер потік виконання виглядає більш схожим на цей:

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

Замість того, щоб нести витрати:

A + B + C

ваш загальний час очікування становить приблизно:

max(A, B, C)

Отже, якщо кожен з трьох викликів все ще триває близько 200 мс:

Sequential:  ~600ms
Parallel:    ~200ms

Це значуща перевага. Але є нюанс, який варто пам’ятати:

Promise.all() не прискорює жодної окремої операції.

Він просто дозволяє незалежним операціям виконуватися одночасно, а не по черзі.

3. Приклад API з реального світу

Розгляньмо екран панелі керування, який потребує відображення:

Profile
Orders
Wishlist
Notifications

Це може відповідати чотирьом окремим запитам до бази даних або викликам сервісу. Якщо написати їх послідовно, це може виглядати так:

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
};

Тепер порівняйте це з паралельною версією:

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

Якщо припустити, що ці чотири виклики справді не залежать один від одного, така переробка може значно зменшити загальну затримку API. Це одна з найпростіших способів покращення продуктивності Node.js.

Але це не кінець історії — є умова, яку потрібно зрозуміти, перш ніж застосовувати цю схему всюди.

4. Promise.all() швидко зазнає невдачі

Ось те, що спричиняє проблеми у користувачів. Розгляньмо цей приклад:

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

Що станеться, якщо getPayments() кине виклик до відхилення?

Увесь виклик Promise.all() буде відхилено, незалежно від того, як пройшли інші виклики:

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

Ви не отримаєте часткового результату з двома успішними викликами та позначки про невдачу. Ви отримаєте виняток, і жодні дані не будуть передані:

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

Така модель «усе або нічого» має сенс, коли кожна операція в групі є необхідною для того, щоб відповідь була коректною. Але це не завжди є правильним рішенням.

5. Коли Promise.all() — неправильний вибір

Припустимо, панель керування має відображати:

  • Профіль
  • Рекомендації
  • Повідомлення
  • Якщо сервіс рекомендацій тимчасово не працює, чи має це призвести до того, що весь панель керування не завантажиться? Майже напевно, ні. Кращим рішенням буде щось на кшталт:

    Profile          → Available
    Notifications    → Available
    Recommendations  → Unavailable
    

    Саме для такої ситуації створений метод Promise.allSettled():

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

    Замість того, щоб зупинитися при першій відмові, він чекає, поки всі обіцянки будуть виконані, і повідомляє про кожну з них:

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

    Після цього логіка вашого додатку може вирішити, як обробляти кожен окремий результат. Різниця полягає саме в цьому:

    Promise.all()
    
    One fails
       ↓
    Everything rejects
    

    порівняно з:

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

    Жоден з підходів не є за своєю суттю кращим — вони розроблені для вирішення різних проблем, і вибір правильного залежить від того, чи слід розглядати одну невдачу як фатальну для всієї групи.

    6. Ланцюгові операції не слід примушувати до паралельного виконання

    Є ще одна підступність, на яку варто звернути увагу.

    Припустимо, ваша робоча процедура вимагає від вас:

    1. Створити користувача
    2. Отримати ID цього користувача
    3. Створити замовлення, пов’язане з цим користувачем

    Ці кроки залежать один від одного.

    Ви фізично не можете створити замовлення до того, як існуватиме запис про користувача.

    Це означає, що код на кшталт цього є некоректним:

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

    Крок створення замовлення, ймовірно, потребує ID користувача як вхідних даних.

    Правильним підходом є виконання цих кроків по черзі:

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

    Основний принцип тут є простим:

    Операції, які не мають між собою зв’язку, підходять для паралельної обробки.

    Операції, які залежать від результатів інших операцій, повинні виконуватися послідовно.

    Не вдаються до паралелізму лише тому, що мова це дозволяє.

    7. Неконтрольований паралелізм може завантажити вашу систему

    Існує ще одна більш тонка проблема, яку легко проігнорувати.

    Розгляньмо цей фрагмент коду:

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

    З 10 користувачами це навряд чи спричинить проблеми.

    З 10 000 користувачами ви починаєте виконувати тисячі одночасних операцій.

    Більша кількість одночасних операцій не обов’язково призводить до кращої продуктивності.

    Ви можете зіткнутися з:

    • Обмеженнями на кількість з’єднань до бази даних
    • Лімітами швидкості API
    • Навантаженням на пам’ять
    • Заторами в мережі
  • Обмеження, накладені сторонніми сервісами
  • Зростання рівня помилок
  • Замість необмеженої паралельної обробки вам часто потрібна обмежена конкуруентність.

    Один із способів досягти цього — використання бібліотеки для обмеження конкуруентності:

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

    За такої налаштовки одночасно виконується не більше п’яти операцій.

    Візуально різниця виглядає ось так:

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

    Це повільніше, ніж запуск усіх 1 000 завдань одночасно.

    Але це надає значно більше контролю.

    У реальних умовах контрольована обробка часто працює краще в цілому, оскільки уникає перевантаження ресурсів, від яких залежать ваші операції.

    8. Promise.race() вирішує іншу проблему

    Існує ще один метод, який часто плутають з Promise.all():

    Promise.race()
    

    Promise.race() приймає рішення — вирішує або відхиляє — у момент, коли перший з об’єктів Promise у групі приймає рішення.

    Наприклад:

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

    Візуально:

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

    Цей паттерн має закономірні сфери застосування, такі як порівняння дублюючихся запитів між собою або реалізація поведінки таймауту.

    Promise.race() не зупиняє автоматично операції, які програють у цьому порівнянні.

    Якщо вам потрібно скасувати такі операції, вам доведеться зробити це самостійно, зазвичай за допомогою чогось на кшталт AbortController.

    9. Не забувайте про поведінку перепробувань

    Припустимо, ви здійснюєте виклик до зовнішнього сервісу:

    const result = await paymentService();
    

    Виклик провалюється через тимчасові проблеми мережі.

    Якщо ви обгорнете все у велику функцію Promise.all() та сліпо будете повторювати спроби у разі помилки, це може призвести до нових проблем.

    Ви ризикуєте спричинити „бурю повторних спроб“.

    Перш ніж починати повторні спроби, розгляньте:

    • Які операції насправді можна безпечно повторювати?
    • Скільки спроб повторення слід дозволити?
    • Скільки часу потрібно чекати між спробами?
    • Чи є операція ідемпотентною?
    • Що робити, якщо зовнішня служба вже має проблеми через навантаження?

    Поширеним підходом до тимчасових помилок є експоненційне затримування спроб.

    Концептуально:

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

    Швидкість мало значить, якщо вона підриває надійність.

    10. Застосовуйте ту саму логіку до запитів до бази даних

    Хочеться думати, що оскільки JavaScript підтримує конкуруючі обіцянки, запити до бази даних завжди слід виконувати паралельно.

    Це не завжди так.

    Візьмемо цей приклад:

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

    Це запускає приблизно п’ять операцій з базою даних одночасно.

    Це може бути цілком нормально.

    Або ж це може перевантажити вашу базу даних під час піку навантаження.

    Фактори, які варто врахувати, включають:

    • Наскільки складним є кожен запит
    • Розмір пулу з’єднань до бази даних
    • Скільки інстанцій API працює
    • Загальний обсяг трафіку
    • Чи існують належні індекси
    • Скільки часу потрібно для виконання кожного запиту
    • Вільні ресурси CPU та пам’яті на сервері бази даних

    Оптимізацію продуктивності не можна проводити ізольовано.

    Ваш API — це лише частина набагато більшої системи.

    11. Фреймворк для вирішення, коли використовувати Promise.all()

    Перш ніж застосовувати Promise.all(), корисно розглянути три запитання.

    Питання 1: Чи є операції незалежними?

    Якщо так, їх виконання паралельно може бути доцільним.

    Якщо ні, необхідно дотримуватися послідовності, від якої вони залежать.

    Питання 2: Що має статися, якщо одна операція зазнає невдачі?

    Якщо одна невдача має зробити всю партію недійсною:

    Promise.all()
    

    ймовірно, є правильним інструментом.

    Якщо ви хочете зібрати результати тих операцій, які виконалися успішно:

    Promise.allSettled()
    

    зазвичай є кращим варіантом.

    Питання 3: Скільки операцій запускається одночасно?

    Три паралельні виклики?

    Це зазвичай під контролем.

    Десять тисяч?

    Це зовсім інша проблема.

    Вам може знадобитися впровадити:

    • Ліміти на паралельність
    • Групування операцій у партії
    • Очікування у черзі
    • Сторінкування
    • Обмеження швидкості

    12. Короткий посібник з вибору підходу

    Ситуація Кращий підхід
    Незалежні операції, всі мають завершитися успішно Promise.all()
    Незалежні операції, частковий успіх допустимий Promise.allSettled()
    Операції залежать одна від одної Послідовне використання await
    Вам потрібен лише той варіант, який завершиться першим Promise.race()
    Багато завдань, що вимагають обмеженої конкурентності p-limit або групування
    Повільна, неважлива фонова робота Очередь або процес-робітник
    Зовнішні виклики, схильні до тимчасових помилок Логіка повторних спроб із затримкою

    Важливо не запам’ятовувати цю таблицю.

    Важливо зрозуміти логіку кожного вибору.

    13. Основний висновок

    Коли ви вперше стикаєтесь з Promise.all(), природно припускати:

    "Виконання завдань паралельно завжди є швидшим."

    Цей припущення не є правильним.

    Більш точний підхід полягає у наступному:

    Паралельне виконання є ефективним лише тоді, коли система, яка його підтримує, може справді обробити одночасний навантаження.

    Якщо у вас є три незалежні операції, кожна з яких триває 100 мс:

    Sequential → ~300ms
    Parallel   → ~100ms
    

    Покращення є очевидним.

    Але якщо кількість операцій збільшиться до 10 000, а база даних обмежена 100 одночасними з’єднаннями, неконтрольований паралелізм може погіршити продуктивність замість того, щоб її покращити.

    Краще поставити собі таке запитання:

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

    Ця зміна підходу відрізняє просте знання синтаксису JavaScript від справжнього розуміння того, як функціонують бекенд-системи під навантаженням.

    Остаточні висновки

    Promise.all() залишається одним із найцінніших інструментів у Node.js для одночасного виконання незалежних асинхронних завдань.

    Проте це не гарантує прискорення.

    Використовуйте його, коли:

    • Операції не залежать одна від одної
    • Вам справді потрібні всі результати
    • Ваша система може впоратися з доданою конкурентністю

    Використовуйте Promise.allSettled(), коли допустимо, щоб деякі операції зазнали невдачі, не порушуючи роботу інших.

    Використовуйте послідовні виклики await, коли операції залежать від результатів одна від одної.

    Встановлюйте обмеження на конкурентність, коли ви обробляєте велику кількість завдань одночасно.

    І використовуйте черги, коли роботу не потрібно завершити протягом терміну життя HTTP-запиту.

    Мета не лише скоротити кількість мілісекунд у вашому коді.

    Мета — зробити вашу систему швидшою, не роблячи її крихкою.

    Пов’язана література

  • Усунення помилок обробки помилок Async/Await у продакшн-коді Node.js — Дізнайтеся про п’ять поширених помилок у обробці помилок async/await у JavaScript та Node.js, які спричиняють беззвучні збої та ситуації конкуренції, а також про конкретні способи їх усунення.
  • Пояснення конкурентності в Node.js: libuv, цикл подій та пул потоків — Дізнайтеся, як Node.js використовує примітиви операційної системи libuv та пул робочих потоків для обробки асинхронних операцій вводу-виводу, а також про поширені проблеми пулу потоків та поради щодо його налаштування.
  • Вибір між EC2, ECS та EKS для завдань Node.js — Порівнює способи, якими EC2, ECS з Fargate та EKS керують роботою додатків Node.js, щоб допомогти вам обрати правильну послугу обчислень AWS відповідно до масштабу та навичок вашої команди.
  • Кореляція журналів між асинхронними викликами за допомогою AsyncLocalStorage — Дізнайтеся, як Node.js AsyncLocalStorage відстежує контекст кожного запиту, такий як requestId, через межі await, не вимагаючи ручного передавання його через кожну функцію.