Галоўная / Артыкулы / Выбір между Promise.all, Promise.race і последовымі await-заявамі

Выбір между Promise.all, Promise.race і последовымі await-заявамі

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

2267 слоў

Выкананне асінхронных задач у Node.js частаца здаецца простым рашэнням: вы караеце кілька операцыяў адразу, замест таго каб чакаць на яны па архіве, і ваш API становіцца быстрэйшым. Аднак у практыцы выкарыстоўванне Promise.all() як стандартнай практыки, а не як свядомага выбору, можа таямніча ператварыць падвышэнне швальнасці на проблему надзеямасці. Не менш важлівае, чым знаўство синтаксісу, ёст тое, калі можна безбедна паралелізаваць операцыі, што трэба рабіць, якшо адна з іх не выйдзе, і калі інструмент падходзіць лепей.

1. Парадный падход

Уявіце API, які должен з’едынаць тры элементы:

  • Інформацыя пра корыстувальца
  • Замовленні
  • Аплатацыі

Проста першая версія можа выглядаць так:

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

Этот код работает нормально. Але внимательно посмотрите на порядок выполнения:

getUser()
   ↓
getOrders()
   ↓
getPayments()

Кожны крок чакае, пакуль не завершыцца паперадні. Запуск аперацыі fetch не можа пачацца, пакуль не будзе завершана аперацыя fetch для замовленняў, а запуск аперацыі fetch для платэжаў не можа пачацца, пакуль не будзе завершана аперацыя fetch для замовленняў. Якшчо кожны вызов трывае прыблізна адно і тое ж час:

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() і слепа прабавайце зноў, калі выйшла памылка, вы можете створыць новую проблему.

    Вы рызікуеце спрычыніць шторм прабоўкаў.

    Перад тым, як прабаваць зноў, разважыце:

    • Калі саме операцыі можна безпечна прабаваць зноў?
    • Скількі прабоўкаў можна дазволіць?
    • Сколькі часу трэба чакаць между прабоўкамі?
    • Чы ўсё ж таки операцыя ідэмпатна?
    • Што, як зовнішняй сервіс вялікім навантажэнням вже мае труднасці?

    Пашырэны спосаб борьбы з транзіённымі памылкамі — экспансіявы backoff.

    Канцэптуальна:

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

    Швалёнасць мае мала значэнне, якщо ёй паслужае падрыв надзяйнасці.

    10. Застосаваць тое ж правілу да запытоў базы дадзенаў

    Хочацца думаць, што адтолькі, як JavaScript падтрымляе паралельныя promises, запыты базы дадзенаў завжды трэба выкананы паралельна.

    Але гэта не завжды так.

    Возьмімо такій прыклад:

    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 і пул рабочых задач для обробкі асінхронных операцый I/O, а таксама з распашчастыміі проблемамі пулу задач і саветамі ўсунення яных.
  • Выбір межа EC2, ECS і EKS для задач на Node.js — Порівнюе, як EC2, ECS з Fargate і EKS керуюць роботай прыкладанняў на Node.js з пункту оператывання, чым дапамагае выбраць наяўнейшую службу обчыслення AWS для масштаба і навыкаў вашай команды.
  • Спаўненне логаў між асінхроннымі вызывамі за дапамогою AsyncLocalStorage — Дзеянне, як Node.js AsyncLocalStorage фіксуе контэкст кожнага запытку, такі як requestId, за межами await, не трэбаючы ручна праходзіць яго чераз кожную функцыю.