Представьте себе ресторан с одним официантом, который обслуживает десятки столов. Он не стоит и не ждёт, пока на кухне приготовят заказ для первого стола, а сразу идёт принимать заказы у следующих. Именно так, по принципу этого супер-официанта, работает Node.js, и его главный механизм — событийный цикл (Event Loop) — является тем самым секретом производительности, который позволяет обрабатывать тысячи одновременных подключений на одном потоке.
Что такое событийный цикл и зачем он нужен?
Node.js — это однопоточная среда выполнения. Это значит, что весь ваш JavaScript-код выполняется в одном потоке. Звучит как ограничение, но это и есть его сила. Чтобы не блокировать этот единственный поток на время выполнения долгих операций (таких как чтение файлов, запросы к базе данных или сетевые вызовы), Node.js использует асинхронную, событийно-ориентированную архитектуру. Сердцем этой архитектуры и является событийный цикл.
Событийный цикл — это бесконечный цикл, который постоянно проверяет, есть ли задачи для выполнения. Он координирует выполнение синхронного кода, колбэков и операций ввода-вывода.
Архитектура: не один, а много потоков
Важно понять: хотя ваш код выполняется в одном потоке (Main Thread), под капотом Node.js использует многопоточность. Для этого задействуется библиотека libuv, написанная на C++. Она создаёт пул рабочих потоков (worker threads) и управляет асинхронными операциями ввода-вывода (I/O).
Ключевые компоненты системы
- Call Stack (Стек вызовов): Выполняет синхронные функции по принципу LIFO (Last In, First Out).
- Node.js APIs (Библиотеки libuv): Асинхронные операции (fs, crypto, http, таймеры). Они передаются из стека в libuv.
- Callback Queue (Очередь колбэков) / Task Queue: Сюда попадают готовые к выполнению колбэки от завершённых асинхронных операций.
- Microtask Queue (Очередь микрозадач): Приоритетная очередь для колбэков Promises (
.then/.catch/.finally) иprocess.nextTick(). - Event Loop (Событийный цикл): Непрерывно проверяет стек и очереди, решая, что выполнять дальше.
Как работает цикл: фазы детально
Событийный цикл libuv разделён на несколько фаз, которые выполняются по порядку. После завершения последней фазы цикл начинается заново.
- Timers (Таймеры): Выполняются колбэки от
setTimeout()иsetInterval(), чьё время ожидания истекло. - Pending callbacks (Отложенные колбэки): Выполняются колбэки от некоторых системных операций (например, ошибки сети).
- Idle, prepare (Фоновые операции): Внутренние фазы для подготовки.
- Poll (Опрос): Самая важная фаза. Здесь:
- Вычисляется, сколько времени ждать новых событий I/O.
- Выполняются колбэки из очереди опроса (например, от входящих HTTP-запросов или чтения файлов).
- Если очередь опроса пуста, цикл проверит, есть ли колбэки таймеров, готовые к выполнению, и перейдёт к следующей фазе.
- Check (Проверка): Выполняются колбэки, установленные с помощью
setImmediate(). - Close callbacks (Колбэки закрытия): Выполняются колбэки от событий закрытия (например,
socket.on('close', ...)).
Между каждой фазой событийного цикла Node.js проверяет очередь микрозадач (Microtask Queue). Если в ней есть задачи (например, из разрешённых Promise), они выполняются полностью, до опустошения очереди, прежде чем цикл перейдёт к следующей фазе. process.nextTick() имеет даже более высокий приоритет, чем микрозадачи Promise.
Практические примеры и важные выводы
setImmediate() vs setTimeout(()=>{}, 0)
Оба планируют выполнение, но в разных фазах цикла. setImmediate() выполнится в фазе Check, а setTimeout(()=>{}, 0) — в фазе Timers. Внутри основного модуля их порядок может быть непредсказуем, но внутри цикла I/O (например, в колбэке чтения файла) setImmediate() всегда выполнится раньше.
Блокировка цикличного потока — главный грех
Если в синхронном коде или в колбэке, выполняемом в цикле, поместить тяжёлую CPU-задачу (например, цикл на миллиард итераций), событийный цикл будет заблокирован. Он не сможет переходить к следующим фазам, и все остальные запросы "заморозятся". Решение — разбивать такие задачи на части с помощью setImmediate() или выносить в отдельные потоки (Worker Threads).
FAQ: Часто задаваемые вопросы
Node.js действительно однопоточный?
Да, в контексте выполнения вашего JavaScript-кода. Но библиотека libuv использует пул потоков (обычно 4) для выполнения "тяжёлых" асинхронных операций из модулей, написанных на C++ (например, часть операций с файловой системой, шифрование).
Что имеет высший приоритет: Promise или process.nextTick()?
process.nextTick() имеет приоритет выше. Очередь nextTick обрабатывается после завершения текущей операции перед переходом к следующей фазе цикла и даже перед очередью микрозадач Promise.
Как избежать блокировки событийного цикла?
- Разбивайте долгие синхронные вычисления на части с помощью
setImmediate(). - Используйте асинхронные версии API везде, где это возможно (fs.promises вместо fs.readFileSync).
- Для очень CPU-нагруженных задач используйте Worker Threads или выносите логику в отдельные микросервисы.
В чём разница между событийным циклом в браузере и в Node.js?
В браузере управление циклом осуществляет движок (например, V8 в Chrome) в связке с Web APIs. В Node.js за цикл отвечает библиотека libuv, которая предоставляет более широкий набор асинхронных системных операций, недоступных в браузере (работа с файлами, сетевыми сокетами на низком уровне).