- Опубліковано
- 10 хв читання
Як Node.js обробляє багато запитів одним потоком
- Автори
- Ім’я
- Learna
Задача в числах
Уявімо один endpoint, який зберігає користувача в базі: перевіряє вхідні дані, записує рядок і повертає відповідь. Від початку до кінця запит займає 100 мс.
Із цих 100 мс виклик бази займає 90, а решта 10 припадає на JavaScript.
Отже, 90 мс із кожних 100 процесор не виконує роботу цього запиту: він очікує відповідь іншої машини. Якби Node.js теж просто зупинявся й чекав, сервер обробляв би 10 запитів на секунду, 90% часу простоюючи.
Але він не зупиняється. Один потік виконує JavaScript, і той самий сервер може одночасно обслуговувати сотні таких запитів. Ця стаття — про те, що відбувається протягом тих 90 мс.
Про цикл подій і його фази я вже писав у попередній статті. Там розгляд починається, коли колбек уже стоїть у черзі. Тут — усе, що відбувається до цього.
Node.js — це більше, ніж JavaScript
Коли Node.js називають однопотоковим, мають на увазі конкретну річ: ваш JavaScript у звичайному сценарії виконується в одному основному потоці. Два його фрагменти не виконуються в цьому потоці водночас.
Але Node.js — не лише V8, який виконує JavaScript. Значна його частина написана на C і C++. Нас тут цікавить libuv — бібліотека C для I/O: читання файлів, роботи із сокетами, пошуку адрес за іменами. libuv має потоки та взаємодіє з операційною системою, у якої є власні механізми й потоки.
Тож точна відповідь на запитання «Node.js однопотоковий?» така: типовий JavaScript-код — так, увесь процес — ні.
Асинхронність не означає паралельність
Це ключова відмінність, від якої залежить усе подальше пояснення.
Асинхронність сама собою не означає, що операцію просто зараз паралельно виконує хтось інший. Вона означає, що результат буде доступний не зараз, а пізніше.
Запис await db.save(user) не обіцяє появи другого робочого потоку. Він означає, що виконання функції призупиниться й продовжиться, коли з’явиться результат. Чи працює хтось під час цієї паузи — окреме запитання з двома різними відповідями.
Два види очікування
Деякі операції потребують потоку, який виконає блокувальний виклик. Для інших такого окремого потоку не потрібно.
blocking non-blocking
──────── ────────────
libuv takes a thread from libuv asks the operating system
its pool and that thread to watch the socket, then walks
sits there until the work away. Nobody waits. The OS taps
is finished. Node.js on the shoulder later.
costs one thread costs no thread
pool has 4 by default limited only by memory
У типовому шляху файлового I/O libuv читання виконує потік пулу, який може блокуватися в системному виклику. Очікування TCP-сокета організоване інакше: система вміє стежити за тисячами сокетів і повідомляти, які з них готові. Це опис звичайного шляху libuv, а не твердження, що асинхронних дискових API взагалі не існує.
Значна частина очікування вебсервера належить до другого виду. Саме тому один потік JavaScript може обслуговувати багато з’єднань.
Три короткі програми допоможуть це побачити. Їх можна запустити самостійно. Числа нижче отримано на Apple M4 Pro з Node.js v24.16.0 та libuv 1.52.1. Ваші результати відрізнятимуться; важливі не абсолютні числа, а характер залежності.
Експеримент 1 — пул потоків справді існує
pbkdf2 — функція виведення ключа з пароля. Її асинхронний варіант виконує обчислювально інтенсивну роботу в пулі libuv і утримує його потік до завершення.
import { pbkdf2 } from 'node:crypto';
const start = Date.now();
for (const run of [1, 2]) {
pbkdf2('secret', 'salt', 1e7, 64, 'sha512', () => {
console.log(`run ${run} finished after ${(Date.now() - start) / 1000}s`);
});
}
UV_THREADPOOL_SIZE задає розмір пулу libuv. Запустіть файл спочатку з одним потоком, потім із двома.
$ UV_THREADPOOL_SIZE=1 node blocking.mjs
run 1 finished after 1.728s
run 2 finished after 3.452s
$ UV_THREADPOOL_SIZE=2 node blocking.mjs
run 1 finished after 1.794s
run 2 finished after 1.803s
З одним потоком обчислення йдуть послідовно, тому друге завершується приблизно вдвічі пізніше. З двома вони можуть виконуватися разом. Пул справді існує, і його місткість визначає, скільки такої роботи можна почати одночасно.
Експеримент 2 — мережеве очікування не залежить від пулу
Тепер той самий тип перевірки, але замість двох обчислень — два HTTP-запити.
const start = Date.now();
for (const run of [1, 2]) {
fetch('https://example.com').then(() => {
console.log(`fetch ${run} returned after ${(Date.now() - start) / 1000}s`);
});
}
$ UV_THREADPOOL_SIZE=1 node non-blocking.mjs
fetch 1 returned after 0.071s
fetch 2 returned after 0.072s
$ UV_THREADPOOL_SIZE=8 node non-blocking.mjs
fetch 1 returned after 0.075s
fetch 2 returned after 0.076s
Потоків у вісім разів більше, а результат майже той самий. Для кожного розміру пулу я виконав три запуски: більшість результатів була між 0,069 і 0,076 с, ще два — близько 0,13 с, причому обидва запити сповільнилися разом.
Збільшення пулу не прискорює саме мережеве очікування, оскільки йому не потрібен окремий потік пулу.
Експеримент 3 — що з чим конкурує
Перші два експерименти показують по одному випадку. Третій поєднує їх в одній програмі — і саме тут виникає несподіванка.
Типовий пул має чотири потоки. Займемо їх чотирма повільними обчисленнями, а одночасно почнемо три операції: читання невеликого файла, TCP-з’єднання за IP-адресою та fetch.
import { pbkdf2 } from 'node:crypto';
import { readFile } from 'node:fs';
import { connect } from 'node:net';
const busy = Number(process.argv[2] ?? 0);
const start = Date.now();
const since = () => `${(Date.now() - start) / 1000}s`;
// Fill every thread in the pool with slow CPU work.
for (let i = 0; i < busy; i += 1) {
pbkdf2('secret', 'salt', 1e7, 64, 'sha512', () => {});
}
readFile(new URL(import.meta.url), () =>
console.log(` file read done at ${since()}`),
);
connect({ host: '1.1.1.1', port: 443 }, function onConnect() {
console.log(` tcp connect done at ${since()}`);
this.destroy();
});
fetch('https://example.com').then(() =>
console.log(` fetch done at ${since()}`),
);
Коли пул вільний, усе завершується швидко.
$ node competition.mjs 0
file read done at 0.015s
tcp connect done at 0.027s
fetch done at 0.071s
Тепер заповнімо пул.
$ node competition.mjs 4
tcp connect done at 0.027s
file read done at 2.065s
fetch done at 2.115s
Придивіться до результату: у ньому три окремі висновки.
TCP-з’єднання завершилося за 0,027 с в обох запусках. Чотири зайняті потоки нічого не змінили: готовністю сокета керувала система, а не окремий потік пулу.
Читання файла зросло з 0,015 до 2,065 с. Воно чекало близько двох секунд, доки звільниться потік. Проблеми з диском не було — був зайнятий пул.
А fetch сповільнився з 0,071 до 2,115 с. Здавалося б, мережева операція мала поводитися як TCP-з’єднання. Сам обмін даними так і працює, але перед відкриттям сокета потрібно перетворити example.com на IP-адресу. Типово Node.js робить це через dns.lookup, який використовує пул. Пошук адреси чекав у черзі, а з ним — увесь запит.
Це головний практичний висновок: важке обчислення в одній частині сервера може затримувати файлові й вихідні HTTP-операції в іншій. Сам stack trace не обов’язково підкаже причину.
Основні користувачі пулу
Пул libuv використовують, зокрема, такі асинхронні API Node.js:
- Більшість асинхронних файлових операцій
fs— на відміну відSync-варіантів, які блокують викликаючий потік. Спостереження за файлами має окремі механізми. dns.lookup, який типово використовують HTTP-клієнти й багато драйверів баз для пошуку адрес за іменами.- Асинхронні
crypto.pbkdf2,scrypt,randomBytes,randomFill,generateKeyPair. - Асинхронні операції
zlib.
Варто пам’ятати про dns.resolve: він робить DNS-запит через мережу, не використовуючи цей пул. dns.lookup звертається до системного механізму пошуку імен; відповідний виклик може блокуватися, тому потрібен потік.
Інша робота може виконуватися в основному потоці, внутрішніх потоках середовища або механізмах системи. Не варто буквально зводити всі внутрішні API Node.js лише до чотирьох категорій; наведений список описує типові публічні користувачі пулу libuv.
Хто ж стежить за мережею?
Якщо за кожним сокетом не закріплено потік, хто стежить за готовністю? Ядро системи. Операційні системи вдосконалювали ці механізми десятиліттями; коротко розгляньмо їхню еволюцію.
Старий підхід — потік на кожне з’єднання
Історично простий сервер давав кожному з’єднанню власний потік. Надходило з’єднання — створювався потік, який читав і записував дані до від’єднання клієнта.
Це працює, але 20 000 з’єднань потребують 20 000 потоків. Кожен витрачає пам’ять і ресурси планувальника, навіть коли лише чекає. Масштабувати такий підхід дорого.
Ключове спостереження: для опису з’єднання, що очікує, потрібно небагато даних — ідентифікатор і запис про те, що зробити після події.
Що означає цей ідентифікатор
Ядро відстежує відкриті ресурси процесу: файли, сокети, пристрої. Дескриптор посилається на запис, через який система знаходить потрібний ресурс і його службові дані — тип, доступ, поточну позицію та інше.
Файловий дескриптор — ціле число, яке процес використовує для звернення до відкритого ресурсу. Якщо після відкриття файла повернулося 7, подальші операції використовуватимуть цей номер.
У Windows схожа ідея реалізована через handle. Механіка відрізняється, але мета та сама: дати програмі ідентифікатор системного ресурсу.
Тож замість потоку на кожне з’єднання сервер може зберігати ідентифікатори й запитувати ядро: які з цих ресурсів готові?
select — чому його стало замало
select став одним із перших поширених рішень. Програма передає набір дескрипторів, чекає готовності принаймні одного, а потім обходить набір, щоб знайти готові.
Обходити набір потрібно щоразу. Маючи 10 000 дескрипторів, можна перевірити їх усі заради трьох готових, а на наступній ітерації повторити роботу. Вартість залежить від розміру набору, а не лише від кількості подій. Також типові реалізації select мають обмеження розміру набору.
epoll, kqueue та IOCP
Наступний крок — один раз зареєструвати ресурси, що цікавлять програму, а потім отримувати від ядра лише готові події.
У Linux для цього є epoll: створення через epoll_create, реєстрація через epoll_ctl й очікування через epoll_wait. Постійно реєструвати весь набір заново не потрібно. Очікування повертає готові події, а параметр timeout дає циклу змогу прокинутися для наступного таймера.
У macOS та BSD є kqueue, у Windows — I/O completion ports. libuv використовує механізми платформи й приховує значну частину відмінностей. Важливо, що IOCP повідомляє про завершення операцій, а не просто про готовність у стилі epoll.
У спрощеній моделі цикл запитує систему про події, виконує колбеки й повторює це знову. Коли немає ресурсів чи роботи, що утримують процес живим, він може завершитися.
io_uring та історія його використання
io_uring — новіший механізм Linux з іншою моделлю. Процес додає запити в кільцеву чергу, спільну з ядром, а результати отримує через другу чергу. Одна черга — для подання роботи, інша — для завершень. Це не просто швидший спосіб спитати, який сокет готовий.
Тут важлива історія версій, яку легко пропустити в старих статтях.
Тому експерименту на власній машині варто довіряти більше, ніж загальному твердженню будь-якої статті, зокрема цієї. Запустіть програму й перевірте свою версію Node.js.
Повний шлях одного запиту
Тепер зберімо шлях від підключення клієнта до отримання ним байтів відповіді:
your JavaScript libuv operating system
─────────────── ───── ────────────────
app.post('/users')
│
│ db.save(user)
├──────────────────────▶ is this call
│ blocking?
│ │
│ no ───────┴─────── yes
│ │ │
│ ▼ ▼
│ register the take a thread
│ socket with ◀── from the pool
│ epoll / kqueue and block on it
│ │ │
▼ │ │
returns straight away │ │
main thread is free to │ │
run the next request │ │
▼ ▼
the kernel says the thread finishes
the socket is and reports back
ready to the loop
│ │
└────────┬─────────┘
▼
the callback joins a queue
│
▼
the event loop runs it on
the main thread, in its turn
Черги колбеків і порядок їх обробки розглянуто у статті про цикл подій.
Два одночасні запити
Тепер відповідь на початкове запитання проста — отже, модель стала зрозумілою.
Надходять два запити. Ядро приймає обидва з’єднання й повідомляє про них циклу подій. Перший обробник виконується в основному потоці, доходить до db.save(user), починає мережеву операцію й призупиняється. Основний потік вільний, тому виконується другий обробник. Він робить те саме.
Обидва запити тепер очікують, але кожен із них не утримує власний потік JavaScript. Відповідь бази, яка надійде першою, дасть змогу раніше запланувати продовження відповідного обробника.
Node.js не виконував ці два JavaScript-обробники водночас. Йому й не потрібно було: важливо, що потік не витрачав час на блокувальне очікування.
Як застосувати ці знання
Практичне правило коротке: не блокуйте основний потік.
Кожен readFileSync, розбір 40 МБ JSON через JSON.parse або довгий цикл займає основний потік. Поки він працює, решта запитів у цьому потоці не отримує своєї черги. Ті 10 мс обчислень із початкового прикладу — це витрата CPU на кожен запит. Якщо перетворити її на 500 мс, затримку відчують й інші користувачі.
Для важких обчислень worker_threads дає окремі потоки JavaScript. Це стабільний API, а не експериментальна новинка. Про нього є окрема стаття. Workers потрібні для обчислень; саме мережеве очікування вони зазвичай не прискорюють.
Якщо під навантаженням раптово сповільнилися читання файлів або вихідні запити, перевірте зайнятість пулу. UV_THREADPOOL_SIZE можна збільшувати до 1024, але більший пул часто лише маскує важку операцію, яку варто організувати інакше.
Що почитати далі
Node.js event loop architecture Андраніка Кешишяна добре пояснює проблему «потік на з’єднання» й потребу в epoll. Стаття 2019 року, тому згадки про новизну worker_threads та старі адреси документації слід сприймати історично. Поточний посібник Node.js доступний за новою адресою.
Із Inside Node.js: Exploring Asynchronous I/O автора ocodista походять перші два експерименти. Там добре пояснено дескриптори й перехід від select до epoll. Матеріал опубліковано в грудні 2023 року: його висновок про використання io_uring від Node.js 20.3.0 потрібно читати з урахуванням пізніших змін, описаних вище.
Офіційний посібник Don't block the event loop стисло пояснює головне правило. А документація пулу libuv містить офіційні відомості про його використання.