Процеси и OTP : Supervisor


Продължаваме с друго поведение идващо от OTP - supervisor и неговата Elixir-ска обвивка - Supervisor.

Какво е Supervisor?

Това е процес, чиято единствена роля е да наглежда други процеси и да се грижи за тях по някакъв начин в случай на проблем. С помощта на Supervisor-ите по лесен начин можем да изградим fault tolerant система. Казваме лесен, защото те ни предлагат изградена система от функции точно за това и идеологията около тях е лесна за възприемане. Това, което е трудно е да направим добър дизайн на такава система - къде какви Supervisor-и ще ни трябват.

Често сме споменавали за девиза “Let it crash!”. Тази мантра се базира върху следното: Важно е програмата да върви, ако части от нея се сринат, не е проблем - нещо наблюдава тези части, нещо ще се погрижи те да бъдат възстановени. Това нещо е Supervisor.

За да имплементираме Supervisor, просто трябва да направим следното:

defmodule SomeSupervisor do
  use Supervisor
end

Подобно на GenServer, Supervisor е поведение, за което callback функциите имат имплементации по подразбиране.

В модула Supervisor има код, който може:

  • Да инициализира и стартира Supervisor процес.
  • Да осигури това, че Supervisor процесът прихваща EXIT сигнали.
  • Да стартира определен списък от процеси-деца, зададени на Supervisor-а и да ги link-не към него.

Поведението Supervisor дава възможност:

  • Ако някой от процесите-деца ‘умре’ непредвидено, Supervisor-ът ще получи сигнал и ще предприеме конкретно действие, зададено при създаването му.
  • Ако Supervisor-ът бъде терминиран, всичките му процеси-деца биват ‘убити’.

Отново ще разгледаме пример от Blogit и след това ще разгледаме всичко, което Supervisor предлага.

Пример : Supervisor и Blogit - Component Supervisor

Нека си припомним за GenServer процеса Blogit.Component.Posts от статията за GenServer. Той държеше състояние - всичките публикации и се грижеше да ги връща под някаква форма на клиентите си. Този процес-компонент на Blogit не е единствения, в момента на писане на тази статия те са три:

  1. Компонент за публикации
  2. Компонент за публикации, удобен за заявки по година и месец
  3. Компонент за конфигурация на блога.

Тези три GenServer процеса, които наричаме компоненти не зависят един от друг. Ако един от тях ‘умре’ поради някаква причина, другите могат да продължат да сервират заявки. При тази ситуация обаче ще загубим дадена функционалност на блога, да речем, ако конфигурацията падне ще спрем да виждаме заглавието и стиловете на блога ще се променят. Именно затова има процес Suervisor, който се грижи да рестартира компонент, който ‘умре’:

defmodule Blogit.Components.Supervisor do
  use Supervisor

  alias Blogit.Components.Posts
  alias Blogit.Components.PostsByDate
  alias Blogit.Components.Configuration

  def start_link() do
    Supervisor.start_link(__MODULE__, nil, name: __MODULE__)
  end

  def init(_) do
    children = [
      worker(Posts, []),
      worker(Configuration, []),
      worker(PostsByDate, []),
    ]

    opts = [strategy: :one_for_one]
    supervise(children, opts)
  end
end

Всъщност този модул дефинира само две функции - една за инициализация и една за стартирането на Supervisor процеса.

Подобно на GenServer, Supervisor може да се стартира със start_link/3, който приема същите аргументи - модул имплементиращ поведението, аргументи за init/1 и опции.

За разлика от GenServer, Supervisor поведението идва с една единствена callback функция - init/1. Тя връща много специфичен резултат, за който ще поговорим по-късно. Този резултат лесно се генерира с функцията Supervisor.Spec.supervise/2. Редът use Supervisor ни предоставя всички функции от Supervisor.Spec.

Тази функция, която генерира резултата от init/1, Supervisor.Spec.supervise/2 приема два аргумента - списък от спецификации и keyword list от опции.

