Статьи

Супервайзеры в Эликсире

В моей предыдущей статье мы говорили об Open Telecom Platform (OTP) и, более конкретно, об абстракции GenServer, которая упрощает работу с серверными процессами. GenServer, как вы, вероятно, помните, является поведением — чтобы использовать его, вам нужно определить специальный модуль обратного вызова, который удовлетворяет условиям контракта, как диктуется этим поведением.

Однако мы не обсудили обработку ошибок . Я имею в виду, что любая система может в конечном итоге испытывать ошибки, и важно правильно их исправлять. Вы можете обратиться к статье Как обрабатывать исключения в Elixir, чтобы узнать о блоке try/rescue , raise и некоторых других общих решениях. Эти решения очень похожи на те, которые можно найти в других популярных языках программирования, таких как JavaScript или Ruby.

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

Поэтому сегодня мы увидим супервайзеров в действии!

В демонстрационных целях мы собираемся использовать пример кода из моей предыдущей статьи о GenServer. Этот модуль называется CalcServer , и он позволяет нам выполнять различные вычисления и сохранять результат.

Итак, во-первых, создайте новый проект с помощью команды mix new calc_server . Затем определите модуль, GenServer и предоставьте ярлык start/1 :

1
2
3
4
5
6
7
8
9
# lib/calc_server.ex
 
defmodule CalcServer do
  use GenServer
 
  def start(initial_value) do
    GenServer.start(__MODULE__, initial_value, name: __MODULE__)
  end
end

Затем init/1 обратный вызов init/1 который будет запущен сразу после запуска сервера. Он принимает начальное значение и использует выражение охраны, чтобы проверить, является ли это число. Если нет, сервер завершает работу:

1
2
3
4
5
6
7
def init(initial_value) when is_number(initial_value) do
    {:ok, initial_value}
end
 
def init(_) do
    {:stop, «The value must be an integer!»}
end

Теперь напишите код интерфейсных функций для выполнения сложения, деления, умножения, вычисления квадратного корня и извлечения результата (конечно, при необходимости вы можете добавить больше математических операций):

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
def sqrt do
   GenServer.cast(__MODULE__, :sqrt)
 end
 
 def add(number) do
   GenServer.cast(__MODULE__, {:add, number})
 end
 
 def multiply(number) do
   GenServer.cast(__MODULE__, {:multiply, number})
 end
 
 def div(number) do
   GenServer.cast(__MODULE__, {:div, number})
 end
 
 def result do
   GenServer.call(__MODULE__, :result)
 end

Большинство из этих функций обрабатываются асинхронно , что означает, что мы не ждем их завершения. Последняя функция является синхронной, потому что мы на самом деле хотим дождаться получения результата. Поэтому добавьте обратные вызовы handle_call и handle_cast :

01
02
03
04
05
06
07
08
09
10
11
12
13
def handle_call(:result, _, state) do
   {:reply, state, state}
 end
 
 def handle_cast(operation, state) do
   case operation do
     :sqrt -> {:noreply, :math.sqrt(state)}
     {:multiply, multiplier} -> {:noreply, state * multiplier}
     {:div, number} -> {:noreply, state / number}
     {:add, number} -> {:noreply, state + number}
     _ -> {:stop, «Not implemented», state}
   end
 end

Также укажите, что делать, если сервер завершен (мы играем здесь в Captain Obvious):

1
2
3
def terminate(_reason, _state) do
   IO.puts «The server terminated»
 end

Теперь программа может быть скомпилирована с использованием iex -S mix и использована следующим образом:

1
2
3
4
5
CalcServer.start(6.1)
CalcServer.sqrt
CalcServer.multiply(2)
CalcServer.result |> IO.puts
# => 4.9396356140913875

