Фазы событийного цикла
Урок объясняет, из каких фаз состоит событийный цикл Node.js и как libuv проворачивает их круг за кругом.
Событийный цикл — бесконечный круг фаз, на каждом обороте которого Node забирает готовые к выполнению колбэки и вызывает их по одному в главном (и единственном) JS-потоке.
Node.js часто описывают как «однопоточный, но неблокирующий». Эти два слова кажутся противоречием: как один поток может обслуживать тысячи соединений, не вставая в очередь? Ответ — событийный цикл. Ваш JavaScript-код действительно исполняется в одном потоке, но всю медленную работу (чтение файла, сетевой запрос, ожидание таймера) Node поручает операционной системе и библиотеке libuv, а сам в это время занимается другими делами. Когда работа готова, в очередь падает колбэк, и цикл вызывает его, как только дойдёт до соответствующей фазы.
Понимать фазы важно не из академического интереса. От порядка фаз зависит, когда сработает ваш setTimeout, успеет ли ответ уйти клиенту до следующей итерации и почему иногда колбэки выполняются не в том порядке, в каком вы их написали. Без этой модели поведение асинхронного кода выглядит магией.
Зачем это на практике
Представьте HTTP-сервер. Пришёл запрос — Node читает его из сокета, запускает ваш обработчик, тот делает запрос в базу и регистрирует колбэк «когда придёт ответ — отправь его клиенту». Здесь обработчик мгновенно завершается, поток свободен, и Node принимает следующий запрос. Колбэк с ответом базы выполнится позже, в фазе poll. Именно так один поток держит тысячи одновременных соединений: он никогда не ждёт впустую.
Если же вы в обработчике запустите тяжёлый синхронный цикл, поток встанет — и все остальные запросы будут ждать. Знание фаз помогает понять, где именно «застрянет» цикл и почему сервер перестаёт отвечать.
Шесть фаз одного оборота
На каждой итерации libuv последовательно проходит фиксированный набор фаз. У каждой фазы своя очередь колбэков (FIFO); цикл опустошает очередь текущей фазы и переходит к следующей.
| Фаза | Что обрабатывает |
timers | колбэки setTimeout и setInterval, у которых истёк срок |
pending callbacks | отложенные системные колбэки (например, некоторые ошибки TCP) |
idle, prepare | внутреннее использование libuv |
poll | забирает новые I/O-события, выполняет их колбэки; здесь цикл может ждать |
check | колбэки setImmediate |
close callbacks | колбэки закрытия, например socket.on('close') |
Ключевые для прикладного кода — три: timers, poll и check. Остальные нужны самой libuv или редким случаям. Фаза poll — самая важная: именно в ней выполняется большинство ваших колбэков (чтение файлов, сетевые ответы), и именно она умеет блокироваться и ждать новых событий, если делать больше нечего.
Как это работает под капотом
Под Node лежит libuv — кроссплатформенная C-библиотека асинхронного I/O. Она абстрагирует системные механизмы опроса: epoll в Linux, kqueue в macOS/BSD, IOCP в Windows. Когда вы вызываете, скажем, fs.readFile, libuv либо отдаёт операцию ОС (для сети это умеет ядро), либо ставит её в свой пул потоков (по умолчанию 4 потока) — так делается файловый ввод-вывод и часть crypto. Главный JS-поток при этом свободен.
Когда операция завершилась, поток из пула или ядро кладёт результат в очередь соответствующей фазы. На следующем заходе в poll цикл заберёт готовое событие и вызовет ваш колбэк — уже в главном потоке. Поэтому ваш JS-код всегда однопоточен и вам не нужны мьютексы для собственных переменных, хотя под капотом потоков несколько.
Важный нюанс фазы poll: если её очередь пуста, цикл решает, ждать ли. Если есть отложенные таймеры или setImmediate, он не блокируется и идёт дальше. Если делать совсем нечего, но есть открытые соединения, он засыпает в системном вызове опроса до ближайшего события — это и есть «неблокирующее ожидание», не сжигающее процессор.
Псевдокод одного оборота полезно держать в голове:
while (есть_активные_задачи) {
выполнить_фазу(timers) // истёкшие setTimeout/setInterval
выполнить_фазу(pending) // отложенные системные колбэки
выполнить_фазу(poll) // I/O: чтение/сеть; здесь можно подождать
выполнить_фазу(check) // setImmediate
выполнить_фазу(close) // socket.on('close') и т.п.
// между колбэками — опустошить очереди микрозадач
}
Этот код использует require/системные вызовы и помечен как language-text: запускать его в браузере нечем — Node-API недоступны. Но саму логику оборота он передаёт точно.
Маленький эксперимент с порядком
Чтобы почувствовать фазы, посмотрите на этот скрипт. Он использует setTimeout, поэтому исполнить его можно только в Node — здесь это блок language-text для чтения, кнопки «Запустить» у него нет.
console.log('1: синхронный старт');
setTimeout(() => console.log('2: timers (setTimeout 0)'), 0);
setImmediate(() => console.log('3: check (setImmediate)'));
Promise.resolve().then(() => console.log('4: микрозадача (промис)'));
process.nextTick(() => console.log('5: nextTick'));
console.log('6: синхронный конец');
Вывод:
1: синхронный старт 6: синхронный конец 5: nextTick 4: микрозадача (промис) 2: timers (setTimeout 0) 3: check (setImmediate)
Сначала идёт весь синхронный код (строки 1 и 6). Затем, прежде чем цикл вообще зайдёт в первую фазу, опустошаются микрозадачи: nextTick (5) и промисы (4). И только потом начинаются фазы: timers (2), затем check (3). Микрозадачи — отдельный механизм поверх фаз, ему посвящён следующий урок.
Частые ошибки
Считать, что setTimeout(fn, 0) выполнится «сразу». Нет: колбэк попадёт в фазу timers следующего оборота, после всего синхронного кода и микрозадач. «0» — это «не раньше чем через 0 мс», а не «немедленно».
Путать фазы с потоками. Фаз шесть, но поток для вашего JS — один. Фазы — это порядок разбора очередей, а не параллелизм.
Думать, что файловый I/O «бесплатен». Он идёт через пул потоков libuv (4 по умолчанию). Запустите 100 одновременных тяжёлых чтений — они выстроятся в очередь по 4, потому что больше потоков нет (предел поднимается через UV_THREADPOOL_SIZE).
Блокировать фазу poll синхронным кодом. Если ваш колбэк в poll крутит тяжёлый цикл, весь событийный цикл стоит: ни таймеры, ни новые соединения не обслуживаются, пока колбэк не вернёт управление.
Итоги
- Событийный цикл — круг из шести фаз;
libuvпроходит их по очереди, опустошая очередь каждой фазы. - Прикладному коду важны три фазы:
timers,poll(I/O, может ждать) иcheck(setImmediate). - Node однопоточен для вашего JS, но libuv держит пул из 4 потоков и использует
epoll/kqueue/IOCP для I/O — отсюда «неблокирующий». setTimeout(fn, 0)срабатывает в фазеtimersследующего оборота, а не мгновенно.- Любой тяжёлый синхронный код в колбэке останавливает весь цикл — это главный источник лагов.