В нашия пример тези спецификации, често наричани деца, защото точно те описват процесите-деца на Supervisor-а са три. За всеки от компонентите има спецификация създадена чрез Supervisor.Spec.worker/3. Опциите пък включват единствено стратегия със стойност :one_for_one.

Какво означава всичко това?

Всъщност, когато Supervisor процес стартира, ще се изпълни init/1 функцията, която можем да дефинираме. Тя ще върне резултат който съдържа спецификации описващи децата-процеси. Тези спецификации се използват да се стартират тези деца-процеси и да се свържат със Supervisor-a.

Първият параметър на worker/3 е модулът, който обикновено е GenServer имплементация, а вторият са аргументите, които ще му се подадат при стартиране. Това означава че Blogit.Component.Supervisor-ът има грижата да стартира трите компонента и да направи връзки към тях.

Опциите се използват за да се зададе поведение на Supervisor-а, когато някой от процесите-деца умре. Стратегията, която задаваме в примера означава, че ако даден процес умре, той ще бъде рестартиран, без да се прави нищо с другите процеси-деца.

Следователно Blogit.Component.Supervisor стартира трите си GenServer компонента и ако някой от тях ‘умре’ го рестартира. Тъй като всеки такъв компонент знае как да си вземе състоянието, това рестартиране не е проблем и ще възстанови системата.

Ето как Blogit.Components.Posts си вземаше състоянието:

def init(_) do
  send(self(), :init_posts)
  {:ok, nil}
end

def handle_info(:init_posts, nil) do
  posts = GenServer.call(Blogit.Server, :get_posts)
  {:noreply, posts}
end

Всъщност тук се прави call към друг GenServer процес, наречен Blogit.Server, който се наблюдава от друг Supervisor, който пък наблюдава Blogit.Components.Supervisor. Да - Supervisor може да наблюдава Supervisor. Ще научим повече за това малко по-късно. Нека сега да разгледаме функциите и опциите, които идват със Supervisor.Spec.

Опциите на Supervisor.Spec.supervise/2

strategy

Стратегията няма стойност по подразбиране и е задължителна опция. Има четири възможни стойности:

  1. :one_for_one : Ако наблюдаван процес ‘умре’, той се рестартира. Другите не се влияят. Тази стратегия е добра за процеси които нямат връзки и комуникация помежду си, които могат да загубят състоянието си без това да повлияе на другите процеси-деца на Supervisor-а им.
  2. :one_for_all : Ако наблюдаван процес ‘умре’, всички наблюдавани процеси се ‘убиват’ и след това всички се стартират. Обикновено тази стратегия се използва за процеси, чиито състояния зависят доста едно от друго и ако само един от тях бъде рестартиран ще се наруши общата логика на програмата.
  3. :rest_for_one : Ако наблюдаван процес ‘умре’, всички наблюдавани процеси стартирани СЛЕД недго също ‘умират’. Всички тези процеси, включително процесът-причина се рестартират по първоначалния си стартов ред. Тази стратегия е подходяща за ситуация като : процес ‘A’ няма зависимости, но процес ‘Б’ зависи от ‘А’, а има и процес ‘В’, който зависи както от ‘Б’, така и транзитивно от ‘А’.
  4. :simple_one_for_one : При тази стратегия даден Supervisor има право само на един тип процеси-деца. Обикновено стартира без процеси-деца, но знае как да си ги произведе. Процесите-деца се рестартират както при :one_for_one, но при много деца Supervisor-и с тази стратегия са по-бързи - не знаят реда на стартиране на процесите-си-деца. Подходяща е за ситуации е които искаме да управляваме pool-ове от процеси да речем.

Всъщност при simple_one_for_one стратегията различни функции на Supervisor работят малко по-различно. Повече за тази стратегия, прочетете в документацията.

max_restarts

С опцията :max_restarts задаваме колко пъти може един процес да бъде рестартиран в даден период от време. По подразбиране е 3.