Проблема в том, что сервер вылетает при возникновении ошибки. Например, попробуйте разделить на ноль:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
CalcServer.start(6.1)
CalcServer.div(0)
# [error] GenServer CalcServer terminating
# ** (ArithmeticError) bad argument in arithmetic expression
# (calc_server) lib/calc_server.ex:44: CalcServer.handle_cast/2
# (stdlib) gen_server.erl:601: :gen_server.try_dispatch/4
# (stdlib) gen_server.erl:667: :gen_server.handle_msg/5
# (stdlib) proc_lib.erl:247: :proc_lib.init_p_do_apply/3
# Last message: {:»$gen_cast», {:div, 0}}
# State: 6.1
CalcServer.result |> IO.puts
# ** (exit) exited in: GenServer.call(CalcServer, :result, 5000)
# ** (EXIT) no process: the process is not alive or there’s no process currently associated with the given name, possibly because its application isn’t started
# (elixir) lib/gen_server.ex:729: GenServer.call/3

Таким образом, процесс завершен и больше не может использоваться. Это действительно плохо, но мы исправим это очень скоро!

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

Многие языки программирования используют только try и catch (или аналогичные конструкции), что является более защитным стилем программирования. В основном мы стараемся предвидеть все возможные проблемы и предоставить способ их преодоления.

С супервизорами все по-другому: если происходит сбой процесса, он падает. Но супервайзер, как храбрый боевой медик, поможет выздороветь падшему процессу. Это может звучать немного странно, но на самом деле это очень разумная логика. Более того, вы даже можете создавать деревья контроля и таким образом изолировать ошибки, предотвращая сбой всего приложения, если одна из его частей испытывает проблемы.

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

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

Прежде всего, вам нужно будет создать ссылку на вашего руководителя. Связывание также является очень важной техникой: когда два процесса связаны друг с другом, и один из них завершается, другой получает уведомление с указанием причины выхода. Если связанный процесс завершился ненормально (то есть, произошел сбой), его аналог также завершается.

Это можно продемонстрировать с помощью функций spawn / 1 и spawn_link / 1 :

1
2
3
4
5
6
spawn(fn ->
  IO.puts «hi from parent!»
  spawn_link(fn ->
    IO.puts «hi from child!»
  end)
end)

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

01
02
03
04
05
06
07
08
09
10
11
12
spawn(fn ->
  IO.puts «hi from parent!»
  spawn_link(fn ->
    IO.puts «hi from child!»
    raise(«oops.»)
  end)
  :timer.sleep(2000)
  IO.puts «unreachable!»
end)
# [error] Process #PID<0.83.0> raised an exception
# ** (RuntimeError) oops.
# gen.ex:5: anonymous fn/0 in :elixir_compiler_0.__FILE__/1

Итак, чтобы создать ссылку при использовании GenServer, просто замените свои функции start на start_link :

1
2
3
4
5
6
7
8
defmodule CalcServer do
  use GenServer
 
  def start_link(initial_value) do
    GenServer.start_link(__MODULE__, initial_value, name: __MODULE__)
  end
  # …
end

Теперь, конечно, должен быть создан руководитель. Добавьте новый файл lib / calc_supervisor.ex со следующим содержимым:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
defmodule CalcSupervisor do
  use Supervisor
 
  def start_link do
    Supervisor.start_link(__MODULE__, nil)
  end
 
  def init(_) do
    supervise(
      [ worker(CalcServer, [0]) ],
      strategy: :one_for_one
    )
  end
end

Здесь много чего происходит, поэтому давайте двигаться медленно.

start_link / 2 — это функция для запуска фактического руководителя. Обратите внимание, что соответствующий дочерний процесс также будет запущен, поэтому вам больше не придется вводить CalcServer.start_link(5) .

init / 2 — это обратный вызов, который должен присутствовать для использования поведения. Функция supervise , в основном, описывает этого руководителя. Внутри вы указываете, какие дочерние процессы контролируются. Мы, конечно, указываем рабочий процесс CalcServer . [0] здесь означает начальное состояние процесса — это то же самое, что сказать CalcServer.start_link(0) .

