Skip to main content

Синхронний та асинхронний код у Node.js

Синхронний код блокує event loop Node.js до завершення операції; асинхронний реєструє задачу в libuv і повертається миттєво, поки цикл обробляє інші запити.

Теорія

TL;DR

  • Уяви одного касира в магазині: sync змушує всіх чекати, поки один покупець розраховується з повним кошиком; async дозволяє касиру взяти замовлення на папірець і одразу обслужити наступного
  • Головна різниця: sync зупиняє весь event loop; async тримає його вільним для нових запитів
  • Node.js працює в одному потоці, тому заблокований loop означає нуль оброблених запитів у цей час
  • Правило: async для будь-чого пов'язаного з файлами, мережею або базою даних; sync прийнятний лише для коротких операцій у пам'яті або скриптів

Швидкий приклад

js
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/awaittry/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-обробника

js
// Неправильно: блокує кожен запит на час читання 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 у циклі

js
// Неправильно: файли читаються по одному, час = сума всіх читань 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. Забутий аргумент помилки в колбеку

js
// Неправильно: при 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

js
// Неправильно: піраміда смерті, помилки важко відловити 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

js
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 читання без блокування

js
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

js
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 запускає всі три читання одночасно і чекає на найповільніше. Загальний час приблизно рівний часу одного найдовшого читання, а не їхній сумі.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?