Почему Node однопоточный, но неблокирующий
Вопрос звучит как противоречие: «Node однопоточный — как он тогда держит десять тысяч соединений?» Разбираем, что здесь однопоточное, а что нет.
В одном потоке выполняется ваш JavaScript. Ожидание ввода-вывода в этом потоке не происходит: оно делегируется операционной системе и thread pool libuv, а результат возвращается в цикл в виде колбэка.
Что проверяет интервьюер
Понимаете ли вы, где проходит граница. Кандидат-джун обычно отвечает «Node асинхронный, поэтому быстрый» — и не может объяснить, почему хеширование пароля через bcrypt.hashSync в обработчике логина роняет производительность всего сервиса. Здесь же обычно проверяют знание про разницу между I/O-bound и CPU-bound нагрузкой.
Развёрнутый ответ
Классический сервер на потоках выделяет по потоку на соединение. Тысяча одновременных клиентов — тысяча потоков, каждый со своим стеком в несколько мегабайт, плюс постоянные переключения контекста. При этом почти всё время эти потоки просто спят, ожидая ответа от диска или базы данных.
Node решает задачу иначе. Он говорит ядру: «сообщи, когда на этих сокетах появятся данные», — и уходит заниматься другой работой. Механизмы у ОС свои: epoll в Linux, kqueue в macOS, IOCP в Windows. Когда данные пришли, libuv кладёт соответствующий колбэк в очередь фазы poll, и цикл выполнит его, когда освободится.
Отсюда честная формулировка: Node неблокирующий именно на вводе-выводе. Ожидание сети, диска и базы стоит почти ничего. Но любые вычисления выполняются в том же единственном потоке, и на это время сервер глух ко всему остальному.
Что значит «заблокировать цикл» — на живом примере
Ниже полностью синхронный код: он честно считает простые числа и никуда ничего не делегирует. Запустите его и обратите внимание, что до окончания подсчёта не произойдёт вообще ничего.
function isPrime(n) {
if (n < 2) return false;
for (let i = 2; i * i <= n; i++) {
if (n % i === 0) return false;
}
return true;
}
let count = 0;
for (let n = 2; n < 200000; n++) {
if (isPrime(n)) count++;
}
console.log('простых чисел до 200000:', count);
console.log('всё это время event loop стоял бы на месте');
Вывод:
простых чисел до 200000: 17984
всё это время event loop стоял бы на месте
В Node такой цикл внутри обработчика означает: пока он крутится, ни один другой клиент не получит ответ. Ровно то же самое делают fs.readFileSync по большому файлу, синхронное шифрование, разбор многомегабайтного JSON, генерация PDF и сборка отчёта в памяти.
Что делать с CPU-задачами
- Worker threads — вычисления в отдельном потоке того же процесса. Подходит для «посчитать здесь и сейчас, но не в главном потоке».
- Очередь задач (BullMQ, RabbitMQ) и отдельный воркер-процесс — правильный вариант для тяжёлых фоновых операций: обработки видео, отчётов, рассылок.
- Асинхронные версии API вместо синхронных:
crypto.pbkdf2,bcrypt.hash,fs.promises.readFile— они уходят в thread pool libuv. - Нарезка на порции через
setImmediate, если задачу можно разбить: цикл получает возможность обслужить I/O между кусками.
// вместо одного длинного цикла — порции по 1000 элементов
function processInChunks(items, handler, done) {
let i = 0;
function chunk() {
const end = Math.min(i + 1000, items.length);
for (; i < end; i++) handler(items[i]);
if (i < items.length) setImmediate(chunk); // отдаём управление циклу
else done();
}
chunk();
}
Сколько потоков на самом деле
Полезно уточнить: «однопоточный» относится к исполнению JavaScript. В процессе Node потоков заметно больше — thread pool libuv (по умолчанию четыре), потоки V8 для сборки мусора и оптимизирующей компиляции, плюс те, что вы создадите через worker_threads. Но пользовательский JS-код в любой момент времени выполняет ровно один поток.
Типичные ошибки кандидатов
- «Node многопоточный, потому что есть thread pool» — путают исполнение JS и обслуживание I/O.
- Считают, что
async/awaitсам по себе делает код параллельным.awaitвокруг синхронного вычисления ничего не ускоряет и цикл не освобождает. - Не могут назвать ни одного примера блокирующей операции.
- Отвечают догмой «Node не годится для CPU-задач» и не знают про worker threads и очереди.
Как ответить кратко
В одном потоке выполняется JavaScript, а ожидание ввода-вывода отдаётся операционной системе (epoll/kqueue) и thread pool libuv. Поэтому тысячи соединений, которые в основном чего-то ждут, стоят дёшево: поток не спит на каждом из них, а обрабатывает готовые события. Плата за это — любые вычисления идут в том же потоке и блокируют всех клиентов сразу, поэтому CPU-задачи выносят в worker threads или в отдельную очередь.