Supervisor

Немного теории

На прошлом уроке мы выяснили, что стратегия эрланг -- разделить потоки на рабочие (worker) и системные (supervisor), и поручить системным потокам обрабатывать падения рабочих потоков.

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

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

supervision_tree

На картинке нарисовано такое дерево. Узлы в нем -- супервизоры, а листья -- рабочие процессы. Падение любого потока и любой части системы не останется незамеченным.

Дерево супервизоров разворачивается на старте системы. Каждый супервизор отвечает за то, чтобы запустить своих потомков, наблюдать за их состоянием, рестартовать и корректно завершать, если надо.

В эрланг есть стандартная реализация супервизора. Он работает аналогично gen_server. Вы должны написать кастомный модуль, реализующий поведение supervisor, куда входит одна функция обратного вызова init/1. С одной стороны это просто -- всего один callback. С другой стороны init должен вернуть довольно сложную структуру данных, с которой нужно как следует разобраться.

Запуск супервизора

Запуск supervisor похож на запуск gen_server. Вот картинка, аналогичная той, что мы видели в 10-м уроке:

supervision_tree

Напомню, что два левых квадрата (верхний и нижний), соответствуют нашему модулю. Два правых квадрата соответствуют коду OTP. Два верхних квадрата выполняются в потоке родителя, два нижних квадрата выполняются в потоке потомка.

Начинаем с функции start_link/0:

start_link() ->
    supervisor:start_link({local, ?MODULE}, ?MODULE, []).

Здесь мы просим supervisor запустить новый поток.

Первый аргумент, {local, ?MODULE} -- это имя, под которым нужно зарегистрировать поток. Есть вариант supervisor:start_link/2 на случай, если мы не хотим регистрировать поток.

Второй аргумент, ?MODULE -- это имя модуля, callback-функции которого будет вызывать supervisor.

Третий аргумент -- это набор параметров, которые нужны при инициализации.

Дальше происходит некая магия в недрах OTP, в результате которой создается дочерний поток, и вызывается callback init/1.

Из init/1 нужно вернуть структуру данных, содержащую всю необходимую информацию для работы супервизора.

Настройка супервизора

Разберем подробнее:

{ok, {SupervisorSpecification, ChildSpecifications}}

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

Спецификация супервизора -- это кортеж из трех значений:

{RestartStrategy, Intensity, Period}

RestartStrategy описывает политику перезапуска дочерних потоков. Есть 4 варианта стратегии:

one_for_one -- при падении одного потока перезапускается только этот поток, остальные продолжают работать.

one_for_all -- при падении одного потока перезапускаются все дочерние потоки.

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

simple_one_for_one -- это особый вариант, будет рассмотрен ниже.

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

Например, если Intensity = 10, а Period = 1000, это значит, что разрешено не более 10 рестартов за 1000 миллисекунд. Если поток падает 11-й раз, то супервизор понимает, что он не может исправить проблему. Тогда супервизор завершается сам, а проблему пытается решить его родитель -- супервизор уровнем выше.

В 18-й версии эрланг вместо кортежа:

{RestartStrategy, Intensity, Period}

используется map:

 #{strategy => one_for_one,
   intensity => 10,
   period => 1000

Но и кортеж поддерживается для обратной совместимости.

child specifications

Теперь разберем, как описываются дочерние потоки. Каждый из них описывается кортежем из 6-ти элементов:

{ChildID, Start, Restart, Shutdown, Type, Modules}.

ChildID -- идентификатор потока. Тут может быть любое значение. Супервизор не использует Pid дочернего потока, потому что Pid будет меняться при рестарте.

Start -- кортеж {Module, Function, Args} описывающий, с какой функции стартует новый поток.

Restart -- атом, указывающий необходимость рестарта дочернего потока. Возможны 3 варианта:

  • permanent -- поток нужно рестартовать всегда.
  • transient -- поток нужно рестартовать, если он завершился аварийно. При нормальном завершении рестартовать не нужно.
  • temporary -- поток не нужно рестартовать.

Shutdown -- определяет, сколько времени супервизор дает дочернему потоку на нормальное завершение работы.

Когда супервизор хочет остановить дочерний поток, он шлет сигнал shutdown, и ждет заданное время. Если за это время дочерний поток не завершился, супервизор останавливает его сигналом kill.

Shutdown может быть указан как время в миллисекунах, либо атомами:

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

