Знай свой инструмент: Event Loop в libuv

в 21:05, , рубрики: ansi c, C, event loop, event-driven, event-driven programming, javascript, libuv, node.js, nodejs, Анализ и проектирование систем, Программирование, системное программирование

image
Юдель Пэн. Часовщик. 1924

«Компьютер — это конечный автомат. Потоковое программирование нужно тем, кто не умеет программировать конечные автоматы» 
Алан Кокс, прим. Википедия

“Знай свой инструмент” — твердят все вокруг и все равно доверяют. Доверяют модулю, доверяют фреймворку, доверяют чужому примеру.

Излюбленный вопрос на собеседованиях по Node.js — это устройство Event Loop. И при всем том, очевидном факте, что прикладному разработчику эти знания будут полезны, мало кто пытается самостоятельно погрузиться в устройство событийного цикла. В основном, всех устраивает картинка сверху. Хоть это и похоже на пересказ фильма, который ты не смотрел, а о котором тебе рассказал друг.

image

Самое сложное, наверное для меня, это признавать свои ошибки и соглашаться с тем, что я чего-то не знаю. Об ошибках не любят говорить и писать. В основном, все любят писать и говорить о своих успехах и хороших историях, человек старается выстроить образ непобедимого героя.
А ведь как правило, ошибки допускаются именно по незнанию, именно из-за поверхностных суждений, из-за того, что кто-то потратил меньше времени, чем нужно, на изучение поставленного вопроса. Очевидность. Я знаю.

Ниже, я попробую описать мое понимание событийного цикла на примере исходного кода 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:

image

Пример прост, резервируется необходимая оперативная память и инициализируется структура событийного цикла, далее он запускается в режиме по умолчанию (это кстати именно тот режим, который используется в Node.js). 

Потом происходит закрытие цикла (остановка всех наблюдателей за событиями, наблюдателей сигналов, освобождение памяти выделенной под наблюдатели) и освобождение памяти зарезервированной самим циклом. Нас же будет интересовать устройство функции-запуска цикла (uv_run), посмотрим ее исходный код (он не совсем оригинальный, я удалил строки не связанные с режимом по умолчанию, поэтому в примере переменная “mode” нигде не участвует):

image

Тело функции-запуска, как мы видим, начинается не с цикла “while”, а с вызова uv__loop_alive. В свою очередь, данная функция проверяет наличие активных обработчиков или запросов:

image

От результата выполнения этой функции будет зависеть запустится ли цикл “while” или нет. В случае отсутствия запросов или обработчиков, функция-запуска просто обновит время выполнения событийного цикла и тут же завершится.
Если же есть что обрабатывать (r != 0) и флаг остановки не установлен (stop_flag == 0), то цикл запустится. И первым действием в итерации цикла будет тоже обновление времени выполнения (uv__update_time).

image

image

Следующий шаг в итерации — это запуск таймеров.

image

Структура событийного цикла содержит, так называемую, кучу таймеров. Функция-запуска таймеров вытягивает из кучи обработчик таймера с наименьшим временем и сравнивает это значение с временем выполнения событийного цикла. В случае, если время таймера меньше, то этот таймер останавливается (удаляется из кучи, его обработчик тоже удаляется из кучи). Далее идет проверка, нужно ли его перезапустить.

В Node.js (JavaScript) у нас есть функции setInterval и setTimeout, в терминах libuv это одно и то же — таймер (uv_timer_t), с той лишь разницей, что у интервального таймера выставлен флаг повтора (repeat = 1).

Интересное наблюдение: в случае выставленного флага повтора, функция uv_timer_stop сработает дважды для обработчика таймера.

Перейдем к следующему действию в итерации событийного цикла, а именно функции-запуску ожидающих обратных вызовов (pending callbacks). Вызовы хранятся в очереди. Это могут быть обработчики чтения или записи файла, TCP или UDP соединений, в общем, любых I/O операций, ибо тип не особо имеет значение, так как, вы помните, в unix все есть файл.

image

image

Далее в итерации идут две мистические строки:

image

На самом деле это тоже функции-запуска обратных вызовов, но они не имеют никакого отношения к I/O. Фактически, это какие-то внутренние подготовительные действия, которые было бы неплохо совершить перед тем, как начинать выполнение внешних операций (имеется в виду I/O). В случае с “Hello World”, таких обработчиков нет, но на сайте есть примеры, где такие обратные вызовы регистрируются.

image

В данном примере, idle-обработчик ничего не делает, он будет выполняться пока счетчик не достигнет определенного значение. Таким же способом регистрируются и подготовительные обработчики (prepare).

В Node.js (JavaScript) нет эквивалента этим обработчикам, т.е. мы не можем зарегистрировать какой-то обратный вызов, который бы выполнялся именно на одном из этих шагов. Однако, надо сделать одну оговорку, используя process.nextTick, мы можем ненамеренно выполнить код на одном из этих шагов, так как эта функция срабатывает непосредственно на текущем этапе событийного цикла, а это, в том числе, может быть и uv__run_idle или uv__run_prepare. Сама же, функция process.nextTick, никакого отношения к библиотеке libuv не имеет.

На эту тему (работа process.nextTick) у меня сохранилась старая, но пока еще актуальная, диаграмма со stackoverflow:

image

Следующий этап итерации самый интересный — это внешние операции I/O (poll(2)).

Тут я объединил два шага: вычисление времени для выполнения внешней операции и, непосредственно, внешняя операция.

image

Вычисление времени выполнения внешней операции I/O по реализации схоже с функцией запуска таймеров, так как значение этого времени вычисляется на основе ближайшего таймера. Этим, кстати, и достигается неблокирующая модель (non-blocking poll).

image

image

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

Я не буду тут приводить код этой функции, картинка вполне отражает суть этой операции:

image

image

Следующая операция в очереди команд итерации событийного цикла это uv__run_check. Она по своей сути идентична функциям uv__run_idle и uv__run_prepare, т.е. это запуск обратных вызовов, регистрирующихся по тому же принципу, и вызывающих после внешних операций. Однако, в этом случае, у нас есть возможность регистрации подобных обработчиков из Node.js. Это функция setImmediate (т.е. немедленное выполнение после внешней операции I/O).

Предпоследний шаг — это запуск закрывающихся обработчиков.

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

image

image

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

***

Если у вас есть какие-то замечания или дополнения, я буду рад их увидеть в комментариях или пишите на artur.basak.devingrodno@gmail.com

Автор: Artur Basak

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js