Игра по правилам: Event loop Node.js
Автор: Илья Горкун, Junior Software Engineer
Паттерн Reactor
Обратимся к классической модели блокирующего I/O: есть сервер, и некоторый компонент системы обращающийся к БД, в данном случае выполнение кода блокируется, пока не придет ответ компоненте от БД.
Это создает некоторую сложность, если как минимум два пользователя одновременно обратятся к серверу, поэтому самый логичный вариант, который позволит выйти из этой ситуации - это создать для другого подключения отдельный поток или процесс (или повторно использовать один из имеющихся в пуле), в котором он бы выполнял свои задачи.
Рассмотрим рисунок, который отображает суть модели.

Желтым цветом выделена полезная работа, которая совершается, когда мы получаем новые данные из одного конкретного потока, а бежевым - бездействие потока. На каждый поток у нас тратится ресурс по памяти, а также вызывается переключение контекстов, что при достаточно большом количестве подключений делает работу сервера не оптимальной.

Кроме блокирующего I/O существует неблокирующий I/O. При его использовании системные вызовы немедленно возвращают управление, не ожидая чтения файла или сетевого запроса.

Одним из вариантов неблокирующего I/O будет реализация цикла ожидания (busy-waiting). Идея алгоритма заключается в том, что мы проводим активный опрос ресурсов, пока не получим ответа об их готовности.


Через цикл ожидания мы можем обрабатывать несколько ресурсов в одном потоке, но этот метод будет не слишком эффективен, так как мы тратим наше драгоценное процессорное время на обход ресурсов, недоступных большую часть времени.


Вообще, цикл ожиданий можно представить как консьержа, который работает в большом отеле:
он бегает по всем номерам и спрашивает, что принести постояльцам. Он будет бегать с первого до последнего этажа и обратно, но если отель очень большой, то для консьержа это задача станет крайне сложной.

Поэтому нужно избавить наш цикл от опроса ресурсов, которые еще не завершились, а сосредоточить на работе только с теми ресурсами, которые нам уже доступны.

Рассмотрим другой вариант, который является более эффективным механизмом параллельной работы с ресурсами. Механизм называется синхронным демультиплексированием событий или интерфейсом уведомления событий.


Основная идея, заложенная в этом принципе такова: у нас есть структура, которая добавляет в себя ресурсы с неким статусам для использования, а механизм оповещения наблюдает за группой ресурсов. Этот вызов работает синхронно и блокирует выполнение до готовности любого из наблюдаемых ресурсов к чтению. Когда вызов выполняется, демультиплексор возобновляет работу и становится доступным для обработки нового набора событий. В вызове обрабатываются все события, возвращенные демультиплексором. Здесь ресурс, связанный с событием, гарантировано готов к чтению и не блокирует выполнение. После обработки всех событий поток демультиплексора вновь блокируется до готовности обработки новых событий.

Данная реализация уже работает не как простой консьерж: теперь ему помогает менеджер отеля.
Менеджер соберет все данные, которые получит от постояльцев, обработает их, выполнит все задачи и только потом делегирует обязанности консьержу, который будет работать только с теми постояльцами, которые действительно оставляли запрос.

Те идеи, которые мы рассмотрели выше, содержатся в паттерне Reactor, на котором основана сама технология Node.js. Давайте детально разберем этот паттерн.

Рассмотрим все его фазы:

0. Пусть у нас есть приложение, которое использует паттерн Reactor.


1. Приложение создает новую операцию I/O, передает запрос демультиплексору событий, а также приложение определяет для этой операции обработчик. Демультиплексор не блокирует приложение, а немедленно передает ему управление. Все работы по операциям происходят на уровне ОС.
2. После обработки набора операции I/O демультиплексор добавляет новые события в очередь событий.
3. Цикл событий приступает к обходу очереди событий.
5. После обработки всех событий в очереди событий демультиплексор блокирует цикл. Он снова заработает, когда демультиплексор событий не отправит новые события в очередь.
4. Обработчики делятся на две группы: те, которые выполняются и передают управление в цикл событий, чтобы тот взял новое событие, и те, которые создают новую операцию I/O, что приводит к добавлению новой операции в демультиплексор событий до возврата обратно к циклу событий.


Рассмотрим демультиплексор событий, а точнее его реализацию. Так как уже говорилось, что операции I/O происходят на уровне ОС, то любая такая операция может вести себя совершенно по-разному в разных ОС. Например, в Unix не поддерживается неблокирующий I/O, поэтому для имитации этой операции приходится создавать отдельный поток вне цикла событий. Подобные несоответствия и различия в ОС привели инженеров к созданию адаптера между Node.js приложением и системными вызовам ОС. Библиотека, реализующий это адаптер, называется libuv.
Но libuv не только выполняет функцию адаптера, его создатели также добавили туда реализацию самого паттерна Reactor, цикл событий и очередь событий.
Ремарка: V8 вообще не причастен к циклу событий, как многие могли бы подумать.
Если подвести итог, то Node.js предоставляет иной подход к обработке запросов, по сравнению с концепцией "один поток на одно соединение". Если классическая модель создает на каждую задачу отдельный поток, то есть для этого она выделяет системные ресурсы, то Node.js работает в одном потоке. Поэтому единственным ресурсом, на котором Node.js может выполнять много задач, является время. Из этого вытекает правило, что для лучшей работы на обработчик не нужно вешать очень сложные задачи, затрагивающие ресурс процессора.
О цикле событий мы говорили, когда обсуждали паттерн Reactor. Его задачей было разбирать события, которые нам отдает демультиплексор событий, и затухать, когда очередь пуста, а у демультиплексора событий еще есть задачи блокирующего I/O.

