И снова здравствуйте, дорогие читатели! В этой статье я продолжу исследовать тонкости реализации механизмов работы node.js. В предыдущей своей статье я рассмотрел, как работают таймеры. На этот раз речь пойдет об одном из механизмов межпроцессного взаимодействия, а именно, о сигналах.

Готовимся сигналить

Я буду рассматривать как работают сигналы в node.js в рамках операционной системы linux, потому что сам пользуюсь ей на повседневной основе и лучше других в ней разбираюсь. А так же, в случае с linux, сигналы поддерживаются на уровне ядра. Мы проследим, что происходит с момента вызова соответствующих функций на уровне javascript и до момента срабатывания системных вызовов. Спецификация javascript не предусматривает работу с сигналами, поэтому, так же как и таймеры, сигналы должны поддерживаться на уровне окружения, в котором будет работать javascript. Сигналы реализованы в node.js на уровне библиотеки libuv. При исследовании сигналов я решил придерживаться того же подхода, что и с таймерами: я снова взял оригинальный libuv, только на этот раз выбросил все, кроме сигналов. В результате получился упрощенный аналог libuv, в котором поддерживаются только сигналы.

Как хранятся обработчики сигналов

В node.js можно для одного и того же сигнала установить множество обработчиков. А если смотреть с точки зрения операционной системы, то с помощью системного вызова sigaction(2), который позволяет устанавливать обработчики для сигналов, можно установить только один обработчик на каждый сигнал. Также мы знаем, что если приходит сигнал в node.js-процесс, то срабатывают все обработчики, которые были установлены на сигнал. Так где же хранятся и откуда потом берутся все эти обработчики? 

И вот опять мы сталкиваемся со структурами данных. Для того чтобы решить задачу по хранению обработчиков сигналов можно, конечно, использовать разные структуры данных, просто от того, какую структуру мы выберем, будет зависеть эффективность работы с этой структурой. С задачей поиска элемента в структуре хорошо справляются древовидные структуры данных. Например, двоичное дерево поиска позволяет находить элементы за O(log N). Но в таком дереве эффективность поиска зависит от порядка добавления элементов. Можно так добавить элементы, что дерево будет перекошенное и в самом плохом случае просто превратится в связный список, и сложность поиска будет фактически O(n), несмотря на то что это дерево поиска. Поэтому нужно, чтобы дерево поддерживалось в сбалансированном состоянии — попросту говоря, нужно, чтобы оно было как можно более ветвистым, а не перекошенным. Чем более ветвистое дерево, тем меньше его высота. Соответственно, тем меньше шагов нужно будет проделать от корня до искомого элемента, а значит, поиск будет быстрее. Существуют различные алгоритмы балансировки, и нужно выбирать под свою задачу. Нужно определиться, например, как часто происходит вставка и поиск элементов. На мой взгляд, в случае с сигналами тяжело однозначно сказать, что происходит чаще, поиск или вставка. Можно написать приложение, в котором один раз добавили обработчики сигналов, а потом постоянно посылают сигналы, и при этом приложение не завершается. Но, с другой стороны, в каком-то приложении будут часто добавляться и удаляться обработчики сигналов, и при получении сигнала, после срабатывания обработчиков процесс будет завершаться. Поэтому нужно исходить из того, что поиск, вставка и удаление будут происходить примерно с одинаковой частотой. Для этого хорошо подходит такая структура данных, как красно-чёрное дерево

Эксперимент

Давайте добавим несколько обработчиков на разные сигналы и посмотрим, какое у нас получится дерево :) Сначала я предлагаю написать код на javascript, а потом сразу же переключиться на аналогичный код на C, где будут вызываться соответствующие функции libuv.

Код на javascript:

import process from "node:process";

// Begin reading from stdin so the process does not exit.
process.stdin.resume();

console.log("PID", process.pid);

// SIGUSR1
process.on("SIGUSR1", () => {
 console.log("[1] SIGUSR1 received");
});

process.on("SIGUSR1", () => {
 console.log("[2] SIGUSR1 received");
});

process.on("SIGUSR1", () => {
 console.log("[3] SIGUSR1 received");
});

// SIGUSR2
process.on("SIGUSR2", () => {
 console.log("[1] SIGUSR2 received");
});

process.on("SIGUSR2", () => {
 console.log("[2] SIGUSR2 received");
});

process.on("SIGUSR2", () => {
 console.log("[3] SIGUSR2 received");
});

// SIGINT
process.on("SIGINT", () => {
 console.log("[1] SIGINT received");
});

process.on("SIGINT", () => {
 console.log("[2] SIGINT received");
});

process.on("SIGINT", () => {
 console.log("[3] SIGINT received");
});

Мы устанавливаем по 3 обработчика на сигналы SIGUSR1, SIGUSR2 и SIGINT соответственно. Если запустить этот скрипт и посылать в процесс соответствующие сигналы, то будут вызываться обработчики для этих сигналов в том порядке, в котором были добавлены. Хочу обратить внимание на то, что когда мы добавляем обработчики, они сразу же перестают отслеживаться циклом событий: неявно вызывается метод unref у дескриптора сигнала в коде самого node.js. Именно поэтому, чтобы циклу событий было ради чего жить, в самом начале мы вызываем:

process.stdin.resume();

А вот аналогичный код на C:

#include <stdio.h>
#include <unistd.h>
#include <uv.h>

/* SIGUSR1 */
void sigusr1_handler_1(uv_signal_t *handle, int signum)
{
   /* UNSAFE: This handler uses non-async-signal-safe function printf() */
   printf("[1] SIGUSR1 received\n");
}

void sigusr1_handler_2(uv_signal_t *handle, int signum)
{
   /* UNSAFE: This handler uses non-async-signal-safe function printf() */
   printf("[2] SIGUSR1 received\n");
}

void sigusr1_handler_3(uv_signal_t *handle, int signum)
{
   /* UNSAFE: This handler uses non-async-signal-safe function printf() */
   printf("[3] SIGUSR1 received\n");
}

/* SIGUSR2 */
void sigusr2_handler_1(uv_signal_t *handle, int signum)
{
   /* UNSAFE: This handler uses non-async-signal-safe function printf() */
   printf("[1] SIGUSR2 received\n");
}

void sigusr2_handler_2(uv_signal_t *handle, int signum)
{
   /* UNSAFE: This handler uses non-async-signal-safe function printf() */
   printf("[2] SIGUSR2 received\n");
}

void sigusr2_handler_3(uv_signal_t *handle, int signum)
{
   /* UNSAFE: This handler uses non-async-signal-safe function printf() */
   printf("[3] SIGUSR2 received\n");
}

/* SIGINT */
void sigint_handler_1(uv_signal_t *handle, int signum)
{
   /* UNSAFE: This handler uses non-async-signal-safe function printf() */
   printf("[1] SIGINT received\n");
}

void sigint_handler_2(uv_signal_t *handle, int signum)
{
   /* UNSAFE: This handler uses non-async-signal-safe function printf() */
   printf("[2] SIGINT received\n");
}

void sigint_handler_3(uv_signal_t *handle, int signum)
{
   /* UNSAFE: This handler uses non-async-signal-safe function printf() */
   printf("[3] SIGINT received\n");
}

int main()
{
   uv_loop_t loop;
   uv_signal_t sigusr1_1, sigusr1_2, sigusr1_3;
   uv_signal_t sigusr2_1, sigusr2_2, sigusr2_3;
   uv_signal_t sigint_1, sigint_2, sigint_3;

   printf("PID %d\n", getpid());

   uv_loop_init(&loop);
 
   /* SIGUSR1 */
   uv_signal_init(&loop, &sigusr1_1);
   uv_signal_start(&sigusr1_1, sigusr1_handler_1, SIGUSR1);

   uv_signal_init(&loop, &sigusr1_2);
   uv_signal_start(&sigusr1_2, sigusr1_handler_2, SIGUSR1);
   
   uv_signal_init(&loop, &sigusr1_3);
   uv_signal_start(&sigusr1_3, sigusr1_handler_3, SIGUSR1);

   /* SIGUSR2 */
   uv_signal_init(&loop, &sigusr2_1);
   uv_signal_start(&sigusr2_1, sigusr2_handler_1, SIGUSR2);

   uv_signal_init(&loop, &sigusr2_2);
   uv_signal_start(&sigusr2_2, sigusr2_handler_2, SIGUSR2);

   uv_signal_init(&loop, &sigusr2_3);
   uv_signal_start(&sigusr2_3, sigusr2_handler_3, SIGUSR2);

   /* SIGINT */
   uv_signal_init(&loop, &sigint_1);
   uv_signal_start(&sigint_1, sigint_handler_1, SIGINT);

   uv_signal_init(&loop, &sigint_2);
   uv_signal_start(&sigint_2, sigint_handler_2, SIGINT);

   uv_signal_init(&loop, &sigint_3);
   uv_signal_start(&sigint_3, sigint_handler_3, SIGINT);

   uv_run(&loop, UV_RUN_DEFAULT);

   return 0;
}

В случае с примером на C обработчики создаются так, что цикл событий за ними следит. Явно uv_unref мы не вызывали. Интересным открытием для меня было то, что порядок, в котором обработчики сигналов будут добавлены в дерево, а потом при получении сигнала выбираться из него, зависит не от того, в каком порядке вызывались функции uv_signal_init и uv_signal_start, а от того, в каком порядке инициализировались переменные, представляющие дескрипторы сигналов:

uv_signal_t sigusr1_1, sigusr1_2, sigusr1_3;
uv_signal_t sigusr2_1, sigusr2_2, sigusr2_3;
uv_signal_t sigint_1, sigint_2, sigint_3;

Итак, после установки обработчиков сигналов, дерево будет выглядеть следующим образом:

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

О реализации красно-черного дерева в libuv

