Цикл событий: как выполняется асинхронный JavaScript-код в Node.js
Разбираемся, как работает цикл событий Node.js, зачем там коллбэки и в каком порядке они выполняются.
Вы наверняка слышали о знаменитом цикле событий Node.js и о том, как ему удается обеспечивать невероятную скорость без блокировок, располагая всего лишь одним потоком выполнения кода. Ввод-вывод в Node.js событийно управляемый, и все действия выполняются в форме коллбэков – функций обратного вызова. Для организации приложения важно понимать, в каком порядке цикл событий эти коллбэки запускает. Давайте разбираться.
Фазы цикла событий
Цикл событий состоит из нескольких фаз, которые повторяются на каждой итерации одна за другой. В этой статье мы заглянем одним глазом на нижние уровни архитектуры Node.js и посмотрим, что это за фазы и какой код они выполняют.
Существует ошибочное представление, что в Node есть только одна глобальная очередь коллбэков. На самом деле, каждая фаза имеет собственную очередь/кучу.
Общая схема работы выглядит так: попадая на определенную фазу, цикл событий выполняет некоторые специфические действия, затем выполняет все коллбэки из очереди этой фазы (до определенного предела) и переходит дальше.
Таймеры
Функции обратного вызова таймеров (setTimeout
, setInterval
) хранятся в куче до того момента, пока не истечет их срок действия. Если в очереди есть несколько таких «просроченных» коллбэков, цикл событий начинает вызывать их в порядке возрастания задержки, пока они не кончатся.
Выполнение таймеров контролируется в фазе опроса, до которой мы очень скоро доберемся. Если цикл долго задерживается в poll-фазе (блокировка), выполнение функций таймеров может быть задержано.
I/O коллбэки
На этом этапе цикл событий выполняет обратные вызовы системных операций ввода-вывода, которые были отложены на предыдущей итерации.
Например, вы пишете Node-сервер. Порт, на котором вы хотите запустить процесс, уже используется другим процессом. Node выдаст ошибку ECONNREFUSED
. Некоторые *nix-системы могут ожидать получения сообщения об ошибке. Такие вызовы помещаются в очередь этой фазы цикла событий.
Ожидание/Подготовка
На этой фазе для нас не происходит ничего интересного.
Опрос
Цикл событий проверяет, появились ли в очереди новые асинхронные коллбэки. Здесь выполняются почти все наши функции обратного вызова кроме setTimeout
, setInterval
, setImmediate
и close
-функций.
Если в начале этой фазы в очереди уже есть обратные вызовы, все они будут выполнены по порядку (за раз выполняется не более определенного числа коллбэков).
Если очередь пуста, возможно несколько вариантов:
- Если в очереди
setImmediate
что-то есть, то цикл событий сразу завершит фазу опроса и перейдет к фазе проверки. Здесь он последовательно выполнит всеsetImmediate
-коллбэки. - Если очередь
setImmediate
пуста, то будет проверена очередь таймеров. Если какой-то таймер уже завершился, то цикл перейдет в первую фазу (timers) для выполнения функций обратного вызова. - Если же нет ни событий
setImmediate
, ни готовых таймеров, цикл событий останется в poll-состоянии и будет ожидать добавления в очередь новых коллбэков.
Проверка/setImmediate
К этой фазе цикл событий приходит, когда в фазе опроса не осталось никаких обратных вызовов. Здесь выполняются коллбэки функции setImmediate
.
Close-коллбэки
Последними в цикле выполняются функции обратного вызова, связанные с внезапными close
-событиями, например, socket.on('close', fn)
или process.exit()
.
Микротаски
Помимо всего этого, в Node есть еще одна очередь микрозадач. В нее помещаются коллбэки метода process.nextTick()
, имеющие максимальный приоритет вне зависимости от фазы цикла событий.
Примеры
setTimeout vs setImmediate
Начнем с простых таймеров (на самом деле, они только кажутся простыми):
Ожидаемый вывод этого фрагмента:
Цикл событий начинается с фазы таймеров, выполняет обратный вызов для setTimeout
, переходит к следующим фазам, в которых нет никаких коллбэков, достигает фазы проверки и выполняет функцию обратного вызова для setImmediate
.
Вы можете справедливо возмутиться, что вызов setTimeout
с задержкой в 0 секунд не выполняется сразу же. А значит первый коллбэк не выполнится в фазе таймеров.
Может быть, наш код будет выводить такой результат?
Если вы запустите этот фрагмент в Node несколько раз, то увидите, что возможны оба варианта.
Важно понимать, что когда цикл событий встречает обратный вызов, он сначала просто регистрирует его в соответствующей очереди. Только после этого начинается выполнение по фазам.
Существует неправильное представление, что setTimeout(fn, 0)
всегда выполняется до setImmediate
. Как мы только что видели, это далеко не всегда так. setTimeout всегда имеет небольшую задержку (4-20мс). Если она успеет истечь до наступления фазы таймеров (после регистрации коллбэков), то вызов будет выполнен. Иначе сначала вызовется функция, связанная с setImmediate
. Это поведение невозможно предсказать – оно зависит от количества коллбэков, фазы цикла и пр.
I/O-коллбэки и сдвиг цикла
Однако если мы немного перепишем код:
Вывод всегда будет следующим:
Что здесь происходит?
- Когда мы вызываем функцию main(), цикл событий изначально выполняется без фактического вызова коллбэков. Он видит функцию fs.readFile и регистрирует ее обратный вызов в очереди I/O callbacks. После этого цикл переходит к реальному выполнению кода.
- Он начинает с фазы таймеров и ничего там не находит.
- В фазе I/O коллбэков тоже нет выполненных вызовов.
- Когда операция чтения файла будет завершена, цикл событий выполнит ее коллбэк (в I/O фазе).
- Затем он перейдет в фазу проверки (
setImmediate
) и лишь затем – на новую итерацию, в фазу таймеров. Таким образом в I/O-коллбэкахsetImmediate
всегда выполняется раньше, чемsetTimeout(fn, 0)
.
Микротаски
Коллбэки методаprocess.nextTick()
– это микрозадачи, которые имеют приоритет над всеми фазами. Они выполняются сразу же после того, как цикл событий завершит текущую операцию. То есть после каждого действия цикл проверяет очередь микротасков, и если в ней что-то есть – выполняет сразу все.
Итак, как работает этот код:
- Регистрирует коллбэки по соответствующим очередям.
- Выполняет два микротаска.
- Переходит в фазу таймеров, но вызов
setTimeout
с задержкой в 50 мс еще не готов. - Выполняет коллбэк
setImmediate
в фазе проверки. - Обратный вызов
setTimeout
выполняется уже на следующей итерации.
Асинхронные микротаски
А что произойдет, если в метод process.nextTick
передать асинхронную функцию?
Результат будет таким:
Разберемся, откуда что взялось:
- Коллбэки регистрируются в соответствующих очередях.
- Выполняется первый синхронный микротаск – в консоль выводится
2
. - Начинает выполняться второй микротаск. Коллбэк
setTimeout
из него помещается в очередь фазы таймеров. - После этого цикл событий начинает работать в обычном режиме, начиная с фазы таймеров.
- Время первого setTimeout (50 мс) еще не истекло, двигаемся дальше.
- Выполняется коллбэк
setImmediate
в фазе проверки – в консоль выводится3
. - Начинается новая итерация, цикл возвращается в фазу таймеров. Тут остались две функции обратного вызова setTimeout, которые и выполнятся по очереди, выведя в консоль
1
и4.
Все вместе
Разобравшись с фазами цикла событий и очередью микротасков, мы можем испытать свои знания на новом комплексном примере:
На гифке представлена схема работы цикла событий для этого примера:
Этот фрагмент кода выведет следущий результат:
Или вот такой – вспоминаем первый пример:
Определения
Микрозадачи
В Node.js (а точнее в движке V8) существует понятие микрозадач. Они являются именно частью движка, а не частью цикла событий. Кроме process.nextTick()
к ним относится, например, метод Promise.resolve()
.
Микрозадачи имеют приоритет перед всеми другими задачами. Как только что-то попадает в очередь микрозадач – оно сразу же выполняется (после завершения текущей операции).
Но если вы поместите много коллбэков в эту очередь, то можете вызвать "голодание" цикла событий.
Макрозадачи
Такие задачи, как setTimeout
, setInterval
, setImmediate
, requestAnimationFrame
, I/O
, рендеринг пользовательского интерфейса или другие функции обратного вызова относятся к макрозадачам. У них нет никаких приоритетов, а выполнение определяется фазой цикла событий.
Итерация (tick) цикла событий
Один тик цикла соответствует проходу по всем фазам и возвращение к началу. Он характеризуется частотой (количество итераций в единицу времени) и длительностью (время затраченное на одну итерацию).
Если вы тоже любите Node.js, у нас есть для вас несколько материалов:
- Зачем и как строить Интернет вещей с Node.js
- Подборка бесплатных ресурсов для изучения Node.js
- Создаем мощный API на Node.js, GraphQL, MongoDB, Hapi и Swagger
- 10 главных ошибок, которые совершают Node-разработчики
- Простое симпатичное CLI приложение для Node.JS