Да речем ако процес ‘умира’ защото е станала някаква грешка в remote service, от който той зависи, колкото и пъти Supervisor-ът да го рестартира, той ще се чупи и след даден брой рестартирания, Supervisor-ът е добре да се откаже. Това е този брой. Но тази опция е тясно свързана с интервал от време. Да речем ако за 5 секунди пробваме да рестартираме процеса 3 пъти, трябва да се откажем.

max_seconds

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

Какво е Supervisor.Spec.spec

Един Supervisor знае как да рестартира всеки от процесите си. Това е така защото за всеки от тях (или за всички ако е simple_one_for_one) държи спецификация. Една такава спецификация съдържа модула дефиниращ процеса и параметрите с които трябва да се стартира, както и няколко специални настройки.

Функцията Supervisor.Spec.supervise/2 взима като първи аргумент списък с елементи от типа Supervisor.Spec.spec. Този тип представлява точно тази спецификация на процес-дете. Както видяхме функцията Supervisor.Spec.worker/3 помага за създаването спецификации. Нека разгледаме спецификацията на този тип:

@type spec :: {
  child_id  :: term,
  start_fun :: {module, atom, [term]},
  restart   :: :permanent | :transient | :temporary,
  shutdown  :: :brutal_kill | :infinity | non_neg_integer,
  worker    :: :worker | :supervisor,
  modules   :: :dynamic | [module]
}

Функцията Supervisor.Spec.worker/3 взима модул за свой първи аргумент. Този модул обикновено имплементира GenServer. Вторият ѝ аргумент е списък от параметри за стартиране/рестартиране. Третият е опции. Тези опции са:

[restart: restart, shutdown: shutdown, id: term, function: atom, modules: modules]

Както виждате, точно те се използват за изграждането на спецификацията на процеса-дете. Нека сега се върнем към нея и да разгледаме всяко от полетата ѝ.

child_id

Това е стойност която се ползва от Supervisor-ите вътрешно. Рядко ще я използваме за нещо, макар че можем да я подадем като опция на worker/3 с [id: <id>]. Може да се ползва за debugging да речем.

start_fun

Тази стойност е tupple съдържащ MFA. Използва се за стартиране на процеса-дете.

Модулът, който съдържа логиката на процеса се подава като първи аргумент на worker/3.

Функцията за стартиране на процеса се подава от опциите на worker/3 чрез [function: <atom-representing-public-function-from-the-module>]. По подразбиране е :start_link. Задължително тази функция трябва да стартира процес и да го свързва със процеса, който я е извикал.

Аргументите ще се подадат на зададената като атом функция при старт. Тези аргументи се подават във формата на списък на worker/3.

restart

Атом, който указва кога и дали ‘терминиран’ процес-дете ще се рестартира. Възможните стойности са:

  • :permanent - Процесът винаги се рестартира от Supervisor-а. Това е и стойността по подразбиране на restart. Може да се зададе друга от опциите на worker/3 с [restart: :permanent | :transient | :temporary]. Този начин на рестартиране е подходящ за дълго-живеещи процеси, които не трябва да ‘умират’.
  • :transient - С тази опция, даденият процес-дете няма да бъде рестартиран ако излезе нормално - с exit(:normal) или просто завърши изпълнение. Ако обаче излезе с друга причина (exit(:reason)), ще бъде рестартиран. Тази опция е подходяща за процеси, които трябва да свършат някаква задача и ако го направят без проблеми няма да има нужда повече от тях. Ако има проблем, обаче, ще пробват отново и отново.
  • :temporary - Процесът-дете няма да бъде рестартиран ако ‘умре’. Няма значение дали е излязъл с грешка или не. Подходяща е за кратко-живеещи процеси за които е очаквано, че могат да ‘умрат’ с грешка и няма много код зависещ от тях.

Начинът на рестартиране може да е различен за всеки процес-дете. Той и зададената стратегия на Supervisor-а си влияят. Да речем процес с temporary restart няма да бъде рестартиран даже, когато стратегията е one_for_all или rest_for_one. Също така, ако temporary процес ‘умре’, той няма да задейства one_for_all стратегия, но пък щя бъде рестартиран ако permanent процес, под същия Supervisor ‘умре’ първи.

