Как работают супервизоры в Elixir

В Erlang и Elixir супервизоры — это процессы, управляющие дочерними процессами и перезапускающие их в случае возникновения ошибок. В этой статье подробно рассматривается реализация супервизоров в Elixir.

Но прежде чем перейти к самим супервизорам, неплохо было бы как следует ознакомиться с модулями gen_server и supervisor. Можно обойтись и без этого, если вам уже доводилось работать с эквивалентными модулями Elixir, так как они просто передают вызовы модулям Erlang, не меняя своего поведения.

Начнём, пожалуй, с примера из самой документации Elixir:

defmodule MyApp.Supervisor do
  use Supervisor

  def start_link do
    Supervisor.start_link(__MODULE__, [])
  end

  def init([]) do
    children = [
      worker(Stack, [[:hello]])
    ]

    # supervise/2 is imported from Supervisor.Spec
    supervise(children, strategy: :one_for_one)
  end
end

В приведённом примере метод start_link создаёт супервизор, а метод init реализует обратный вызов, используемый поведением из Supervisor. Рассмотрим Supervisor подробнее.

Пробежимся по поведениям. Инструкция use Supervisor разворачивается во время компиляции в поведение, указанное в макросе __using__. Посмотрим на код макроса __using__ из модуля Supervisor в исходниках Elixir.

На момент написания статьи макрос __using__ выглядел так:

defmacro __using__(_) do
  quote location: :keep do
    @behaviour Supervisor
    import Supervisor.Spec
  end
end

С помощью @behaviour в супервизоре проверяется наличие необходимых функций обратного вызова, а оператор import подгружает в MyApp.Supervisor дополнительные функции из модуля Supervisor.Spec. Именно в нём определены функции worker и supervise.

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

Работа супервизора начинается с вызова MyApp.Supervisor.start_link. Он делегируется функции Supervisor.start_link, передавая ссылку на себя (с помощью макроса __MODULE__).

Посмотрим на реализацию Supervisor.start_link:

def start_link(module, arg, options \\ []) when is_list(options) do
  case Keyword.get(options, :name) do
    nil ->
      :supervisor.start_link(module, arg)
    atom when is_atom(atom) ->
      :supervisor.start_link({:local, atom}, module, arg)
    {:global, _term} = tuple ->
      :supervisor.start_link(tuple, module, arg)
    {:via, via_module, _term} = tuple when is_atom(via_module) ->
      :supervisor.start_link(tuple, module, arg)
    other ->
      raise ArgumentError, """
      expected :name option to be one of:
        * nil
        * atom
        * {:global, term}
        * {:via, module, term}
      Got: #{inspect(other)}
      """
  end
end

Обратите внимание, что модуль Elixir делегирует вызовы модулю Erlang :supervisor. Но это не повод для паники! Доверьтесь мне и загляните в исходники Erlang.

Введя в поиске start_link, вы наткнётесь на инструкцию export, позволяющую использовать функцию за пределами модуля, спецификацию, описывающую доступные для функции типы данных, и, собственно, саму её реализацию:

start_link(Mod, Args) ->
    gen_server:start_link(supervisor, {self, Mod, Args}, []).

Видите? Уже что-то знакомое. Просто запускаем gen_server, не вдаваясь в подробности его работы. Главное, что здесь нужно усвоить, — это то, что супервизоры построены на основе модуля gen_server, который ожидает увидеть реализацию нескольких функций обратного вызова.

Гораздо больший интерес представляет метод init. Обратите внимание, что хоть MyApp.Supervisor и содержит реализацию функции init, но это не функция обратного вызова, которая будет вызвана далее. Если вернуться к реализации функции start_link в модуле :supervisor из Erlang, можно заметить, что в неё передаётся параметр self. Это означает, что :supervisor.init — та самая искомая функция.

Посмотрите на её исходный код:

init({SupName, Mod, Args}) ->
    process_flag(trap_exit, true),
    case Mod:init(Args) of
        {ok, {SupFlags, StartSpec}} ->
            case init_state(SupName, SupFlags, Mod, Args) of
                {ok, State} when ?is_simple(State) ->
                    init_dynamic(State, StartSpec);
                {ok, State} ->
                    init_children(State, StartSpec);
                Error ->
                    {stop, {supervisor_data, Error}}
                  end;
        ignore ->
            ignore;
        Error ->
            {stop, {bad_return, {Mod, init, Error}}}
end.

Во-первых, вызывается функция Mod:init(Args), которая в свою очередь вызывает функцию MyApp.Supervisor.init. Ещё раз взглянем на код:

def init([]) do
  children = [
    worker(Stack, [[:hello]])
  ]

  # supervise/2 is imported from Supervisor.Spec
  supervise(children, strategy: :one_for_one)
end

Не стоит забывать, что worker и supervise — хелперы из Supervisor.Spec. Не будем ходить вокруг да около, а перейдём к самому главному, то есть рассмотрим подробнее, как всё это реализуется в Erlang. worker возвращает child_spec, а supervise — такой кортеж: {:ok, { {strategy, max_retries, max_seconds}, child_specs} }.

Во-вторых, :supervisor.init вызывает process_flag, после чего происходит перехват сигнала выхода. Это — ключевой момент, демонстрирующий, как и когда супервизор принимает решение о перезапуске процесса. Если коротко, то перед уничтожением процесс посылает сигнал выхода всем связанным с ним процессам. Вызов process_flag перехватывает сигнал и вместо этого посылает процессу сообщение {’EXIT’, from_pid, reason}. Далее будет показано, каким образом процесс-супервизор использует значение from_pid для выявления уничтоженного процесса и его перезапуска.

Итак, мы рассмотрели функцию :supervisor.init и узнали про перехват сигналов выхода. Теперь перейдём к инициализации состояния и дочерних процессов. Так как в данном примере супервизоры :simple_one_for_one не используются, то обратим внимание лишь на инициализацию дочерних процессов init_children.

