Пошук уроків, статей та іншого контенту
Дослідимо Event Loop, черги завдань і неблокувальні операції, що дають Node.js змогу працювати з багатьма запитами.
Node.js виконує JavaScript-код в одному основному потоці. Це означає, що одночасно виконується лише одна JavaScript-операція.
Попри це, Node.js може обробляти багато запитів одночасно. Причина — неблокувальні операції та Event Loop.
Event Loop — це механізм, який:
виконує синхронний JavaScript-код;
запускає асинхронні операції;
стежить за готовністю їхніх результатів;
додає callback-и до відповідних черг;
виконує ці callback-и, коли стек викликів звільняється.
Наприклад, під час читання файлу Node.js не зупиняє виконання всієї програми. Він передає операцію системі або внутрішньому пулу потоків, а потім продовжує виконувати інший код.
Спочатку Node.js виконує синхронний код у стеку викликів:
console.log("Перший");
function greet() {
console.log("Привіт");
}
greet();
console.log("Останній");Результат:
Перший
Привіт
ОстаннійКожна викликана функція додається до стеку. Коли функція завершується, вона видаляється зі стеку.
Поки стек зайнятий синхронним кодом, Event Loop не може виконати callback асинхронної операції.
const start = Date.now();
while (Date.now() - start < 3000) {
// Блокуємо основний потік приблизно на 3 секунди
}
console.log("Цей код виконається після блокування");Під час циклу Node.js не може обробляти інші JavaScript callback-и. Тому важкі синхронні обчислення можуть затримати всі запити в застосунку.
Неблокувальна операція запускається, але не змушує JavaScript чекати її завершення.
const fs = require("node:fs");
console.log("Початок");
fs.readFile("example.txt", "utf8", (error, content) => {
if (error) {
console.error("Помилка читання:", error.message);
return;
}
console.log("Вміст файлу:", content);
});
console.log("Кінець");Якщо файл існує, результат зазвичай буде таким:
Початок
Кінець
Вміст файлу: ...Виклик fs.readFile запускає операцію читання, але Node.js не чекає на її завершення. Він одразу переходить до console.log("Кінець").
Коли читання завершиться, callback буде запланований для виконання.
У спрощеному вигляді робота Node.js має такий вигляд:
Виконується весь доступний синхронний код.
Перевіряються спеціальні черги мікрозавдань.
Event Loop переходить між фазами.
У кожній фазі виконуються callback-и відповідного типу.
Після завершення callback-ів знову перевіряються мікрозавдання.
Цикл продовжується, поки є активні операції або заплановані callback-и.
Event Loop реалізований у Node.js за допомогою бібліотеки libuv.
У Node.js Event Loop має кілька фаз. Для практичної роботи найважливіші такі:
У цій фазі виконуються callback-и таймерів:
setTimeout;
setInterval.
Час таймера — це мінімальна затримка перед можливим виконанням callback-а, а не гарантія точного часу.
setTimeout(() => {
console.log("Таймер завершився");
}, 100);Якщо основний потік зайнятий, callback виконається пізніше за 100 мілісекунд.
Тут можуть виконуватися деякі системні callback-и, відкладені з попередніх операцій. У прикладному коді зазвичай не потрібно безпосередньо керувати цією фазою.
Фаза poll відповідає за:
отримання нових подій вводу-виводу;
виконання callback-ів завершених операцій вводу-виводу;
очікування нових операцій, якщо інших завдань немає.
Саме тут часто виконуються callback-и таких операцій, як читання файлу або робота із сокетами.
У фазі check виконуються callback-и, заплановані через setImmediate.
setImmediate(() => {
console.log("Виконання у фазі check");
});setImmediate особливо корисний, коли потрібно виконати код після завершення поточної операції вводу-виводу.
У цій фазі виконуються callback-и подій закриття, наприклад закриття мережевого з’єднання.
Для розуміння порядку виконання потрібно розрізняти кілька типів завдань.
Виконується одразу, поки стек викликів не порожній:
console.log("Синхронний код");setTimeout і setInterval додають callback-и до черги таймерів:
setTimeout(() => {
console.log("setTimeout");
}, 0);Нульова затримка не означає «виконати негайно». Callback буде виконаний лише після завершення поточного синхронного коду та за умови, що Event Loop дійде до відповідної фази.
setImmediatesetImmediate додає callback до фази check:
setImmediate(() => {
console.log("setImmediate");
});Порядок між setTimeout(..., 0) і setImmediate у головному модулі може відрізнятися залежно від версії Node.js та стану Event Loop. Усередині callback-а вводу-виводу setImmediate зазвичай виконується раніше за таймер:
const fs = require("node:fs");
fs.readFile(__filename, () => {
setTimeout(() => {
console.log("setTimeout");
}, 0);
setImmediate(() => {
console.log("setImmediate");
});
});Типовий результат:
setImmediate
setTimeoutНе варто будувати логіку програми на припущенні про порядок цих двох API поза конкретним контекстом.
Мікрозавдання мають вищий пріоритет, ніж перехід до наступної фази Event Loop.
До них належать:
callback-и process.nextTick;
обробники Promise, зокрема then, catch і finally;
queueMicrotask.
Приклад:
console.log("1");
setTimeout(() => {
console.log("5 — таймер");
}, 0);
Promise.resolve().then(() => {
console.log("4 — Promise");
});
process.nextTick(() => {
console.log("3 — nextTick");
});
console.log("2");Типовий результат:
1
2
3 — nextTick
4 — Promise
5 — таймерПісля виконання синхронного коду Node.js спочатку обробляє чергу process.nextTick, потім чергу Promise-мікрозавдань, і лише після цього переходить до таймерів.
process.nextTickprocess.nextTick планує callback для виконання після поточної операції, але до переходу Event Loop до наступної фази.
console.log("Початок");
process.nextTick(() => {
console.log("nextTick");
});
console.log("Кінець");Результат:
Початок
Кінець
nextTickconsole.log("Початок");
Promise.resolve().then(() => {
console.log("then");
});
console.log("Кінець");Результат:
Початок
Кінець
thenНадмірне використання process.nextTick або рекурсивне додавання таких callback-ів може не дати Event Loop перейти до операцій вводу-виводу:
function scheduleForever() {
process.nextTick(scheduleForever);
}
// Небезпечний код: може заблокувати інші фази Event Loop
// scheduleForever();Мікрозавдання мають високий пріоритет, але вони також виконуються синхронно одне за одним. Велика кількість мікрозавдань може затримати таймери та мережеві callback-и.
Наступний приклад можна запустити у Node.js:
const fs = require("node:fs");
console.log("1. Синхронний код");
process.nextTick(() => {
console.log("2. process.nextTick");
});
Promise.resolve().then(() => {
console.log("3. Promise");
});
setTimeout(() => {
console.log("4. setTimeout");
}, 0);
setImmediate(() => {
console.log("5. setImmediate");
});
fs.readFile(__filename, () => {
console.log("6. Callback читання файлу");
process.nextTick(() => {
console.log("7. nextTick після I/O");
});
setImmediate(() => {
console.log("8. setImmediate після I/O");
});
});
console.log("9. Кінець синхронного коду");Першими гарантовано будуть:
1. Синхронний код
9. Кінець синхронного коду
2. process.nextTick
3. PromiseПорядок деяких інших повідомлень залежить від того, коли операційна система завершить читання файлу та в якій фазі перебуватиме Event Loop. У callback-і fs.readFile nextTick буде оброблений перед setImmediate.
Головна ідея: асинхронний callback не перериває поточний синхронний код. Він виконається пізніше, коли стек буде вільним і Event Loop обробить відповідну чергу.
Не всі асинхронні операції виконуються безпосередньо основним потоком. Node.js використовує можливості операційної системи, а для частини операцій — внутрішній пул потоків libuv.
Це стосується, зокрема, деяких операцій:
файлової системи;
криптографії;
стиснення;
DNS-операцій.
Після завершення такої операції її callback повертається до Event Loop і виконується в основному потоці JavaScript.
Це не означає, що JavaScript виконується паралельно. Паралельно може виконуватися сама системна або фонова операція, але її callback усе одно буде виконаний основним потоком Node.js.
Розглянемо HTTP-сервер:
const http = require("node:http");
const server = http.createServer((request, response) => {
const start = Date.now();
while (Date.now() - start < 2000) {
// Імітуємо важке синхронне обчислення
}
response.end("Готово");
});
server.listen(3000, () => {
console.log("Сервер працює на http://localhost:3000");
});Якщо одночасно надійде кілька запитів:
перший запит почне блокуюче обчислення;
Event Loop не зможе обробити другий запит;
другий запит чекатиме завершення першого;
запити будуть оброблятися послідовно із затримкою.
Саме тому неблокувальна модель важлива для серверів із великою кількістю одночасних з’єднань.
Асинхронний ввід-вивід не робить важкі обчислення неблокувальними автоматично. Якщо callback містить довгий цикл або іншу важку синхронну операцію, Event Loop все одно буде заблокований.
const http = require("node:http");
const fs = require("node:fs/promises");
const server = http.createServer(async (request, response) => {
if (request.url !== "/") {
response.statusCode = 404;
response.end("Не знайдено");
return;
}
try {
const content = await fs.readFile(__filename, "utf8");
response.writeHead(200, {
"Content-Type": "text/plain; charset=utf-8",
});
response.end(`Розмір файлу: ${content.length} символів`);
} catch (error) {
response.statusCode = 500;
response.end("Не вдалося прочитати файл");
}
});
server.listen(3000, () => {
console.log("Відкрийте http://localhost:3000");
});await у цьому прикладі не блокує весь процес Node.js. Поки файл читається, Event Loop може обробляти інші події. Коли Promise завершиться, продовження функції буде заплановане як мікрозавдання.
Водночас синхронний виклик на кшталт fs.readFileSync зупинив би виконання JavaScript до завершення читання.
Node.js не виконує кілька callback-ів JavaScript одночасно в одному основному потоці. Асинхронність означає, що операція може завершитися пізніше, а Event Loop у цей час обробляє інші завдання.
setTimeout(fn, 0) негайним викликомНульова затримка — це мінімальний час очікування. Callback виконається пізніше, коли звільниться стек і Event Loop дійде до фази таймерів.
process.nextTick для довгих ланцюжківВелика кількість nextTick може відкласти обробку вводу-виводу, таймерів та інших подій.
Навіть якщо операція читання або мережевий запит асинхронні, довгий цикл у їхньому callback-і блокує Event Loop.
setTimeout і setImmediateПорядок їхнього виконання залежить від контексту. Якщо важливий певний порядок, його потрібно явно організувати через Promise, callback або інший контрольований механізм.
await повністю блокуючимawait призупиняє виконання поточної async-функції до завершення Promise, але не блокує весь Event Loop. Проте синхронний код усередині цієї функції все одно може його блокувати.
Event Loop дає Node.js змогу обробляти асинхронні операції в одному основному потоці JavaScript.
Синхронний код виконується негайно та блокує Event Loop, поки не завершиться.
Неблокувальні операції передають роботу операційній системі або внутрішнім механізмам libuv.
Callback асинхронної операції виконується пізніше через відповідну фазу Event Loop.
process.nextTick і Promise-обробники належать до мікрозавдань і мають високий пріоритет.
setTimeout працює через фазу таймерів, а setImmediate — через фазу check.
setTimeout(..., 0) не означає негайне виконання.
Довгі синхронні обчислення та нескінченні ланцюжки мікрозавдань блокують обробку інших запитів.
Неблокувальний ввід-вивід допомагає Node.js ефективно працювати з великою кількістю одночасних з’єднань.