Цикл событий, рабочие потоки и конкурентность в Node.js
Цикл событий Node.js, пул потоков libuv, worker threads и cluster: ясное объяснение конкуренции I/O и CPU-нагрузки.
Node.js выполняет JavaScript на одном потоке, управляемом циклом событий; конкурентность достигается за счёт делегирования ожидания — libuv передаёт блокирующий I/O фоновому пулу потоков, — а не за счёт параллельного выполнения JavaScript. Когда требуется запустить JavaScript параллельно для CPU-интенсивных задач, применяется отдельный механизм: рабочие потоки (worker threads), каждый из которых является полноценным V8-изолятом с собственным циклом событий. Эти три составляющие — единственный JS-поток, пул потоков libuv и рабочие потоки — постоянно смешиваются, и именно это непонимание порождает медленные эндпоинты и зависающие серверы.
Данная статья чётко разграничивает эти уровни. В ней объясняются фазы цикла событий и разница между микрозадачами и макрозадачами, что именно обслуживает пул потоков libuv (и что он не обслуживает), когда рабочие потоки эффективнее обычного async/await, чем отличается кластеризация, и как выбрать подходящий инструмент. Код написан для Node.js 24 (Active LTS) с учётом Node.js 26 в качестве текущей ветки; рабочие потоки являются стабильной, а не экспериментальной функциональностью.
Ключевые выводы
- Node выполняет JavaScript на одном потоке; цикл событий обеспечивает конкурентность, передавая блокирующий I/O фоновому пулу потоков libuv, а не запуская ваш код параллельно.
- Пул потоков libuv по умолчанию содержит 4 потока и может быть расширен до максимума в 1024 потока через
UV_THREADPOOL_SIZE; он обслуживает операции файловой системы,dns.lookup, криптографические операции и zlib — но не сетевые сокеты, которые напрямую используют epoll/kqueue/IOCP операционной системы. - Рабочий поток — это не «просто поток ОС»: каждый является отдельным V8-изолятом с собственным циклом событий и собственным циклом libuv, именно поэтому рабочие потоки не могут совместно использовать обычные объекты и должны обмениваться данными через передачу сообщений.
- Микрозадачи не являются фазой цикла событий: сначала выполняются коллбэки
process.nextTick, затем очередь микрозадач промисов, и только после этого цикл переходит к следующей макрозадаче. - Выбирайте инструмент по типу узкого места:
async/awaitдля I/O-интенсивных задач, рабочие потоки для CPU-интенсивного JavaScript, кластер — для масштабирования I/O-интенсивной нагрузки по ядрам.
Базовая модель: один JS-поток, цикл событий и libuv
Node.js выполняет JavaScript на одном потоке, и именно этот поток управляет циклом событий. Среда выполнения построена на движке V8 от Google для исполнения JavaScript и на libuv — C-библиотеке, предоставляющей цикл событий и асинхронный I/O. Секрет того, как один поток справляется с тысячами одновременных подключений, заключается в делегировании: когда ваш код вызывает блокирующую операцию, например чтение файла, Node не ждёт результата. Он регистрирует операцию в libuv, немедленно возвращает управление, а коллбэк выполняется позже, когда результат готов.
Конкурентность в Node — это делегирование ожидания. Пока выполняется чтение файла или DNS-запрос, единственный JS-поток свободен для обработки других коллбэков. Ничто в вашем JavaScript не выполняется параллельно — существует ровно один стек вызовов, — однако множество операций могут быть в процессе выполнения одновременно, поскольку медленные части происходят в другом месте.
Именно поэтому утверждение «Node однопоточный» является полуправдой, заслуживающей уточнения. Выполнение JavaScript однопоточно. Среда выполнения — нет: libuv поддерживает пул фоновых потоков, а операционная система управляет сетевыми сокетами от имени Node. Воспринимайте «однопоточность» как утверждение о том, где выполняется ваш код, а не о процессе в целом.
Распространённый миф: «Node однопоточный.» Ваш JavaScript выполняется на одном потоке; процесс Node использует несколько. Это различие и составляет суть данной статьи.
Конкурентность и параллелизм: точные определения
Конкурентность означает, что несколько задач продвигаются в одном временном промежутке, чередуясь на общем ресурсе; параллелизм означает, что несколько задач выполняются в один и тот же момент на разных ядрах. Однопроцессорная машина с Node является конкурентной, но не параллельной: цикл событий быстро переключается между выполняемыми операциями, однако в любой момент времени выполняется лишь один фрагмент JavaScript. Рабочие потоки и кластеризация добавляют настоящий параллелизм, вводя дополнительные контексты выполнения, которые ОС может планировать на разных ядрах.
Практическое следствие: конкурентность решает проблемы ожидания (I/O), параллелизм — проблемы вычислений (CPU). Использование неподходящего инструмента — корень большинства ошибок производительности в Node.
Шесть фаз цикла событий и разделение микрозадач
Discover how at OpenReplay.com.
Цикл событий выполняется в фиксированном цикле из шести фаз, и каждая фаза имеет собственную очередь коллбэков, которую полностью опустошает перед переходом к следующей. Согласно официальному руководству по циклу событий Node.js, фазы следуют в таком порядке:
- Timers (Таймеры) — выполняет коллбэки, запланированные через
setTimeout()иsetInterval(), чей порог времени истёк. - Pending callbacks (Ожидающие коллбэки) — выполняет определённые системные коллбэки, отложенные из предыдущего цикла.
- Idle, prepare — только для внутреннего использования.
- Poll (Опрос) — получает новые I/O-события и выполняет их коллбэки; при отсутствии других задач цикл блокируется здесь в ожидании I/O.
- Check (Проверка) — выполняет коллбэки
setImmediate(). - Close callbacks (Коллбэки закрытия) — выполняет обработчики закрытия, например
socket.on('close', ...).
Обратите внимание на третью фазу: многие материалы перечисляют только пять фаз и опускают idle/prepare, которая существует, но зарезервирована для внутреннего учёта libuv. Она реальна; вы просто никогда не планируете в неё задачи напрямую.
Микрозадачи не являются фазой
Микрозадачи не являются фазой цикла событий. Сначала выполняются коллбэки process.nextTick, затем очередь микрозадач промисов, и только после этого цикл переходит к следующей макрозадаче — таким образом, process.nextTick имеет приоритет над Promise.then, который имеет приоритет над setTimeout. Руководство Node явно указывает, что process.nextTick технически не является частью цикла событий; его очередь обрабатывается после завершения текущей операции, независимо от текущей фазы, а за ней следует очередь промисов — обе до того, как цикл продвинется вперёд.
Это даёт чёткий трёхуровневый приоритет: process.nextTick → микрозадачи промисов → макрозадачи (таймеры, I/O, setImmediate).
Наглядная демонстрация порядка выполнения
Классическая путаница — setImmediate против setTimeout(0). Внутри I/O-коллбэка порядок детерминирован; на верхнем уровне — нет.
// Run on Node.js 24.16.0
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('1: setTimeout(0)'), 0);
setImmediate(() => console.log('2: setImmediate'));
Promise.resolve().then(() => console.log('3: promise'));
process.nextTick(() => console.log('4: nextTick'));
});
Вывод:
4: nextTick
3: promise
2: setImmediate
1: setTimeout(0)
nextTick и промис выполняются до того, как цикл продвигается вперёд, причём nextTick — первым. Затем, поскольку коллбэки были запланированы внутри I/O-цикла (фаза poll), цикл переходит к фазе check, поэтому setImmediate срабатывает раньше, чем цикл возвращается к фазе timers. Руководство Node подтверждает, что setImmediate() всегда выполняется раньше таймера, если оба запланированы внутри I/O-цикла. Если запланировать те же два вызова на верхнем уровне, порядок будет недетерминированным — не полагайтесь на него вне I/O-коллбэка.
Пул потоков libuv: по умолчанию 4, максимум 1024 — и что им реально пользуется
Пул потоков libuv — это фиксированный набор фоновых потоков (по умолчанию 4, расширяемый до максимума в 1024), которые libuv использует для выполнения операций, не имеющих неблокирующего примитива ОС. Согласно документации пула потоков libuv, максимум задаётся через переменную окружения UV_THREADPOOL_SIZE. (Верхний предел был увеличен со 128 до 1024 в libuv 1.30.0 — статьи, ссылающиеся на «128», устарели.) Пул является общим для всех циклов событий в процессе.
Перечень того, что выполняется в пуле, конкретен и ограничен. Документация Node.js CLI для UV_THREADPOOL_SIZE называет потребителей: API fs (за исключением наблюдателей и явно синхронных вариантов), dns.lookup(), а также асинхронные операции crypto и zlib, такие как crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.generateKeyPair() и сжатие zlib.
Важнее всего то, что пулом не пользуется. Сетевой I/O не задействует пул потоков. Как объясняет официальное руководство Don’t Block the Event Loop, сетевые сокеты обрабатываются механизмом опроса операционной системы — epoll в Linux, kqueue в macOS/BSD, IOCP в Windows — и напрямую появляются в фазе poll. Есть также тонкость с DNS: dns.lookup() (который вызывает getaddrinfo) использует пул, но семейство dns.resolve*() (использующее c-ares) — нет, поэтому обобщение «DNS использует пул» неверно.
# Установите перед запуском Node — пул предварительно выделяется при первом использовании.
UV_THREADPOOL_SIZE=8 node server.js
Одна операционная тонкость: libuv предварительно выделяет максимальное количество потоков при первом использовании пула, поэтому UV_THREADPOOL_SIZE должен быть установлен до этого момента — на практике, до запуска Node. Изменение process.env.UV_THREADPOOL_SIZE после того, как пул был задействован, не даёт никакого эффекта.
Распространённый миф: «Увеличьте
UV_THREADPOOL_SIZE, чтобы ускорить CPU-интенсивные задачи.» УвеличениеUV_THREADPOOL_SIZEускоряет конкурентный I/O, но никогда — CPU-интенсивный JavaScript. Для CPU-задач нужен рабочий поток, поскольку пул не выполняет ваш JavaScript: он запускает C-уровневые операции libuv, а не ваши JS-функции.
Рабочие потоки: настоящий параллелизм для CPU-интенсивного JavaScript
Рабочие потоки выполняют JavaScript параллельно в отдельных потоках по одной причине — для CPU-интенсивных задач, которые иначе блокировали бы единственный JS-поток, — и они не помогают с I/O-интенсивными задачами, которые встроенный асинхронный I/O Node уже обрабатывает эффективнее. Документация worker_threads прямо указывает, что рабочие потоки полезны для CPU-интенсивных JavaScript-операций и практически не помогают с I/O-интенсивными задачами, поскольку встроенный асинхронный I/O Node эффективнее, чем могут обеспечить рабочие потоки.
Ключевой момент, который упускают большинство статей: рабочий поток — это не «просто поток ОС». Каждый из них является отдельным V8-изолятом с собственным циклом событий и собственным циклом libuv. Именно эта изоляция объясняет, почему рабочие потоки не могут совместно использовать обычные JavaScript-объекты и почему всё, что вы передаёте через postMessage, копируется.
Распространённый миф: «Рабочие потоки — это просто потоки ОС.» Каждый рабочий поток — это полноценный V8-изолят плюс собственный цикл событий и цикл libuv, выполняющийся в потоке. Именно поэтому между главным потоком и рабочим нет общих глобальных переменных и общей области видимости замыканий.
Изоляция и передача сообщений
Рабочие потоки обмениваются данными через передачу сообщений, и полезная нагрузка глубоко копируется. Данные, передаваемые через workerData или postMessage(), клонируются согласно алгоритму структурированного клонирования HTML — функции, прототипы классов и живые ссылки не переживают этот процесс. Единственный способ избежать копирования — использование разделяемой памяти: рабочие потоки могут разделять память только через SharedArrayBuffer (или путём передачи ArrayBuffer, что перемещает владение, а не копирует данные). Всё остальное подвергается структурированному клонированию.
// main.js — Run on Node.js 24.16.0
const { Worker } = require('node:worker_threads');
const worker = new Worker('./fib-worker.js', { workerData: { n: 42 } });
worker.on('message', (result) => console.log('fib(42) =', result));
worker.on('error', (err) => console.error(err));
// fib-worker.js
const { parentPort, workerData } = require('node:worker_threads');
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
parentPort.postMessage(fib(workerData.n));
isMainThread позволяет одному файлу разветвляться между двумя ролями, если вы предпочитаете не разделять файлы. Полную поверхность API — MessageChannel, MessagePort, списки передачи, receiveMessageOnPort — смотрите в документации worker_threads. По сравнению с потоками в C++ или Java, эта модель заменяет разделяемую память по умолчанию (и сопутствующие блокировки, мьютексы и состояния гонки) на изоляцию по умолчанию: безопаснее, но ценой копирования.
Используйте пул, а не отдельный рабочий поток для каждой задачи
Создание Worker для каждого запроса расточительно. Документация Node прямо указывает, что на практике следует использовать пул рабочих потоков, поскольку иначе накладные расходы на создание рабочих потоков, скорее всего, превысят их пользу. Стандартным в сообществе пулом является piscina. Piscina — это быстрая и эффективная реализация пула рабочих потоков для Node.js; последняя версия — 5.2.0. Укажите 5.x в диапазоне package.json и зафиксируйте точный релиз в CI.
Есть следствие, которое часто упускают: не переносите уже асинхронные задачи в рабочий поток. Асинхронные операции crypto, fs и zlib уже выполняются в фоновых потоках libuv, поэтому оборачивание их в рабочий поток лишь планирует поток для планирования другого потока, не давая никакого выигрыша. Рабочие потоки оправдывают себя только для синхронного, CPU-интенсивного JavaScript.
Практический пример: CPU-интенсивный маршрут, блокирующий всех
Синхронная CPU-задача в главном потоке замораживает цикл событий для каждого активного запроса, а не только для того, который её вызвал. Вот как выглядит эта проблема в обработчике Express:
// server-blocking.js — Run on Node.js 24.16.0, express 5.x
const express = require('express');
const app = express();
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
app.get('/report', (req, res) => {
res.json({ value: fib(45) }); // блокирует цикл событий на несколько секунд
});
app.get('/health', (req, res) => res.send('ok'));
app.listen(3000);
Пока выполняется fib(45), /health ничего не возвращает — единственный поток занят вычислениями, и каждый параллельный запрос выстраивается в очередь за ним. Решение — вынести CPU-задачу в пул и await результат, освобождая цикл событий для продолжения обслуживания:
// server-pooled.js — Run on Node.js 24.16.0, express 5.x, piscina 5.2.0
const express = require('express');
const Piscina = require('piscina');
const path = require('node:path');
const pool = new Piscina({ filename: path.resolve(__dirname, 'fib-task.js') });
const app = express();
app.get('/report', async (req, res) => {
const value = await pool.run({ n: 45 }); // выполняется в рабочем потоке; цикл остаётся свободным
res.json({ value });
});
app.get('/health', (req, res) => res.send('ok'));
app.listen(3000);
// fib-task.js
module.exports = ({ n }) => {
const fib = (x) => (x < 2 ? x : fib(x - 1) + fib(x - 2));
return fib(n);
};
Теперь fib(45) выполняется в рабочем потоке, цикл событий остаётся отзывчивым, и /health отвечает немедленно, пока тяжёлый маршрут вычисляется в фоне.
Заблокированный цикл событий имеет характерную производственную сигнатуру: поскольку одна синхронная CPU-задача замораживает единственный JS-поток для всех активных запросов, это проявляется как одновременное зависание множества параллельных пользователей в один и тот же момент времени — а не проблема с сетью у одного пользователя. Этот паттерн коррелированных зависаний хорошо виден в записях сессий пользователей, а подтвердить его во время выполнения можно с помощью monitorEventLoopDelay из node:perf_hooks: высокое значение p99 указывает на насыщение цикла (или пула потоков).
Кластеризация и несколько процессов: масштабирование I/O по ядрам
Кластеризация масштабирует Node-приложение по ядрам CPU, разветвляя несколько процессов, каждый из которых запускает полное приложение с собственным циклом событий и памятью, разделяя прослушивающий сокет. Модуль cluster — встроенный способ сделать это; он является правильным инструментом, когда узкое место — это I/O-интенсивная пропускная способность и вы хотите задействовать все ядра машины, а не только одно.
Отличие от рабочих потоков существенно. Рабочие кластера — это отдельные процессы с полностью изолированной памятью, взаимодействующие через IPC; рабочие потоки — это отдельные потоки внутри одного процесса, которые могут разделять память через SharedArrayBuffer. Кластер предназначен для обработки большего количества параллельных запросов по ядрам; рабочие потоки — для вынесения CPU-интенсивного JavaScript из потока запросов. На практике высоконагруженный сервис часто использует оба инструмента: кластер (или менеджер процессов / реплики контейнеров) для охвата ядер и пул рабочих потоков внутри каждого процесса для поглощения периодических CPU-всплесков.
Руководство по выбору: async, пул, рабочий поток или кластер
Выбирайте по типу узкого места: async/await для I/O-интенсивных задач, рабочие потоки для CPU-интенсивного JavaScript, кластер (или несколько процессов) для масштабирования I/O-интенсивной нагрузки по ядрам, и пул рабочих потоков, например piscina, когда иначе вы платили бы за запуск рабочего потока при каждом запросе.
| Инструмент | Выполняет JS параллельно? | Разделяет память? | Лучше всего для | Основная стоимость |
|---|---|---|---|---|
async/await + цикл событий | Нет | Н/Д (один поток) | I/O-интенсивные задачи (сеть, БД, файлы) | Блокирует при CPU-задачах |
| Пул потоков libuv | Нет (выполняет C, не ваш JS) | Н/Д | Конкурентные fs/dns.lookup/crypto/zlib | Фиксированный размер; не для вашего JS |
| Рабочие потоки (+ пул) | Да | Только через SharedArrayBuffer | CPU-интенсивный JavaScript | Запуск + копирование при структурированном клонировании |
| Кластер / несколько процессов | Да | Нет (только IPC) | Масштабирование I/O-нагрузки по ядрам | Накладные расходы процессов; нет общего состояния |
Краткие правила:
- I/O-интенсивная задача, ещё не распараллеленная? Используйте
async/await. Пул потоков и ОС уже обеспечивают конкурентность бесплатно. - Маршрут выполняет тяжёлые синхронные вычисления? Вынесите в рабочий поток, за пулом для повторного использования.
- Насыщаете одно ядро при параллельном трафике? Кластеризуйте по ядрам (или запускайте несколько реплик контейнеров).
- Хотите увеличить
UV_THREADPOOL_SIZEдля скорости? Только если у вас I/O-узкое место на операциях, использующих пул (много параллельныхfs/crypto). Это никогда не ускорит CPU-интенсивный JavaScript.
Перспективное замечание для кода, который вы пишете сегодня: начиная с Node.js 27 в октябре 2026 года, проект переходит на один мажорный релиз в год, отказываясь от чётно-нечётного цикла. Следите за расписанием релизов Node.js и разрабатывайте против текущего LTS.
Ментальная модель, которая помогает держать всё это в голове: перестаньте спрашивать «является ли Node однопоточным?» и начните спрашивать «каково моё узкое место?» Ожидание — задача цикла событий; параллельные вычисления — задача пула рабочих потоков; распределение нагрузки по ядрам — задача кластера. Профилируйте медленный эндпоинт, определите, застрял ли он в ожидании или в вычислениях, и правильный инструмент станет очевидным — затем проверьте исправление с помощью monitorEventLoopDelay перед развёртыванием.
Часто задаваемые вопросы
В чём разница между рабочими потоками и модулем cluster в Node.js?
Рабочие потоки — это отдельные потоки внутри одного процесса, каждый является V8-изолятом с собственным циклом событий и может разделять память через SharedArrayBuffer; cluster разветвляет отдельные процессы с полностью изолированной памятью, взаимодействующие через IPC и разделяющие прослушивающий сокет. Используйте рабочие потоки для вынесения CPU-интенсивного JavaScript из потока запросов, а кластер — для масштабирования I/O-интенсивной нагрузки по ядрам CPU. Высоконагруженные сервисы часто используют оба инструмента.
Почему увеличение UV_THREADPOOL_SIZE не ускоряет мой CPU-интенсивный код?
Пул потоков libuv выполняет C-уровневые операции, такие как вызовы файловой системы, dns.lookup, асинхронные crypto и zlib, а не ваш JavaScript. Увеличение UV_THREADPOOL_SIZE лишь повышает количество таких I/O-операций, выполняемых параллельно; оно никогда не ускоряет CPU-интенсивный JavaScript, поскольку этот код всё равно выполняется на единственном JS-потоке. Для CPU-задач вам нужен рабочий поток, который выполняет JavaScript параллельно в собственном V8-изоляте.
Обрабатывает ли Node.js сетевые запросы через пул потоков libuv?
Нет. Сетевые сокеты обрабатываются механизмом опроса операционной системы — epoll в Linux, kqueue в macOS и BSD, IOCP в Windows — и напрямую появляются в фазе poll цикла событий. Пул потоков libuv обслуживает операции файловой системы, dns.lookup через getaddrinfo, асинхронные crypto и zlib, но не сетевой I/O. Обратите внимание, что функции dns.resolve используют библиотеку c-ares и также обходят пул, поэтому утверждение «DNS использует пул» верно только для dns.lookup.
Могут ли рабочие потоки разделять JavaScript-объекты с главным потоком?
Нет. Рабочие потоки обмениваются данными через передачу сообщений, и данные, передаваемые через workerData или postMessage, глубоко копируются с использованием алгоритма структурированного клонирования HTML, поэтому функции, прототипы классов и живые ссылки не переживают этот процесс. Единственный способ разделить память — использовать SharedArrayBuffer или передать ArrayBuffer, что перемещает владение, а не копирует данные. Такая изоляция по умолчанию позволяет избежать блокировок и состояний гонки, характерных для потоков с разделяемой памятью в языках вроде C++ или Java.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k