Пошук уроків, статей та іншого контенту
Навчіться розрізняти типи навантаження та обирати відповідну модель виконання задач.
Під час виконання програми різні задачі можуть витрачати час переважно на:
CPU-bound — обчислення, які активно використовують процесор;
I/O-bound — очікування введення та виведення: файлів, мережі, бази даних або інших зовнішніх ресурсів.
Цей поділ важливий, тому що для кожного типу задачі підходить своя модель виконання.
I/O-bound задача більшу частину часу очікує завершення операції. Наприклад:
читання файлу;
HTTP-запит;
запит до бази даних;
отримання даних із зовнішнього сервісу.
Поки операційна система або зовнішній сервіс виконує операцію, JavaScript-код Node.js може обробляти інші події.
const { readFile } = require('node:fs/promises');
async function readFiles() {
console.log('Початок читання');
const files = await Promise.all([
readFile(__filename, 'utf8'),
readFile(__filename, 'utf8'),
]);
console.log(`Прочитано файлів: ${files.length}`);
console.log('Читання завершено');
}
readFiles().catch((error) => {
console.error('Помилка читання:', error.message);
});У цьому прикладі Node.js не зупиняє JavaScript-потік на час читання файлів. Виклики readFile запускають операції введення-виведення, а після їх завершення виконання продовжується після await.
Promise.all дає змогу очікувати кілька незалежних I/O-операцій одночасно.
async і await не роблять будь-який код неблокувальним. Вони зручні для роботи з асинхронними операціями, але звичайні обчислення всередині JavaScript усе одно виконуються в основному потоці.
CPU-bound задача більшу частину часу витрачає на обчислення. Приклади:
складні математичні розрахунки;
обробка великих масивів даних;
кодування або декодування;
хешування;
обробка зображень;
стиснення даних.
Наприклад, такий цикл повністю займає основний потік:
function calculate() {
let result = 0;
for (let i = 0; i < 100_000_000; i++) {
result += i;
}
return result;
}
console.log('Початок обчислення');
console.log(calculate());
console.log('Обчислення завершено');Поки виконується calculate, Node.js не може обробляти інші JavaScript-колбеки в цьому потоці.
Це означає, що можуть затриматися:
HTTP-запити;
таймери;
обробники подій;
відповіді користувачам;
завершення вже готових I/O-операцій.
Node.js виконує JavaScript-код в одному основному потоці. Цей потік керує event loop — циклом подій, який запускає готові колбеки та продовження асинхронних операцій.
Розглянемо приклад:
const { performance } = require('node:perf_hooks');
const startedAt = performance.now();
setTimeout(() => {
const elapsed = Math.round(performance.now() - startedAt);
console.log(`Таймер спрацював через приблизно ${elapsed} мс`);
}, 0);
let result = 0;
// Імітація CPU-bound задачі
for (let i = 0; i < 100_000_000; i++) {
result += i;
}
console.log('Обчислення завершено:', result);Попри те, що таймер має затримку 0, його колбек не виконається негайно. Спочатку Node.js завершить синхронний цикл, а вже потім повернеться до event loop і виконає колбек таймера.
Тому значення часу буде більшим за нуль. На різних комп’ютерах воно відрізнятиметься.
Для асинхронних I/O-операцій Node.js передає роботу операційній системі або внутрішнім механізмам Node.js. Коли операція готова, event loop отримує повідомлення про її завершення.
Але сам JavaScript-колбек, який буде виконано після завершення операції, все одно запускається в основному потоці. Якщо цей колбек містить довге обчислення, він також заблокує event loop.
Зазвичай використовують:
асинхронні API Node.js;
Promise;
async/await;
паралельне очікування незалежних операцій через Promise.all.
Приклад вибору:
const response = await fetch('https://example.com/data');
const data = await response.json();Поки програма очікує відповідь мережі, event loop може обробляти інші події.
Коротке обчислення можна виконати безпосередньо в основному потоці, якщо воно не створює помітної затримки.
Важливо оцінювати не лише сам код, а й контекст:
чи виконується обчислення для кожного HTTP-запиту;
скільки користувачів можуть одночасно надіслати запит;
скільки часу займає одна операція;
чи допустима затримка інших запитів.
Навіть невелике обчислення може стати проблемою, якщо запускати його дуже часто.
Довгі обчислення краще винести з основного потоку. У Node.js для цього можна використовувати:
worker_threads для виконання JavaScript у worker-потоках;
окремі процеси або зовнішні сервіси для великих чи незалежних задач.
Ось простий приклад із worker_threads:
const {
Worker,
isMainThread,
parentPort,
} = require('node:worker_threads');
function calculate() {
let result = 0;
for (let i = 0; i < 100_000_000; i++) {
result += i;
}
return result;
}
if (isMainThread) {
console.log('Основний потік запустив worker');
const worker = new Worker(__filename);
worker.on('message', (result) => {
console.log('Результат обчислення:', result);
});
worker.on('error', (error) => {
console.error('Помилка worker:', error.message);
});
worker.on('exit', (code) => {
console.log(`Worker завершив роботу з кодом ${code}`);
});
setTimeout(() => {
console.log('Основний потік продовжує обробляти події');
}, 0);
} else {
// Worker виконує CPU-bound обчислення окремо від основного потоку
const result = calculate();
parentPort.postMessage(result);
}Збережіть код у файл worker-example.js і запустіть:
node worker-example.jsОсновний потік створює worker і продовжує обробляти власні події. Обчислення виконується в іншому потоці, а результат повертається через повідомлення.
Worker-потоки мають власні витрати на створення та обмін даними, тому їх не обов’язково використовувати для кожної маленької операції.
Характеристики:
програма багато часу очікує;
процесор не зайнятий безперервним обчисленням;
добре підходить асинхронне виконання;
можна обробляти багато операцій в одному основному потоці.
Приклад:
const data = await readFile('large-file.txt', 'utf8');Характеристики:
програма постійно виконує обчислення;
процесор активно використовується;
довга синхронна операція блокує event loop;
для довгих задач варто використовувати worker або окремий процес.
Приклад:
const result = calculateLargeValue();async автоматично робить код неблокувальнимТакий код все одно блокує основний потік:
async function run() {
let result = 0;
for (let i = 0; i < 100_000_000; i++) {
result += i;
}
return result;
}async змінює роботу з результатом функції, але не переносить обчислення в інший потік.
PromiseЦей підхід не вирішує проблему блокування:
const result = await Promise.resolve(calculate());Функція calculate() виконається до створення успішно завершеного Promise. Обчислення все одно відбудеться в основному потоці.
Синхронні методи на кшталт readFileSync блокують виконання JavaScript до завершення операції. Для серверного коду зазвичай використовують асинхронні аналоги.
Повільна операція може бути повільною через очікування мережі або через обчислення. Щоб вибрати правильну модель, потрібно з’ясувати, де саме витрачається час:
на очікування зовнішнього ресурсу — це, найімовірніше, I/O-bound;
на виконання JavaScript-інструкцій — це, найімовірніше, CPU-bound.
Визначте, що займає більшу частину часу: очікування чи обчислення.
Для I/O-bound задач використовуйте асинхронні API та async/await.
Не запускайте довгі синхронні обчислення в обробнику кожного запиту.
Для тривалих CPU-bound задач розгляньте worker_threads або окремий процес.
Враховуйте не тільки тривалість однієї операції, а й кількість одночасних операцій.
Перевіряйте поведінку програми вимірюваннями, а не лише припущеннями.
I/O-bound задачі переважно очікують файли, мережу, базу даних або інший зовнішній ресурс.
CPU-bound задачі переважно виконують обчислення та активно використовують процесор.
Асинхронний I/O дає змогу Node.js обробляти інші події під час очікування.
Довгі синхронні обчислення блокують event loop.
async/await не переносять CPU-bound код в інший потік.
Для тривалих CPU-bound задач можна використовувати worker_threads або окремі процеси.