:one_for_one — название стратегии перезапуска процесса (напоминает известный девиз мушкетеров ). Эта стратегия диктует, что, когда дочерний процесс завершается, должен быть запущен новый. Есть несколько других доступных стратегий :

  • :one_for_all (еще больше в стиле мушкетера!) — перезапустить все процессы, если один завершается.
  • :rest_for_one процессы :rest_for_one запущенные после перезапуска завершенного. Завершенный процесс также перезапускается.
  • :simple_one_for_one — аналогично: one_for_one, но требует, чтобы в спецификации присутствовал только один дочерний процесс. Используется, когда контролируемый процесс должен динамически запускаться и останавливаться.

Итак, общая идея довольно проста:

  • Сначала запускается процесс супервизора. Обратный вызов init должен возвращать спецификацию, объясняющую, какие процессы отслеживать и как обрабатывать сбои.
  • Контролируемые дочерние процессы запускаются в соответствии со спецификацией.
  • После сбоя дочернего процесса информация отправляется руководителю благодаря установленной ссылке. Затем Supervisor следует стратегии перезапуска и выполняет необходимые действия.

Теперь вы можете снова запустить вашу программу и попытаться разделить на ноль:

1
2
3
4
5
6
CalcSupervisor.start_link
CalcServer.add(10)
CalcServer.result # => 10
CalcServer.div(0)
# => error!
CalcServer.result # => 0

Таким образом, состояние потеряно, но процесс работает, даже если произошла ошибка, что означает, что наш супервизор работает нормально!

Этот дочерний процесс довольно пуленепробиваемый, и вам буквально будет трудно его убить:

1
2
3
4
Process.whereis(CalcServer) |> Process.exit(:kill)
CalcServer.result
# => 0
# HAHAHA, I am immortal!

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

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

Сначала настройте файл mix.exs, расположенный в корне вашего проекта:

1
2
3
4
5
6
7
8
# …
 def application do
   # Specify extra applications you’ll use from Erlang/Elixir
   [
     extra_applications: [:logger],
     mod: {CalcServer, []} # <== add this line
   ]
 end

Затем Application модуль Application и предоставьте обратный вызов start / 2, который будет запускаться автоматически при запуске приложения:

1
2
3
4
5
6
7
8
9
defmodule CalcServer do
  use Application
  use GenServer
 
  def start(_type, _args) do
    CalcSupervisor.start_link
  end
  # …
end

Теперь после выполнения команды iex -S mix ваш супервизор будет запущен и работает сразу же!

Вы можете задаться вопросом, что произойдет, если процесс постоянно завершается сбоем и соответствующий супервизор перезапускает его снова. Будет ли этот цикл работать бесконечно? Ну вообще нет. По умолчанию разрешено только 3 перезапуска в течение 5 секунд — не более того. Если произойдет больше перезапусков, супервизор сдается и убивает себя и все дочерние процессы. Звучит ужасно, а?

Вы можете легко проверить это, быстро выполняя следующую строку кода снова и снова (или делая это в цикле):

1
2
3
Process.whereis(CalcServer) |> Process.exit(:kill)
# …
# ** (EXIT from #PID<0.117.0>) shutdown

Есть два варианта, которые вы можете настроить, чтобы изменить это поведение:

  • :max_restarts сколько перезапусков разрешено в течение таймфрейма
  • :max_seconds — фактический таймфрейм

Обе эти опции должны быть переданы функции supervise внутри обратного вызова init :

1
2
3
4
5
6
7
8
def init(_) do
   supervise(
     [ worker(CalcServer, [0]) ],
     max_restarts: 5,
     max_seconds: 6,
     strategy: :one_for_one
   )
 end

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

Надеюсь, вы нашли эту статью полезной и интересной. Я благодарю вас за то, что вы остались со мной и до следующего раза!