Юдель Пэн. Часовщик. 1924
«Компьютер — это конечный автомат. Потоковое программирование нужно тем, кто не умеет программировать конечные автоматы»
Алан Кокс, прим. Википедия
“Знай свой инструмент” — твердят все вокруг и все равно доверяют. Доверяют модулю, доверяют фреймворку, доверяют чужому примеру.
Излюбленный вопрос на собеседованиях по Node.js — это устройство Event Loop. И при всем том, очевидном факте, что прикладному разработчику эти знания будут полезны, мало кто пытается самостоятельно погрузиться в устройство событийного цикла. В основном, всех устраивает картинка сверху. Хоть это и похоже на пересказ фильма, который ты не смотрел, а о котором тебе рассказал друг.
Самое сложное, наверное для меня, это признавать свои ошибки и соглашаться с тем, что я чего-то не знаю. Об ошибках не любят говорить и писать. В основном, все любят писать и говорить о своих успехах и хороших историях, человек старается выстроить образ непобедимого героя.
А ведь как правило, ошибки допускаются именно по незнанию, именно из-за поверхностных суждений, из-за того, что кто-то потратил меньше времени, чем нужно, на изучение поставленного вопроса. Очевидность. Я знаю.
Ниже, я попробую описать мое понимание событийного цикла на примере исходного кода libuv (в тандеме с V8, это основа Node.js), а так же я примкну к когорте людей, которые твердят: “Надо знать свой инструмент”.
Кстати последнее, в современных реалиях, становится нелегким занятием. Один только npm насчитывает, на текущий момент, почти полмиллиона модулей, я уже и не говорю про армию репозиториев на github. Но так все устроено, чтобы оставаться на месте, нужно бежать, чтобы сдвинуться с места, нужно бежать в два раза быстрее.
Эта заметка в первую очередь напоминание мне, напоминание быть внимательнее. Читателю же, я рекомендую погрузиться в исходный код самостоятельно, сделать какие-то выводы, а потом вернуться к этому тексту.
Также, описанное ниже — это огромная аппроксимация того, что на самом деле происходит под капотом Node.js. Среди многих других, заметка базируется именно на исходном коде libuv. Я буду рассматривать кодовую базу библиотеки в части unix. Код для win будет другим.
Ну и вначале немного фундаментальной терминологии:
Событийно-ориентированное программирование (СОП, Event-Driven Programming / EDP) — парадигма программирования, в которой выполнение программы определяется событиями.
Парадигма СОП активно применяется при разработке GUI, однако, применение ей нашлось и на стороне сервера. В 1999 году, обслуживая популярный в то время публичный FTP-сервер Simtel, его администратор Ден Кегель заметил, что узел на гигабитном канале по аппаратным показателям должен был бы справляться с нагрузкой в 10 000 соединений, но программное обеспечение этого не позволяло. Проблема была связана с большим количество программных потоков, каждый из которых создавался на отдельное соединение.
Идея событийного цикла, работающего в одном потоке, решала эту проблему. Подобные реализации есть не только в мире JavaScript (Node.js). К примеру, Asyncio и Twisted в Python, EventMachine и Celluloid в Ruby, Vert.x в Java. Еще один яркий представитель подобной реализации — прокси-сервер Nginx.
В основе СОП лежит Событийный Цикл (Event Loop) — программная конструкция, которая занимается диспетчеризацией событий и сообщений в программе.
Цикл, в свою очередь, работает с асинхронным вводом/выводом, или неблокирующим вводом/выводом, что является формой обработки данных, который позволяет другим процессам продолжить выполнение до того, как передача будет завершена.
Функция обратного вызова (Callback) — возможность передачи исполняемого кода в качестве одного из параметров другого кода. Подобная техника позволяет нам удобно работать с асинхронным вводом/выводом.
“Hello World!”
А теперь начнем с официального примера “Hello World!” сайта http://docs.libuv.org:
Пример прост, резервируется необходимая оперативная память и инициализируется структура событийного цикла, далее он запускается в режиме по умолчанию (это кстати именно тот режим, который используется в Node.js).
Потом происходит закрытие цикла (остановка всех наблюдателей за событиями, наблюдателей сигналов, освобождение памяти выделенной под наблюдатели) и освобождение памяти зарезервированной самим циклом. Нас же будет интересовать устройство функции-запуска цикла (uv_run), посмотрим ее исходный код (он не совсем оригинальный, я удалил строки не связанные с режимом по умолчанию, поэтому в примере переменная “mode” нигде не участвует):
Тело функции-запуска, как мы видим, начинается не с цикла “while”, а с вызова uv__loop_alive. В свою очередь, данная функция проверяет наличие активных обработчиков или запросов:
От результата выполнения этой функции будет зависеть запустится ли цикл “while” или нет. В случае отсутствия запросов или обработчиков, функция-запуска просто обновит время выполнения событийного цикла и тут же завершится.
Если же есть что обрабатывать (r != 0) и флаг остановки не установлен (stop_flag == 0), то цикл запустится. И первым действием в итерации цикла будет тоже обновление времени выполнения (uv__update_time).
Следующий шаг в итерации — это запуск таймеров.
Структура событийного цикла содержит, так называемую, кучу таймеров. Функция-запуска таймеров вытягивает из кучи обработчик таймера с наименьшим временем и сравнивает это значение с временем выполнения событийного цикла. В случае, если время таймера меньше, то этот таймер останавливается (удаляется из кучи, его обработчик тоже удаляется из кучи). Далее идет проверка, нужно ли его перезапустить.
В Node.js (JavaScript) у нас есть функции setInterval и setTimeout, в терминах libuv это одно и то же — таймер (uv_timer_t), с той лишь разницей, что у интервального таймера выставлен флаг повтора (repeat = 1).
Интересное наблюдение: в случае выставленного флага повтора, функция uv_timer_stop сработает дважды для обработчика таймера.
Перейдем к следующему действию в итерации событийного цикла, а именно функции-запуску ожидающих обратных вызовов (pending callbacks). Вызовы хранятся в очереди. Это могут быть обработчики чтения или записи файла, TCP или UDP соединений, в общем, любых I/O операций, ибо тип не особо имеет значение, так как, вы помните, в unix все есть файл.
Далее в итерации идут две мистические строки:
На самом деле это тоже функции-запуска обратных вызовов, но они не имеют никакого отношения к I/O. Фактически, это какие-то внутренние подготовительные действия, которые было бы неплохо совершить перед тем, как начинать выполнение внешних операций (имеется в виду I/O). В случае с “Hello World”, таких обработчиков нет, но на сайте есть примеры, где такие обратные вызовы регистрируются.
В данном примере, idle-обработчик ничего не делает, он будет выполняться пока счетчик не достигнет определенного значение. Таким же способом регистрируются и подготовительные обработчики (prepare).
В Node.js (JavaScript) нет эквивалента этим обработчикам, т.е. мы не можем зарегистрировать какой-то обратный вызов, который бы выполнялся именно на одном из этих шагов. Однако, надо сделать одну оговорку, используя process.nextTick, мы можем ненамеренно выполнить код на одном из этих шагов, так как эта функция срабатывает непосредственно на текущем этапе событийного цикла, а это, в том числе, может быть и uv__run_idle или uv__run_prepare. Сама же, функция process.nextTick, никакого отношения к библиотеке libuv не имеет.
На эту тему (работа process.nextTick) у меня сохранилась старая, но пока еще актуальная, диаграмма со stackoverflow:
Следующий этап итерации самый интересный — это внешние операции I/O (poll(2)).
Тут я объединил два шага: вычисление времени для выполнения внешней операции и, непосредственно, внешняя операция.
Вычисление времени выполнения внешней операции I/O по реализации схоже с функцией запуска таймеров, так как значение этого времени вычисляется на основе ближайшего таймера. Этим, кстати, и достигается неблокирующая модель (non-blocking poll).
Исходный код функции uv__io_poll достаточно сложный и не маленький. Там ведется многопоточная работа, регистрируются наблюдатели событий, обратные вызовы и ведется работа с файловыми дескрипторами.
Я не буду тут приводить код этой функции, картинка вполне отражает суть этой операции:
Следующая операция в очереди команд итерации событийного цикла это uv__run_check. Она по своей сути идентична функциям uv__run_idle и uv__run_prepare, т.е. это запуск обратных вызовов, регистрирующихся по тому же принципу, и вызывающих после внешних операций. Однако, в этом случае, у нас есть возможность регистрации подобных обработчиков из Node.js. Это функция setImmediate (т.е. немедленное выполнение после внешней операции I/O).
Предпоследний шаг — это запуск закрывающихся обработчиков.
Данная функция обходит связанный список закрывающихся обработчиков и пытается завершить закрытие для каждого. Если у обработчика есть специальный обратный вызов на закрытие, то, по окончанию, запускается этот обратный вызов.
И последний шаг итерации, это уже знакомая функция uv__loop_alive. Если данная функция вернет результат отличный от нуля, то событийный цикл запустит новую итерацию.
***
Если у вас есть какие-то замечания или дополнения, я буду рад их увидеть в комментариях или пишите на artur.basak.devingrodno@gmail.com
Nodejs.org: The Node.js Event Loop, Timers, and process.nextTick()
Перевод документации Nodejs.org на тему
Philip Roberts: What the heck is the event loop anyway?
RisingStack.com: Understanding Node.js Event Loop
Nodesource.com: Understanding Node.js Event Loop
Mozilla.org: EventLoop
Автор: Artur Basak