Процеси и 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 не е единствения, в момента на писане на тази статия те са три:
- Компонент за публикации
- Компонент за публикации, удобен за заявки по година и месец
- Компонент за конфигурация на блога.
Тези три 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
Стратегията няма стойност по подразбиране и е задължителна опция. Има четири възможни стойности:
:one_for_one: Ако наблюдаван процес ‘умре’, той се рестартира. Другите не се влияят. Тази стратегия е добра за процеси които нямат връзки и комуникация помежду си, които могат да загубят състоянието си без това да повлияе на другите процеси-деца наSupervisor-а им.:one_for_all: Ако наблюдаван процес ‘умре’, всички наблюдавани процеси се ‘убиват’ и след това всички се стартират. Обикновено тази стратегия се използва за процеси, чиито състояния зависят доста едно от друго и ако само един от тях бъде рестартиран ще се наруши общата логика на програмата.:rest_for_one: Ако наблюдаван процес ‘умре’, всички наблюдавани процеси стартирани СЛЕД недго също ‘умират’. Всички тези процеси, включително процесът-причина се рестартират по първоначалния си стартов ред. Тази стратегия е подходяща за ситуация като : процес ‘A’ няма зависимости, но процес ‘Б’ зависи от ‘А’, а има и процес ‘В’, който зависи както от ‘Б’, така и транзитивно от ‘А’.: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]}
# ]
Всяка четворка в този списък съответства на процес-дете.
- Това е
id-то на процеса заSupervisor-а. Известно и катоchild_id. За именовани процеси, това е името. Приsimple_one_for_oneстратегия е:undefined. PID-ът на процеса, може също и да е атома:restarting, ако процесът се рестартира или:undefined, ако процесът не съществува.- Тип -
:workerили:supervisor. - Модули - както в спецификацията. Обикновено списък от един модул - модулът с
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 програма като цяло.