Но цикл событий выполняет приходящие в очередь события довольно нетривиально, что фактически и делает его довольно быстрым.

Структурно цикл событий выглядит так:
Цикл событий
Кратко рассмотрим каждую фазу:

  1. Timers -- обрабатываем все коллбэки setTimeout() и setInterval(). Интересный факт для libuv: эти две функции — это одно и тоже, просто интегральном таймере параметр repeat стоит с 1.
  2. Pending callbacks -- эта фаза выполняет обратные вызовы для некоторых системных операций, например, ошибки TCP.
  3. Idle, prepare -- это системная фаза, у нас нет к ней доступа, node.js сама ее вызывает(оно особо нас не интересует)
  4. Poll -- занимается обработкой I/O операциями
  5. check -- выполняет коллбэки setImmediate()
  6. close -- выполняются события 'close', socket.on('close')

Отдельно от 6 фаза есть так называемые микротаски. Таких фазы две:
  1. NextTickQueue -- выполняются вызовы procces.nextTick()
  2. Other microtasks queue -- в основном здесь выполняются Promise

Данные микротаски выполняются, если цикл событий не находится в одной из 6 фаз.

Хочется заметить, что каждая фаза представляет из себя очередь, в которой содержатся коллбэки одного типа. Они выполнятся все, если мы оказались в конкретной фазе и там есть n коллбэков, то эти n коллбэков поочередно выполняются, а потом фаза завершается.

Для лучшего понимания, разберем следующий пример:
Вообще задавать вопросы про цикл событий и давать код, чтобы его разобрать — одна из самых популярных задач на собеседованиях, связанных с Node.js.

Приступим:

1. После запуска кода -- node index.js, который содержит только функцию main и ее вызов. Мы идем по коду сверху вниз, потенциально пытаясь выполнить команды на нашем пути. Встречая на пути синхронные операции, мы их сразу выполняем, асинхронные операции мы отправляем демультиплексору событий, который займется их обработкой.
Здесь синхронными операциями будут console.log, промисы с await (не будем забывать, что main -- async function), а также зарезолвленные промисы. На момент, когда node.js дойдет до конца функции, мы будем иметь на экране:

START
Promise
Async/await
END

А в очередях фаз будут находиться данные коллбэки:
2. Теперь в работу вступает цикл событий, он видит, что для него есть задачи. Первым делом мы выполняем микротаски, которых у нас две в очереди событий. Как только мы со всеми разберемся, то перейдем уже к основным фазам. В каждой фазе мы будем выполнять все ее готовые коллбэки, выходя из фазы мы будем потенциально выполнять все новые микротаски, а только потом переходить к новой фазе. На момент, как цикл событий дойдет до фазы Poll, мы будем иметь на экране:

START
Promise
Async/await
END
Promise next tick
Next tick
SetTimeout
SetTimeout

А очереди фаз будут в состояниях:


3. Перед тем, как выполнить потенциальные функции обратного вызова у Poll, цикл событий проверяет на пустоту фазу Check, если в ней что-то есть, то он немедленно переключается на данную фазу, Poll цикл событий в рамках этой итерации уже трогать не будет. На данный момент будем иметь такую картину:

START
Promise
Async/await
END
Promise next tick
Next tick
SetTimeout
SetTimeout
SetImmediate




4. Закончив эту итерацию, цикл проверит, нужно ли ему начать новую итерацию, есть ли еще невыполненные коллбэки. На новой итерации он будет вести себя также, как на прошлой, с единственной оговоркой, что в фазах он долго задерживаться не будет, ведь нечего обрабатывать, пока не дойдет до фазы Poll, которую пропустил на прошлой итерации. Выполнив фазу Poll, мы получим новый результат:

START
Promise
Async/await
END
Promise next tick
Next tick
SetTimeout
SetTimeout
SetImmediate
Read file


5. После выхода из Poll цикл ведет себя штатно: он будет ходить по таскам и микротаскам по тем правилам, о которых я писал выше. Единственное, что тут можно заметить, что setImmediate выполнится гарантированно раньше, чем setTimeout — этому есть разумное объяснение: причина в том, что цикл событий уже прошел фазу Timer, но не прошел стадию Check. Когда мы начнем новую итерацию и выполним таймеры, будем иметь:

START
Promise
Async/await
END
Promise next tick
Next tick
SetTimeout
SetTimeout
SetImmediate
Read file
Read file next tick
Read file SetImmediate
Read file SetTimeout
Мы видим, что очереди фаз пустуют, а также, что код больше не создаст новой асинхронной операции, но циклу событий этого неизвестно, он еще раз честно пройдет, начавшуюся итерацию, которую мы начали из-за необработанного коллбэка таймера, а только потом прекратит свою работу
Node.js - отличная платформа, основанная на нескольких важных принципах, которые обеспечивают быструю разработку гибких приложений. Для многих разработчиков эти идеи покажутся незнакомыми: асинхронный характер паттерна Reactor, основанный на функциях обратного вызова, требует другого стиля программирования, event loop, который имеет свой конкретный порядок и правила выполнения событий. И здесь все эти принципы придется знать и соблюдать, если вы хотите добиться нужного вам перфоманса.
Вывод