Синхронний та асинхронний код у Node.js
Синхронний код блокує event loop Node.js до завершення операції; асинхронний реєструє задачу в libuv і повертається миттєво, поки цикл обробляє інші запити.
Теорія
TL;DR
- Уяви одного касира в магазині: sync змушує всіх чекати, поки один покупець розраховується з повним кошиком; async дозволяє касиру взяти замовлення на папірець і одразу обслужити наступного
- Головна різниця: sync зупиняє весь event loop; async тримає його вільним для нових запитів
- Node.js працює в одному потоці, тому заблокований loop означає нуль оброблених запитів у цей час
- Правило: async для будь-чого пов'язаного з файлами, мережею або базою даних; sync прийнятний лише для коротких операцій у пам'яті або скриптів
Швидкий приклад
const fs = require('fs');
// Sync: loop заморожений приблизно на 100ms
console.log('Start');
const data = fs.readFileSync('./file.txt', 'utf8'); // Все зупинено тут
console.log(data);
console.log('End');
// Виведе: Start -> вміст файлу -> End
// Async: loop залишається вільним
console.log('Start');
fs.readFile('./file.txt', 'utf8', (err, data) => {
if (err) return console.error(err);
console.log(data); // Виконається пізніше, коли ОС завершить читання
});
console.log('End');
// Виведе: Start -> End -> вміст файлуСинхронна версія заморожує все, поки Node чекає на диск. Асинхронна передає роботу libuv, повертається миттєво, і колбек запускається щойно файл готовий.
Ключова різниця
Node.js має один головний потік. Коли виконується синхронний I/O, цей потік просто чекає на диск або мережу, нічого більше не обробляючи. Асинхронний I/O делегує очікування пулу потоків libuv (за замовчуванням 4 потоки), тому головний потік продовжує отримувати нові задачі з event loop. Колбек потрапляє до черги задач щойно ОС сигналізує про завершення, і loop запускає його після того як call stack порожній.
Коли що використовувати
- CLI-скрипт з одним файлом: sync нормально, більше нічого не чекає
- API-сервер під навантаженням: тільки async; синхронне читання на 500ms блокує всі паралельні запити
- Парсинг JSON вже в пам'яті: sync нормально, це чисті CPU-обчислення без I/O
- Запит до бази даних або HTTP-дзвінок: async, мережевий round trip займає 50-200ms
- Одноразовий тестовий або дебаг-скрипт: sync спрощує читання коду
Порівняння async-патернів
| Патерн | Обробка помилок | Читабельність | Коли використовувати |
|---|---|---|---|
| Callbacks | Перший аргумент err | Вкладені, важко читати | Legacy-код, старіші Node API |
| Promises | .catch() | Ланцюгові виклики | Послідовні async-потоки |
| async/await | try/catch | Лінійний, найпростіший | Більшість продакшн-коду сьогодні |
Всі три патерни роблять одне й те саме під капотом. Різниця лише в синтаксисі колбеку, що викликається після завершення роботи libuv.
Як Node обробляє async всередині
V8 виконує JavaScript у call stack головного потоку. Коли ти викликаєш fs.readFile, Node передає запит libuv, а той передає його ОС або власному пулу потоків. Коли файл готовий, libuv додає колбек до фази poll в event loop. Після того як call stack порожніє, event loop підхоплює колбек і запускає твій код.
Promises та async/await використовують microtask queue, яка спустошується перед наступним тіком event loop. Саме тому await відчувається як синхронний код, хоча насправді виконання призупиняється і відновлюється непомітно.
Типові помилки
1. Синхронний I/O всередині HTTP-обробника
// Неправильно: блокує кожен запит на час читання
app.get('/data', (req, res) => {
const content = fs.readFileSync('./bigfile.json'); // Сервер завис
res.send(content);
});
// Правильно: loop залишається вільним
app.get('/data', async (req, res) => {
const content = await fs.promises.readFile('./bigfile.json');
res.send(content);
});Цей патерн найчастіше зустрічається з конфіг-файлами, які хтось вирішив читати прямо в обробнику. У скрипті це безпечно, на сервері під трафіком приносить сервер. Виправлення займає один рядок.
2. Послідовні await у циклі
// Неправильно: файли читаються по одному, час = сума всіх читань
for (const file of files) {
const data = await fs.promises.readFile(file); // Серіалізовано
}
// Правильно: всі читання стартують одночасно
const results = await Promise.all(
files.map(f => fs.promises.readFile(f, 'utf8'))
);await всередині for серіалізує операції. 10 файлів по 100ms кожен означає 1000ms замість приблизно 100ms.
3. Забутий аргумент помилки в колбеку
// Неправильно: при ENOENT крашиться, бо `data` насправді є об'єктом помилки
fs.readFile('./file.txt', (data) => console.log(data));
// Правильно
fs.readFile('./file.txt', 'utf8', (err, data) => {
if (err) return console.error(err);
console.log(data);
});Конвенція колбеків у Node завжди (err, data). Якщо пропустити err, ти обробляєш об'єкт помилки як вміст файлу, і процес падає на наступній операції.
4. Callback hell
// Неправильно: піраміда смерті, помилки важко відловити
fs.readFile(file1, (err1, data1) => {
fs.readFile(file2, (err2, data2) => {
fs.readFile(file3, (err3, data3) => {
// ...
});
});
});
// Правильно
const [data1, data2, data3] = await Promise.all([
fs.promises.readFile(file1, 'utf8'),
fs.promises.readFile(file2, 'utf8'),
fs.promises.readFile(file3, 'utf8'),
]);Де зустрічається в реальному коді
- Express.js: обробники маршрутів використовують
async/awaitдля запитів до бази (await User.findById(id)) і читання файлів - NestJS: guards та interceptors асинхронні за замовчуванням для перевірки авторизації
- Fastify: плагіни підключаються до async-lifecycle з
fastify.get('/', async (req, reply) => {...}) - Webpack (Node-інструменти збірки): лоадери компілюють JS і CSS асинхронно, не блокуючи бандлер
- Будь-який продакшн-сервер:
fs.promises,axiosта більшість ORM повертають Promises за замовчуванням
Питання до теми
Q: Чому однопотоковий Node.js справляється з тисячами одночасних запитів?
A: Event loop залишається неблокованим, бо I/O очікування обробляє пул потоків libuv та ОС. Сам JavaScript ніколи не блокується, тому loop постійно обробляє нові події.
Q: Яка різниця між callbacks, Promises та async/await?
A: Callbacks - найстаріший патерн, утворюють глибоку вкладеність при складних потоках. Promises дозволяють .then()-ланцюжки і кращу передачу помилок. Async/await - синтаксичний цукор над Promises, що читається як синхронний код. Всі три компілюються в один механізм.
Q: Що відбувається при CPU-інтенсивних задачах?
A: Event loop все одно блокується, бо CPU-робота виконується в головному потоці незалежно від async-синтаксису. Рішення - модуль worker_threads (доступний з Node 10) або cluster для кількох процесів.
Q: Чим відрізняються process.nextTick і setImmediate?
A: process.nextTick спрацьовує до наступної фази event loop, раніше за I/O колбеки. setImmediate спрацьовує у фазі check, після I/O. Рекурсивне використання process.nextTick може заблокувати I/O колбеки і затримати читання файлів.
Q: Як налаштувати розмір пулу потоків libuv?
A: Встановлюй змінну оточення UV_THREADPOOL_SIZE перед стартом Node. За замовчуванням 4 потоки, максимум 1024. Важкі crypto або database навантаження часто потребують більшого пулу, наприклад UV_THREADPOOL_SIZE=16 node server.js.
Приклади
Порядок виведення: sync проти async
const fs = require('fs');
const fsPromises = require('fs').promises;
// --- Sync ---
console.log('A');
const raw = fs.readFileSync('./file.txt', 'utf8');
console.log('B', raw.slice(0, 10));
console.log('C');
// Виведе: A -> B (вміст) -> C
// Loop був заблокований між A і B.
// --- Async з async/await ---
async function run() {
console.log('A');
const content = await fsPromises.readFile('./file.txt', 'utf8');
console.log('B', content.slice(0, 10));
console.log('C');
}
run();
// Всередині функції: A -> B (вміст) -> C
// Але між A і await інший код міг виконуватись у event loop.Порядок всередині async-функції виглядає однаково, але event loop був вільний для інших задач під час await. В цьому і є вся суть.
Express-маршрут: async читання без блокування
const express = require('express');
const fs = require('fs').promises;
const app = express();
// Поки файл читається, сервер приймає інші запити
app.get('/profile/:user', async (req, res) => {
try {
const raw = await fs.readFile(
`./profiles/${req.params.user}.json`,
'utf8'
);
res.json(JSON.parse(raw));
} catch (err) {
res.status(404).json({ error: 'Профіль не знайдено' });
}
});
app.listen(3000);Два запити надходять одночасно. Обидва викликають await fs.readFile. Обидва повертають управління event loop, поки ОС читає файли. Жоден не блокує інший, і обидва відповідають щойно їхній файл готовий.
Паралельне читання конфігів з Promise.all
const fs = require('fs').promises;
async function loadConfig() {
// Послідовно: ~300ms для 3 файлів по ~100ms кожен
// const db = await fs.readFile('./db.json', 'utf8');
// const cache = await fs.readFile('./cache.json', 'utf8');
// const app = await fs.readFile('./app.json', 'utf8');
// Паралельно: ~100ms загалом, всі три читання стартують одразу
const [db, cache, app] = await Promise.all([
fs.readFile('./db.json', 'utf8'),
fs.readFile('./cache.json', 'utf8'),
fs.readFile('./app.json', 'utf8'),
]);
return {
db: JSON.parse(db),
cache: JSON.parse(cache),
app: JSON.parse(app),
};
}Promise.all запускає всі три читання одночасно і чекає на найповільніше. Загальний час приблизно рівний часу одного найдовшого читання, а не їхній сумі.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.