Почему 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 или в отдельную очередь.

Проверьте себя
1. Что именно в Node.js выполняется в одном потоке?
AВообще всё, включая чтение файлов и сборку мусора
BПользовательский JavaScript-код
CТолько обработка HTTP-запросов
DТолько колбэки таймеров
2. Почему Node хорошо держит тысячи одновременных соединений?
AПотому что на каждое соединение создаётся лёгкий поток
BПотому что ожидание I/O делегируется ядру ОС, и поток обрабатывает только готовые события
CПотому что V8 автоматически распараллеливает колбэки по ядрам
DПотому что соединения обрабатываются пакетами по 64 штуки
3. Какой способ НЕ поможет справиться с тяжёлой CPU-задачей в Node?
AВынести её в worker_threads
BОтправить в очередь задач и обработать отдельным воркером
CОбернуть синхронную функцию в async и вызвать через await
DРазбить работу на порции и отдавать управление через setImmediate