Пошук уроків, статей та іншого контенту
Оцінимо concurrency, межі паралелізму, навантаження на ресурси та способи обмеження кількості одночасних задач.
Concurrency — це здатність системи працювати над кількома задачами в одному часовому проміжку, перемикаючись між ними або очікуючи завершення зовнішніх операцій.
У Node.js concurrency найчастіше проявляється під час роботи з:
HTTP-запитами;
базами даних;
файловою системою;
мережевими сокетами;
таймерами;
іншими асинхронними API.
Node.js виконує JavaScript-код в одному основному потоці, але не очікує завершення кожної операції послідовно:
const result = await fetch(url);Поки мережевий запит очікує відповідь, event loop може обробляти інші задачі.
Ці поняття не є синонімами:
Concurrency — кілька задач перебувають у процесі виконання в один момент часу.
Parallelism — кілька задач фактично виконуються одночасно на різних ядрах процесора або потоках.
Наприклад, Node.js може одночасно очікувати відповіді від сотень HTTP-запитів, хоча JavaScript-код виконується в одному потоці.
Для CPU-intensive задач один потік JavaScript не забезпечує справжнього паралельного виконання. Така задача може блокувати event loop, навіть якщо в програмі є інші асинхронні операції.
Найпростіший спосіб запустити багато задач одночасно — передати їх у Promise.all:
const results = await Promise.all(
urls.map((url) => fetch(url))
);Це може бути коректним для невеликої кількості URL. Проте якщо urls містить десятки тисяч елементів, програма спробує почати всі операції майже одразу.
Сам Promise.all не встановлює обмеження concurrency. Він лише:
отримує масив промісів;
очікує їх завершення;
повертає результати в тому самому порядку.
Необмежений запуск може призвести до:
великого споживання пам’яті;
перевищення кількості файлових дескрипторів;
вичерпання з’єднань у пулі бази даних;
перевищення лімітів зовнішнього API;
черги запитів на стороні сервера;
збільшення затримки;
помилок 429 Too Many Requests;
тайм-аутів;
перевантаження CPU або event loop.
Важливо: асинхронність не означає, що ресурсів достатньо для необмеженої кількості операцій.
Promise.all і фактична кількість одночасних задачРозглянемо приклад:
const tasks = Array.from({ length: 10000 }, (_, index) => {
return processItem(index);
});
await Promise.all(tasks);Якщо processItem одразу починає мережевий запит або запит до бази даних, потенційно всі 10 000 операцій можуть стати активними одночасно.
Навіть якщо сервер або база даних мають власне обмеження, це не вирішує проблему повністю. На стороні Node.js уже можуть бути створені:
проміси;
замикання;
об’єкти запитів;
буфери;
таймери;
записи в локальних чергах.
Крім того, зовнішній сервіс може почати повертати помилки замість корисної роботи.
Інша крайність — виконувати всі задачі послідовно:
const results = [];
for (const item of items) {
results.push(await processItem(item));
}Тут concurrency дорівнює 1.
Перевага такого підходу — мінімальне навантаження на ресурси. Недолік — низька пропускна здатність. Якщо кожна операція переважно очікує мережу, процесор у цей час може простоювати.
Зазвичай потрібен компроміс:
не запускати всі задачі одночасно;
не обмежуватися однією задачею;
підтримувати фіксовану кількість активних операцій.
Обмежувач concurrency зберігає чергу задач і запускає нову задачу лише тоді, коли одна з активних задач завершилася.
Нижче наведено реалізацію mapWithConcurrency. Вона:
приймає масив значень;
запускає не більше limit задач одночасно;
зберігає порядок результатів;
коректно обробляє помилки;
підтримує асинхронний callback.
'use strict';
function mapWithConcurrency(items, limit, mapper) {
if (!Number.isInteger(limit) || limit < 1) {
throw new RangeError('limit має бути додатним цілим числом');
}
return new Promise((resolve, reject) => {
if (items.length === 0) {
resolve([]);
return;
}
const results = new Array(items.length);
let nextIndex = 0;
let active = 0;
let completed = 0;
let rejected = false;
function startNext() {
if (rejected) {
return;
}
while (active < limit && nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
active += 1;
Promise.resolve()
.then(() => mapper(items[index], index))
.then((result) => {
results[index] = result;
active -= 1;
completed += 1;
if (completed === items.length) {
resolve(results);
return;
}
startNext();
})
.catch((error) => {
rejected = true;
reject(error);
});
}
}
startNext();
});
}
function wait(milliseconds) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
async function processItem(item, index) {
const duration = 100 + Math.floor(Math.random() * 400);
console.log(`Початок задачі ${index}: ${item}`);
await wait(duration);
console.log(`Завершення задачі ${index}: ${item}`);
return {
item,
duration
};
}
async function main() {
const items = Array.from({ length: 10 }, (_, index) => `item-${index}`);
const results = await mapWithConcurrency(
items,
3,
processItem
);
console.log('\nРезультати в початковому порядку:');
console.log(results);
}
main().catch((error) => {
console.error('Помилка:', error);
process.exitCode = 1;
});У прикладі одночасно працює не більше трьох задач, хоча загалом задач десять.
Порядок завершення може бути різним:
Завершення задачі 2
Завершення задачі 1
Завершення задачі 0Але масив results зберігає порядок початкового масиву, оскільки кожен результат записується за індексом задачі.
Універсального числа не існує. Обмеження залежить від ресурсу, який є вузьким місцем.
Для HTTP-запитів значення може бути від кількох одиниць до десятків. Вибір залежить від:
ліміту зовнішнього API;
пропускної здатності мережі;
часу відповіді;
дозволеної кількості з’єднань;
вимог до затримки.
Якщо API дозволяє не більше 10 запитів на секунду, просте обмеження кількості одночасних запитів не завжди достатнє. Наприклад, десять швидких запитів можуть завершитися майже миттєво, після чого програма запустить ще десять у тому самому часовому інтервалі.
У такій ситуації потрібен не лише concurrency limit, а й rate limit — обмеження кількості операцій за одиницю часу.
Якщо пул має 10 з’єднань, немає сенсу запускати сотні операцій, які одночасно потребують з’єднання. Частина запитів просто стоятиме в черзі пулу.
Обмеження на рівні застосунку може:
зменшити кількість зайвих об’єктів і промісів;
зробити навантаження передбачуванішим;
зменшити чергу всередині драйвера;
покращити поведінку під час пікових навантажень.
Однак ліміт застосунку не повинен автоматично вважатися рівним розміру пулу. Один тип задач може використовувати кілька ресурсів, а інший — лише частину часу утримувати з’єднання.
Для великої кількості файлових операцій concurrency обмежують через:
кількість файлових дескрипторів;
пропускну здатність диска;
доступну пам’ять;
обмеження операційної системи.
Занадто велика кількість паралельних операцій не обов’язково прискорює роботу з диском. Часто вона лише збільшує конкуренцію за той самий ресурс.
Якщо callback виконує тривалий синхронний код, збільшення concurrency не створює паралельності JavaScript-коду:
function expensiveCalculation() {
const end = Date.now() + 1000;
while (Date.now() < end) {
// Блокуюче обчислення
}
return 42;
}Запуск кількох таких функцій через Promise.all не дає корисного паралелізму:
await Promise.all([
expensiveCalculation(),
expensiveCalculation(),
expensiveCalculation()
]);Функції виконаються синхронно одна за одною і заблокують event loop. Для справжнього паралельного виконання CPU-intensive задач потрібні окремі механізми, наприклад worker threads або окремі процеси. Але навіть у такому випадку кількість одночасних задач потрібно обмежувати відповідно до кількості CPU-ресурсів.
У системі може бути кілька рівнів черг:
черга задач у вашому коді;
черга event loop;
внутрішня черга Node.js або libuv;
пул з’єднань драйвера;
черга операційної системи;
черга зовнішнього сервісу.
Якщо не обмежити concurrency на ранньому рівні, задачі можуть накопичуватися на наступних рівнях. Це ускладнює контроль і діагностику.
Наприклад, застосунок може створити 10 000 запитів до бази даних, а пул на 20 з’єднань виконуватиме їх поступово. Формально робота відбувається, але:
пам’ять уже використовується під усі 10 000 задач;
скасування задач стає складнішим;
час очікування кожної задачі збільшується;
помилки можуть накопичуватися;
навантаження стає менш передбачуваним.
Краще створювати або активувати задачі поступово, відповідно до доступної пропускної здатності.
Обмежувач concurrency керує кількістю активних задач. Але джерело даних може продовжувати генерувати нові елементи швидше, ніж система їх обробляє.
Наприклад:
споживач читає повідомлення повільніше, ніж їх надсилає брокер;
HTTP-клієнти надсилають запити швидше, ніж сервер їх обробляє;
генератор створює об’єкти швидше, ніж вони записуються на диск.
Backpressure — це спосіб передати джерелу сигнал, що споживач більше не встигає обробляти дані.
Для великих потоків даних важливо не лише обмежити кількість активних операцій, а й не зберігати необмежену чергу в пам’яті.
Практичні стратегії:
обмежувати розмір внутрішньої черги;
призупиняти читання, коли черга переповнена;
обробляти дані пакетами;
відхиляти нові задачі при досягненні ліміту;
чекати звільнення місця перед додаванням нового елемента.
Обмежувач concurrency повинен мати чітку політику помилок.
Можливі варіанти:
Після першої помилки нові задачі не запускаються, а загальна операція завершується помилкою.
Це підходить для транзакційних сценаріїв або пакетної операції, де частковий результат не має сенсу.
Важливо: вже запущені задачі можуть продовжити виконання. Відхилення загального промісу не скасовує автоматично мережеві запити чи інші операції.
Кожну задачу можна обгорнути так, щоб вона повертала об’єкт зі статусом:
const result = await Promise.all(
items.map(async (item) => {
try {
return {
item,
status: 'fulfilled',
value: await processItem(item)
};
} catch (error) {
return {
item,
status: 'rejected',
reason: error
};
}
})
);Для великої кількості задач цей підхід також потрібно поєднувати з обмеженням concurrency.
Повторні спроби доречні для тимчасових помилок:
мережевої нестабільності;
тимчасової недоступності сервісу;
окремих відповідей 5xx.
Не всі помилки можна повторювати. Наприклад, повторення некоректного запиту або помилки валідації не допоможе.
Повторні спроби збільшують навантаження, тому їх потрібно поєднувати з:
максимальною кількістю спроб;
затримкою між спробами;
експоненційною затримкою;
загальним timeout;
обмеженням concurrency.
Під час вибору ліміту потрібно вимірювати, а не покладатися лише на припущення.
Корисні метрики:
кількість активних задач;
довжина черги;
час очікування задачі до запуску;
тривалість виконання;
загальна пропускна здатність;
частота помилок;
кількість тайм-аутів;
використання пам’яті;
затримка event loop;
навантаження CPU;
кількість активних з’єднань до зовнішніх ресурсів.
Збільшення concurrency зазвичай:
підвищує пропускну здатність до певної межі;
після цієї межі збільшує затримку;
може підвищити кількість помилок;
може перевантажити зовнішній ресурс.
Типова залежність виглядає так:
concurrency 1 — ресурс використовується недостатньо;
помірне значення — пропускна здатність зростає;
надто велике значення — зростають черги, затримки та помилки.
Оптимальне значення потрібно визначати тестуванням у середовищі, близькому до production.
Promise.all для необмеженого масивуawait Promise.all(items.map(processItem));Це запускає всі задачі без обмеження. Для великих масивів використовуйте чергу або обмежувач concurrency.
await автоматично обмежує concurrencyТакий код послідовний:
for (const item of items) {
await processItem(item);
}Але такий код запускає всі задачі до очікування результату:
const promises = items.map(processItem);
await Promise.all(promises);Різниця визначається моментом запуску задачі, а не самим використанням await.
Кілька промісів не перетворюють синхронне обчислення на багатопотокове. CPU-intensive JavaScript може заблокувати event loop.
Ліміт 1000 не є кращим за ліміт 10 лише тому, що він допускає більше задач. Потрібно враховувати реальні обмеження бази даних, API, диска, мережі та CPU.
Якщо одна задача завершилася помилкою, інші задачі, які вже були запущені, зазвичай не зупиняються автоматично.
Навіть якщо активних задач лише п’ять, масив із мільйона елементів і черга з мільйона callback-функцій можуть зайняти багато пам’яті.
Обмеження кількості одночасних запитів не завжди контролює кількість запитів за секунду. Для цього потрібне окреме rate limiting.
Concurrency дозволяє обробляти кілька задач у межах одного часового проміжку.
Node.js ефективно працює з великою кількістю асинхронних I/O-операцій, але це не означає необмежену кількість задач.
Promise.all очікує багато промісів, але сам по собі не обмежує concurrency.
Необмежений запуск може перевантажити пам’ять, event loop, пули з’єднань, файлову систему та зовнішні API.
Практичний підхід — використовувати чергу з фіксованою кількістю активних задач.
Оптимальний ліміт залежить від вузького місця: мережі, бази даних, диска або CPU.
Для CPU-intensive задач concurrency в одному потоці не створює parallelism.
Обмеження concurrency потрібно відрізняти від rate limiting і backpressure.
Під час налаштування ліміту необхідно вимірювати пропускну здатність, затримки, помилки та використання ресурсів.