shutdown

Когато Supervisor трябва да убие някои или всички свои процеси-деца, той извиква Process.exit(child_pid, :shutdown) за всеки от тях. Стойността зададена като shutdown се използва за timeout след като това се случи. По подразбиране е 5000 или пет секунди.

Когато процес получи :shutdown, ако е Genserver, ще му се извика terminate функцията. Изчистването на някакви ресурси може да отнеме време. Ако това време е повече от зададеното в shutdown, Supervisor-ът ще изпрати нов EXIT сигнал с Process.exit(child_pid, :kill).

Всъщност ако зададем стойност :brutal_kill за shutdown, Supervisor-ът винаги ще терминира даденият процес направо с Process.exit(child_pid, :kill). Можем да зададем и :infinity за да оставим процеса да си излезе спокойно.

Когато Supervisor трябва да завърши изпълнението си, той изпълнява логиката за shutdown за всяко от децата си. Ако стратегията му е simple_one_for_one, обаче, не го прави, а ги оставя сами да си излязат.

worker

Това свойство определя дали процесът дете е worker процес или Supervisor. Всъщност има функция Supervisor.Spec.supervisor/3, която генерира спецификация по същият начин като Supervisor.Spec.worker/3, но задава тази worker част от спецификацията да е supervisor.

modules

Трябва да е списък от един елемент - модул. Това е модулът съдържащ callback функциите на GenServer имплементация или на Supervisor имплементация. По подразбиране е модулът подаден като първи елемент на worker/3 или supervisor/3. Но е възможно стартиращата функция да е в друг модул и да дадем модула-имплементация тук. Може и да има стойност :dynamic но това е свързано със deprecated поведението GenEvent.

Supervisor - друг начин за направа

Много е просто да си конфигурираме и ‘пуснем’ Supervisor без да създаваме специален модул за него с use Supervisor. Просто си дефинираме функция подобна на тази:

def start_supervising! do
  import Supervisor.Spec, warn: false

  children = [
    worker(SomeModule, [])
    worker(SomeOtherModule, [arg1, arg2])
    supervisor(SomeModuleUsingSupervisor, [])
  ]

  options = [strategy: one_for_one]

  supervise(children, options)
end

И това е то. Имаме си Supervisor.

Функциите на Supervisor

Досега си говорихме за статично наблюдение на процеси със Supervisor-и. Стартираме Supervisor със стратегия, опции и спецификации и той използва тези спецификации, стратегия и опции да стартира, а след това, ако се наложи да рестартира процесите си.

В модула Supervisor има функции с които можем да добавяме нови спецификации, да трием спецификации и да спираме процеси ръчно. Нека ги разгледаме.

Supervisor.start_child/2

Динамично добавя нова спецификация към Supervisor и стартира процес за нея. Първият аргумент е pid на Supervisor процес а вторият - валидна Supervisor.Spec.spec.

Ако стратегията е simple_one_for_one вместо спецификация подаваме списък от аргументи за стартиране на процес по вече описаната спецификация при създаването на Supervisor-а. Напомняме че при тази стратегия имаме само един тип процеси-деца.

Използва се в различни случаи. Пример е, да речем при Blogit, Blogit.Components.Supervisor модулът реално не изглежда като в примера по-горе, защото компонентите зависят от информация, която се взима от git repository от друг процес и не могат да стартират веднага със Supervisor-а си.

Реално Blogit.Components.Supervisor-ът изглежда така:

defmodule Blogit.Components.Supervisor do
  use Supervisor

  def start_link() do
    Supervisor.start_link(__MODULE__, nil, name: __MODULE__)
  end

  def init(_), do: supervise([], [strategy: :one_for_one])
end

Има празен списък от деца. Впоследствие, когато данните от git repository-то са готови за ползване използваме следната функция:

defp supervisor_spec(module) do
  import Supervisor.Spec, warn: false

  worker(module, [])
end

С нейна помощ си правим прости спецификации за всеки компонент. И така ги стартираме:

[
  Blogit.Components.Posts,
  Blogit.Components.Configuration,
  Blogit.Components.PostsByDate
]
|> Enum.each(fn (module) ->
  {:ok, _} =
    Supervisor.start_child(ComponentsSupervisor, supervisor_spec(module))
end)

Ако процес се стартира без проблеми, получаваме {:ok, pid}. Можем да получим и {:error, error}. За повече информация, вижте в документацията.

Supervisor.count_children/1

Тази функция връща Map, в който има различни бройки свързани с подадения Supervisor. Пример:

Blogit.Components.Supervisor |> Supervisor.count_children
# %{active: 3, specs: 3, supervisors: 0, workers: 3}
  • active - това е броят на всички активни процеси-деца, които се управляват от подадения Supervisor.
  • specs - това е броят на всички процеси-деца, няма значение дали са ‘живи’ или ‘мъртви’.
  • supervisors - броят на всички процеси-деца, които се управляват от подадения Supervisor и са Supervisor-и на свой ред. Няма значение дали са активни или не.
  • workers - това е броят на всички процеси-деца, които се управляват от подадения Supervisor и не са Supervisor-и. Няма значение дали са активни или не.

В примера трите процеса-компоненти, управлявани от Blogit.Components.Supervisor са прости GenServer процеси и са активни, затова active, spec и workers са 3.

Supervisor.which_children/1

Връща списък с информация за всичките процеси-деца на Supervisor. Пример:

Blogit.Components.Supervisor |> Supervisor.which_children
# [
#   {Blogit.Components.PostsByDate, #PID<0.13088.0>, :worker, [Blogit.Components.PostsByDate] },
#   {Blogit.Components.Configuration, #PID<0.13087.0>, :worker, [Blogit.Components.Configuration]},
#   {Blogit.Components.Posts, #PID<0.13086.0>, :worker, [Blogit.Components.Posts]}
# ]

Всяка четворка в този списък съответства на процес-дете.

  1. Това е id-то на процеса за Supervisor-а. Известно и като child_id. За именовани процеси, това е името. При simple_one_for_one стратегия е :undefined.
  2. PID-ът на процеса, може също и да е атома :restarting, ако процесът се рестартира или :undefined, ако процесът не съществува.
  3. Тип - :worker или :supervisor.
  4. Модули - както в спецификацията. Обикновено списък от един модул - модулът с callback функциите от Genserver / Supervisor поведенията.

В примера, процесите са именовани и активни, всички са :worker тип.

Supervisor.terminate_child/2

Може да ‘убие’ процес-дете на Supervisor, подаден като първи аргумент. Ако стратегията е simple_one_for_one, процесът се подава като pid, при другите стратегии като child_id.

Нека да използваме тази функция за да ‘убием’ процесът Blogit.Components.PostsByDate:

Blogit.Components.Supervisor |> Supervisor.terminate_child(Blogit.Components.PostsByDate)
# :ok

Blogit.Components.Supervisor |> Supervisor.count_children
# %{active: 2, specs: 3, supervisors: 0, workers: 3}

Тази функция терминира един от worker-ите и той не се рестартира. Supervisor-ът си пази спецификациите, което означава че процесът може да бъде рестартиран. Това може да стане или ръчно или чрез стратегията. Ако стратегията е one_for_all, например, ако някой друг процес бъде убит чрез изпращане на EXIT сигнал, всички, включително и терминирания от тази функция ще се рестартират.

Ръчното рестартиране става със Supervisor.restart_child/2.

Supervisor.restart_child/2

Рестартира процес-дете, чиято спецификация се пази в подаденият като първи аргумент Supervisor. Процесът се идентифицира по child_id. От това значи, че при simple_one_to_one стратегията, тази функция не работи. Както и при temporary процеси, чиито child_id-та се губят.

Нека рестартираме терминирания Blogit.Components.PostsByDate:

Blogit.Components.Supervisor |> Supervisor.restart_child(Blogit.Components.PostsByDate)
# {:ok, #PID<0.15486.0>}
Blogit.Components.Supervisor |> Supervisor.count_children
# %{active: 3, specs: 3, supervisors: 0, workers: 3}