Обычно для worker-потоков указывают время в миллисекундах, а для supervisor-потоков указывают infinity.

Type -- тип дочернего потока. Может быть либо worker, либо supervisor.

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

Пример child specitication:

{some_worker,
 {some_worker, start_link, []},
 permanent,
 2000,
 worker,
 [some_worker]},

В 18-й версии эрланг используется map:

 #{id => some_worker,
   start => {some_worker, start_link, []},
   restart => permanent,
   shutdown => 2000,
   type => worker,
   modules => [some_worker]
  }

Пример функции init:

init(_Args) ->
    RestartStrategy = one_for_one, % one_for_one | one_for_all | rest_for_one
    Intensity = 10, %% max restarts
    Period = 60, %% in period of time
    SupervisorSpecification = {RestartStrategy, Intensity, Period},

    Restart = permanent, % permanent | transient | temporary
    Shutdown = 2000, % milliseconds | brutal_kill | infinity

    ChildSpecifications =
        [
         {some_worker,
          {some_worker, start_link, []},
          Restart,
          Shutdown,
          worker,
          [some_worker]},
         {other_worker,
          {other_worker, start_link, []},
          Restart,
          Shutdown,
          worker,
          [other_worker]}
        ],
    {ok, {SupervisorSpecification, ChildSpecifications}}.

То же самое для 18-й версии эрланг:

init(_Args) ->
    SupervisorSpecification = #{
        strategy => one_for_one,
        intensity => 10,
        period => 60},

    ChildSpecifications =
        [#{id => some_worker,
           start => {some_worker, start_link, []},
           restart => permanent,
           shutdown => 2000,
           type => worker,
           modules => [some_worker]},
         #{id => other_worker,
           start => {other_worker, start_link, []},
           restart => permanent,
           shutdown => 2000,
           type => worker,
           modules => [other_worker]}
        ],
    {ok, {SupervisorSpecification, ChildSpecifications}}.

С map это все выглядит понятнее и лаконичнее.

Динамическое создание воркеров

Дерево супервизоров не обязательно должно быть статичным. При необходимости его можно менять: добавлять/удалять новые рабочие потоки, и даже новые ветки супервизоров. Есть два способа это сделать: либо вызовами start_child либо использованием simple_one_for_one стратегии.

start_child

4 функции супервизора позволяют добавлять и убирать дочерние потоки.

start_child/2

Функция позволяет добавить новый дочерний поток, не описанный в init. Она принимает 2 аргумента: имя/pid супервизора, и спецификацию дочернего потока.

supervisor:start_child(
    MySupervisor,
    {some_worker,
     {some_worker, start_link, []},
      Restart,
      Shutdown,
      worker,
      [some_worker]})

terminate_child/2

Функция позволяет остановить работающий дочерний поток. Она принимает 2 аргумента: имя/pid супервизора, и Id дочернего потока.

supervisor:terminate_child(MySupervisor, some_worker)

После того, как поток остановлен, его можно либо рестартовать вызовом restart_child/2, либо вообще убрать его спецификацию из списка дочерних потоков вызовом delete_child/2.

simple_one_for_one стратегия

Использование simple_one_for_one стратегии -- это особый случай, когда нам нужно иметь большое количество потоков: десятки и сотни.

При использовании этой стратегии супервизор может иметь потомков только одного типа. И, соответственно, должен указать только одну child specitication.

init(_Args) ->
    SupervisorSpecification = {simple_one_for_one, 10, 60},
    ChildSpecifications =
        [
         {some_worker,
          {some_worker, start_link, [A, B, C]},
          transient,
          2000,
          worker,
          [some_worker]}
        ],
    {ok, {SupervisorSpecification, ChildSpecifications}}.

Дочерние потоки нужно запускать явно, вызовом start_child/2. Причем, тут меняется роль второго аргумента. Это теперь не child specification, а дополнительные аргументы дочернему потоку.

supervisor:start_child(MySupervisor, [D, E, F]).

И дочерний поток в своей функции start_link получит аргументы и из child specification, и из start_child.

-module(some_worker).

start_link(A, B, C, D, E, F) ->
    ...

Остановка супервизора

В АПИ супервизора не предусмотрено функции для его остановки. Он останавливается либо по своей стратегии, либо по сигналу родителя.

При этом он завершает все свои дочерние потоки в очередности, обратной их запуску, затем останавливается сам.