init_children(State, StartSpec) ->
    SupName = State#state.name,
    case check_startspec(StartSpec) of
        {ok, Children} ->
            case start_children(Children, SupName) of
                {ok, NChildren} ->
                    {ok, State#state{children = NChildren}};
                {error, NChildren, Reason} ->
                    _ = terminate_children(NChildren, SupName),
                    {stop, {shutdown, Reason}}
            end;
        Error ->
            {stop, {start_spec, Error}}
end.

Небольшое отступление: State#state.name обращается к переменной State как к записи state и «вытаскивает» из неё поле name. Записи представляют собой более или менее структурированный тип данных, так как они хранятся в виде кортежей типа {:state, «josh», [1, 2, 3]} (подобно типу enum в других языках). Записи — это лишь способ отвязать позицию поля от его значения. Так выглядит исходный код записи state, прописанный в начале:

-record(state, {name,
                strategy               :: strategy() | 'undefined',
                children = []          :: [child_rec()],
                dynamics               :: {'dict', ?DICT(pid(), list())}
                                        | {'set', ?SET(pid())}
                                        | 'undefined',
                intensity              :: non_neg_integer() | 'undefined',
                period                 :: pos_integer() | 'undefined',
                restarts = [],
                dynamic_restarts = 0   :: non_neg_integer(),
                module,
                args}).

Можно видеть, что запись state содержит поле name, а выражение SupName = State#state.name просто обращается к кортежу State как к записи state, вытаскивая и сохраняя в SupName поле, связанное с name.

Что касается функции check_startspec, то она проверяет данные и помещает полученные от MyApp.Supervisor.init спецификации в запись (исходник).

Суть функции :supervisor.init_children заключается в вызове функции start_children:

start_children(Children, SupName) -> start_children(Children, [], SupName).

start_children([Child|Chs], NChildren, SupName) ->
    case do_start_child(SupName, Child) of
        {ok, undefined} when Child#child.restart_type =:= temporary ->
            start_children(Chs, NChildren, SupName);
        {ok, Pid} ->
            start_children(Chs, [Child#child{pid = Pid}|NChildren], SupName);
        {ok, Pid, _Extra} ->
            start_children(Chs, [Child#child{pid = Pid}|NChildren], SupName);
        {error, Reason} ->
            report_error(start_error, Reason, Child, SupName),
            {error, lists:reverse(Chs) ++ [Child | NChildren],
            {failed_to_start_child,Child#child.name,Reason}}
    end;

start_children([], NChildren, _SupName) ->
    {ok, NChildren}.

Наблюдается небольшая рекурсия: берётся каждый дочерний процесс и вызывается функция do_start_child:

do_start_child(SupName, Child) ->
    #child{mfargs = {M, F, Args}} = Child,
    case catch apply(M, F, Args) of
        {ok, Pid} when is_pid(Pid) ->
            NChild = Child#child{pid = Pid},
            report_progress(NChild, SupName),
            {ok, Pid};
        {ok, Pid, Extra} when is_pid(Pid) ->
            NChild = Child#child{pid = Pid},
            report_progress(NChild, SupName),
            {ok, Pid, Extra};
        ignore ->
            {ok, undefined};
        {error, What} -> {error, What};
        What -> {error, What}
end.

apply в Erlang — метод динамического вызова функций (подобно send в Ruby или apply/call в Javascript). Он динамически обращается к функции обратного вызова, определённой в Supervisor.Spec.worker: по соглашению вызывается функция start_link из модуля worker. И, наконец, он вызывает функцию report_progress и передаёт информационное событие о запуске супервизором нового процесса в менеджер событий Erlang под названием error_logger (не самое удачное имя).

Полдела сделано. Идём дальше.

Резюмируя вышеизложенное: мы рассмотрели, как запускаются супервизоры, перехватываются сигналы выхода и создаются дочерние процессы. Но как же перезапускаются дочерние процессы? Помните, как process_flag отлавливает сигналы выхода, превращая их в кортежи {’EXIT’, from_pid, reason}? А то, что супервизоры построены на основе библиотеки gen_server? gen_server обрабатывает все сообщения, кроме типов call/cast с помощью функции handle_info (подробнее о ней здесь). Именно так супервизор обрабатывает сигналы выхода из дочерних процессов.

handle_info({'EXIT', Pid, Reason}, State) ->
    case restart_child(Pid, Reason, State) of
        {ok, State1} ->
            {noreply, State1};
        {shutdown, State1} ->
            {stop, shutdown, State1}
    end;

Отсюда видно, как он создаёт сообщение, содержащее идентификатор дочернего процесса (pid) и причину его завершения, и передаёт эти данные в метод restart_child! Победа! Функции do_start_child имеет много общего с ранее рассмотренной функцией restart_child, поэтому оставим вопрос её реализации на самостоятельное изучение. Если вам интересно узнать, как супервизоры реализуют стратегии своего завершения, взгляните на функцию :gen_server.stop, которая делегирует вызовы функции обратного вызова под названием terminate.

Подведём итоги. Elixir использует общепринятую модель построения супервизоров, которая базируется на интерфейсе модуля :supervisor из Erlang, построенного на библиотеке :gen_server. Настройки из Elixir передаются в модуль :supervisor, который на их основе запускает дочерние процессы. Супервизор управляет дочерними процессами, перехватывая их сигналы выхода, и превращает эти сигналы в сообщения. В нём реализована функция обратного вызова handle_info, принимающая сообщения о выходе и перезапускающая необходимый воркер. И последнее, что стоит упомянуть, — отчёты выводятся в менеджер событий Erlang :error_logger.

© 2020 / Россия Любые мысли и вопросы пишите на elixir@wunsh.ru.