Всичко е както беше преди терминирането му.

Supervisor.delete_child/2

Изтрива спецификация за дадено child_id. Не работи за simple_one_to_one стратегия. За да проработи тази функция, процесът трябва да е спрян с Supervisor.terminate_child/2.

Supervisor.stop/3

Спира подаденият като първи аргумент Supervisor с подадена като втори аргумент причина и изчаква с timeout - трети аргумент. Това изчакване по подразбиране е :infinity, а причината - :normal. Когато Supervisor-ът е подаден на тази функция, той спира и всеки от процесите си. Всеки процес е спрян по shutdown начина, описан в спецификацията му.

Разбира се ако даденият Supervisor на свой ред се управлява от друг Supervisor, той може да бъде рестартиран, което ще рестартира и децата му в зависимост от стратегията му. Нека разгледаме Supervisor-и, управлявани от Supervisor-и.

Дърво от Supervisor-и

Обикновено в една програма ползваща supervision, Supervisor-ите образуват дърво. Имаме Supervisor-корен с дадена стратегия, който има свои процеси-деца - worker-и и други Supervisor-и. Тези други Supervisor-и от своя страна си имат свои стратегии и свои деца, които отново могат да бъдат worker-и и други Supervisor-и.

Можем да приемем worker-ите за листа в това дърво. Нека разгледаме още един пример от Blogit. Няколко пъти споменахме процеса Blogit.Server. Той е този, който управлява суровото състояние на блога, той е този който създава компонентите. Ако той ‘умре’, всичко друго трябва да се рестартира с него.

Именно затова имаме Blogit.Supervisor, коренът на малкото supervison дърво на Blogit. Ето го кода му:

defmodule Blogit.Supervisor do
  use Supervisor

  def start_link(repository_provider) do
    Supervisor.start_link(__MODULE__, repository_provider, name: __MODULE__)
  end

  def init(repository_provider) do
    children = [
      supervisor(Blogit.Components.Supervisor, []),
      supervisor(Task.Supervisor, [[name: :tasks_supervisor]]),
      worker(Blogit.Server, [repository_provider])
    ]

    opts = [strategy: :one_for_all]

    supervise(children, opts)
  end
end

Както виждате той има три процеси деца:

  • Blogit.Components.Supervisor, който ние разгледахме и опростихме в тази статия. Той започва без спецификации на процеси-деца, но по-късно има три worker процеса.
  • Blogit.Server, който при инициализация прочита състоянието на блога от git rpository-то, зададено на Blogit. След като има състоянието, задава три спецификации на Blogit.Components.Supervisor и ги стартира.
  • :tasks_supervisor, специален Supervisor със стратегия simple_one_for_one, който идва с Elixir и е използван тук за създаването на процеси на даден интервал, които проверяват за промени в git repository-то.

Ако Blogit.Server ‘умре’, както казахме, най-добре е всичко да се рестартира, компонентите на Blogit.Components.Supervisor са добавени като спецификации от него и задачите на :tasks_supervisor-а нотифицират него. Ако той ‘умре’ и се рестартира, PID-ът ще е друг и програмата ни няма да работи правилно.

Ако Blogit.Components.Supervisor умре, би се рестартирал без компонентите си, което ще счупи програмата, така че по-добре всичко да се рестартира и да се инициализира по същият начин като при пускане на програмата.

При :tasks_supervisor рестартирането на цялата система не е нужно, но задачите които се създават от него работят с Blogit.Server и ще е по-чисто ако просто цялата система се рестартира при грешка в него.

От всичко това следва, че правилната стратегия за този Supervisor е :one_for_all.

В този пример имаме Supervisor със стратегия one_for_all, управляващ Supervisor-и със стратегии one_for_one и simple_one_for_one, което е интересно.

Заключение

В тази статия се запознахме със Supervisor-ите и какво и как те правят за да имаме системи, които при грешка знаят как да се съвземат. Следващата статия ще разгледа какво представлява една OTP програма като цяло.