В libuv работа с красно-черным деревом организована через макросы. Через макросы же инициализируются необходимые структуры. Таким образом, в отличие от кучи и очереди в libuv, красно-чёрное дерево точно знает, что находится в его узлах. Если мы хотим начать хранить какие-то данные в красно-черном дереве, мы используем макросы, которые в итоге раскрываются в структуры и функции для конкретного типа данных (в своем libuv для сигналов я раскрыл эти макросы сам, чтобы наглядно показать, во что на самом деле превращаются функции по работе с деревом).

Сигналы и цикл событий

Красно-чёрное дерево для сигналов является не полем цикла событий, как куча для таймеров, например, а хранится в глобальной переменной. И в этом дереве хранятся дескрипторы сигналов со всех циклов событий.

Сигнал может прийти в любое время. Это значит, что мы должны все бросить и выполнить обработчики? 

Сначала давайте рассмотрим, когда будут срабатывать системные обработчики, которые были установлены через sigaction(2). Обычно, сигнал доставляется в процесс при его следующем запланированном выполнении или сразу же, если процесс уже запущен (к примеру, если процесс отправил самому себе сигнал). Также, с помощью сигнальной маски мы можем заблокировать доставку сигналов. Пришедшие сигналы, которые заблокированы, будут доставлены только когда мы их разблокируем. В libuv системный обработчик сигнала устанавливается так, что его не могут прерывать другие сигналы, а также блокируется доставка всех сигналов на время, пока дескрипторы сигналов добавляются в дерево либо удаляются из него. Таким образом, вновь пришедшие сигналы не могут прервать процесс изменения структуры дерева дескрипторов сигналов.

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

Для ожидания событий ввода-вывода в libuv для linux используется программный интерфейс epoll, а именно системный вызов epoll_wait(2) (если быть совсем точным, то epoll_pwait(2), но для понимания сути это не важно). С помощью этого вызова мы можем отслеживать события ввода-вывода, которые произошли на файловых дескрипторах, а так же можем блокировать процесс на определенное время (используется для таймеров). И еще этот вызов может быть прерван сигналом. Может сложиться такая ситуация, что сигнал придет до того, как вызовется epoll_wait, и тогда сработает обработчик сигнала, а мы заблокируемся в epoll_wait. Хотелось бы, чтобы сигналы, которые приходят в процесс, всегда приводили к разблокированию процесса при вызове epoll_wait, чтобы унифицировать подход к обработке событий.

Эту задачу можно решить с помощью такого подхода, как трюк с зацикленным каналом (self-pipe trick). Он заключается в том, что мы создаем канал (через pipe(2)), и делаем оба конца неблокирующими. Считывающий конец канала мы начинаем отслеживать через epoll, а при срабатывании обработчика сигнала мы записываем необходимую информацию в канал. Таким образом, epoll_wait(2) разблокируется, когда в канале появятся данные. Затем мы сможем считать нужные данные из канала и выполнить те действия, которые хотели выполнять при получении сигнала. Сам epoll_wait должен быть помещен в цикл, чтобы он перезапускался в случае прерывания.

В libuv структура для обслуживания цикла событий содержит в себе поле, в котором хранится массив с двумя дескрипторами, которые соответствуют концам канала:

struct uv_loop_s {
   ...
   int signal_pipefd[2];
   ... 
};

Когда срабатывает системный обработчик сигналов, из дерева выбираются те самые обработчики, которые устанавливал пользователь, в соответствии с номером сигнала и отправляются в канал. А когда epoll_wait(2) увидит, что в канале появились данные и разблокируется, из канала считаются все дескрипторы сигналов и сработают обработчики. Таким образом, мы разобрались, что обработчики, которые устанавливал пользователь вызываются не когда угодно, а контролируемо — после того, как мы вышли из epoll_wait(2).

Резюме

Подытожив, могу отметить следующие моменты, связанные с работой сигналов в node.js.

  • Сигналы поддерживаются на уровне окружения, в котором работает javascript. В node.js за поддержку сигналов отвечает libuv.

  • Обработчики сигналов, которые устанавливает пользователь, хранятся в красно-черном дереве в глобальной переменной.

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

  • Системный обработчик сигнала инициирует вызов обработчиков, которые устанавливал пользователь, и мы точно знаем, когда они сработают - после того, как мы выходим из epoll_wait(2).

  • Чтобы одновременно корректно отслеживать и события ввода-вывода, и сигналы применяется подход, который называется трюк с зацикленным каналом (self-pipe trick)

Выводы

С точки зрения конечного пользователя работа с сигналами в node.js выглядит достаточно просто - устанавливаем обработчики на сигнал, а потом, когда приходит этот сигнал, обработчики вызываются. За нас продумали, когда именно будут вызываться эти обработчики, несмотря на то, что сигналы в основном имеют асинхронную природу. И, как часто бывает, на низком уровне все сложнее и интереснее. Для хранения дескрипторов сигналов используется то самое красно-чёрное дерево, которого некоторые боятся :) Вот еще один пример применения. А для контроля за вызовом обработчиков сигналов применяется общепринятый подход под названием self-pipe trick. Было очень интересно про все это узнать! Очень буду рад, если кто-то узнает что-то новое или произойдет озарение благодаря моей статье.

Комментарии (0)