Статьи

Отказоустойчивые примитивы в Scala: ссылки и ловушки

В течение последнего десятилетия Actor Model активно продается как простая, эффективная и экономичная концепция для построения параллелизма. Будучи популяризованным Erlang в начале девяностых и использованным в качестве основной конструкции параллелизма в Scala, акторы предлагают модель, не зависящую от событий, без участия, где создание и поддержка одного актера обходится дешево (так что вы можете продолжать запускать миллионы из них), и удаленные актеры, работающие на распределенных узлах, выглядят и ощущаются как локальные.

Тем не менее, при неразумном использовании субъекты могут принести еще больше боли и шаблонов, чем параллельный параллелизм или STM . Отсутствие углубленной документации по моделированию на основе акторов приводит к неправильным представлениям и мифам, которые могут быть разрушены при использовании лучших практик и руководящих принципов проектирования для построения отказоустойчивых систем в Erlang.

Отказоустойчивые примитивы

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

Отказоустойчивые системы в их применении не склонны предсказывать все возможные причины сбоев (чтобы предотвратить сбой одного компонента), а скорее изолируют компонент от остальной части системы и перезагружают его, поддерживая согласованную работу системы. Если перезапуск компонента не помогает, проблема может быть перенесена на более высокий уровень, чтобы [возможно] перезапустить стороны, которые обмениваются сообщениями с компонентом.

Как говорит Джо Армстронг в своей книге « Программирование на Эрланге », когда один актер умирает в комнате, полной актеров, другие, вероятно, должны это заметить и начать решать проблему (очищать тела).

В Erlang и Scala такое поведение достигается путем объединения актеров. В самой основной форме, когда два актера связаны между собой, и один из них умирает, он посылает сигнал выхода другому актеру, чтобы завершить его тоже.

Связывание актеров является двунаправленной операцией, поэтому, когда вы связываете актера A с B, B находится на заднем плане, связанном с A, и смерть любого из них вызывает отправку сигнала выхода другому. В Erlang возможно создание однонаправленной связи с мониторами (когда отслеживаемый процесс умирает, сообщение «ВНИЗ» отправляется обработчику). Аналогов для мониторов в стандартной библиотеке Scala нет, однако реализовать их по запросу будет легко.

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

В «Программировании Erlang» несколько сценариев выхода из ссылки / ловушки представлены на простом примере:

01
02
03
04
05
06
07
08
09
10
11
12
13
-module(edemo1).
-export([start2]).
 
start(Bool, M) ->
 
   A = spawn(fun() -> a() end),
   B = spawn(fun() -> b(A, Bool) end),
   C = spawn(fun() -> c(B, M) end),
 
   sleep(1000),
 
   status(b, B),
   status(c, C).

В приведенном выше коде создаются три процесса, синхронизированные со сном, чтобы дать им время для обработки пропущенных сообщений (в общем, уродливо, но работает на простом примере), а затем проверяется их состояние. Актеры определены следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
a() ->
   process_flag(trap_exit, true),
   wait(a).
 
b(A, Bool) ->
   process_flag(trap_exit, Bool),
   link(A),
   wait(b).
 
c(B, M) ->
   link(B),
   case M of
      {die, Reason} ->
         exit(Reason);
      {divide, N} ->
         1N,
         wait(c);
      normal ->
         true
end.

Процесс A всегда прерывает выходы, процесс B связан с A и прерывает выходы в зависимости от входа функции, процесс C, связанный с B, получает сообщения и либо выполняет вычисления, либо завершается с ошибкой.

1
2
3
4
5
6
wait(Prog) ->
   receive
   Any ->
      io:format('Process ~p received ~p~n' ,[Prog, Any]),
      wait(Prog)
end.

Метод wait рекурсивно получает сообщения, распечатывающие содержимое сообщения.

При переводе на Scala с использованием стандартной библиотеки Actors пример выглядит следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
object LinkedActors {
 
 case class Die(reason:AnyRef)
 case class Divide(number: Int)
 
 def status(name: String, actor: Actor) = println('Actor %s is %s'
format(name, actor.getState))
 def printMessage(actorName: String):PartialFunction[Any, Unit] =
{case msg => println('Actor %s received %s' format(actorName, msg))}
 
 def start(isTrapExit: Boolean, message: Any) = {
 
    val A = actor{
     self.trapExit = true
     loop(react(printMessage('a')))
    }
 
    val B = actor{
     self.trapExit = isTrapExit
     self link A
     loop(react(printMessage('b')))
    }
    
    val C = actor{
     self link B
     loop{
       react{
         case Die(reason) => exit(reason)
         case Divide(number) => 1number
       }
     }
    }
 
    C! message
    
    Thread.sleep(1000)
 
    status('b', B)
    status('c', C)
 }
 
}

По сути, код такой же, с той разницей, что сообщения, принятые субъектом C, классифицируются с классами случаев, а поведение получателей акторов B и C представляется частичными функциями.

Давайте передадим некоторые входные данные методу start, чтобы увидеть, как поведут себя цепочечные актеры, когда некоторые из них умрут:

1
2
3
4
scala> start(false, Die('abc'))
Actor a received Exit(scala.actors.Actor$$anon$1@dc8f6d,abc)
Actor b is Terminated
Actor c is Terminated

Актер C получает сообщение Die и существует с причиной «abc». Актер B, связанный с C, не перехватывает выходы, поэтому он также завершается. Пока A, подключенный к ловушкам B, завершается, когда субъект B завершает свою работу, он отправляет A сообщение с причиной сбоя (класс дел со следующей сигнатурой):

1
2
3
4
5
6
7
8
** An `Exit` message (an instance of this class) is sent to an actor
with `trapExit` set to `true` whenever one of its linked actors
*  terminates.
*
@param from   the actor that terminated
@param reason the reason that caused the actor to terminate
*
case class Exit(from: AbstractActor, reason: AnyRef)

В то же время, когда ожидается выход (не вызванный вычислительным исключением), связанные субъекты не затрагиваются:

1
2
3
scala> start(false, Die('normal))
Actor b is Suspended
Actor c is Terminated

В приведенном ниже фрагменте необработанное деление на нулевое исключение приводит к смерти C и B:

1
2
3
4
5
6
scala> start(false, Divide(0))
Actor a received Exit(scala.actors.Actor$$anon$1@113eb9c,UncaughtException
(scala.actors.Actor$$anon$1@1a1446d,Some(Divide(0)),Some(scala.act
ors.ActorProxy@14f83d1),java.lang.ArithmeticException:  by zero))
Actor b is Terminated
Actor c is Terminated

Если мы заставим B перехватить выход, актер останется в живых во всех сценариях, описанных выше:

1
2
3
4
scala> start(true, Die('abc'))
Actor b received Exit(scala.actors.Actor$$anon$1@13e49a8,abc)
Actor b is Suspended
Actor c is Terminated

По сравнению с первым фрагментом, теперь B получает сообщение о выходе от C.

Необработанные ошибки также не распространяются на A:

1
2
3
4
5
6
scala> start(true, Divide(0))
Actor b received Exit(scala.actors.Actor$$anon$1@119f779,UncaughtException
(scala.actors.Actor$$anon$1@119f779,Some(Divide(0)),Some(scala.act
ors.ActorProxy@14f83d1),java.lang.ArithmeticException:  by zero))
Actor b is Suspended
Actor c is Terminated

Основной супервизор

Выходное сообщение содержит ссылку на неисправного субъекта, который можно использовать для его перезапуска. Можно реализовать очень простой супервизор актера по аналогии с поведением супервизора в Erlang.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
case class ChildSpecification(worker: Worker, restartBehavior: Child
RestartBehavior.Value = permanent)
case class OneForOne(maxRestarts: Long = 3, maxTime: Long = 3000)
extends RestartStrategy
case class OneForAll(maxRestarts: Long = 3, maxTime: Long = 3000)
extends RestartStrategy
 
class Supervisor(supervisorId: String, strategy: RestartStrategy,
childSpecs: List[ChildSpecification]) extends Worker {
 
...
 
 override def act = {
    self.trapExit = true
    linkWorkers
    loop {
     react{
       case Exit(worker: Worker, reason) =>
         println('Worker [%s] has failed due to [%s]' format(worker.id,
reason))
         if(worker.restartsInPeriod(strategy.maxTime) >= strategy
.maxRestarts) exit('Maximum restart intensity for %s is reached!' format(worker.id))
         strategy match {
           case str:OneForOne => restartWorker(worker)
           case str:OneForAll => childSpecs.foreach{spec =>
restartWorker(spec.worker)}
         }
       case Terminate(reason) => println('Supervisor terminated with
reason [%s]' format(reason))
         exit(reason)
     }
    }
 }
 
...
}

Сам по себе Supervisor — это обычный Scala Actor, который отлавливает сообщения от других связанных с ним субъектов (рабочих, с точки зрения терминологии надзора) и перезапускает либо только одного отказавшего участника, либо всех контролируемых субъектов. Когда частота перезапуска достигает предела, заданного стратегией перезапуска, супервизор завершает работу, так что супервизор на более высокой иерархической позиции может попытаться решить проблему.

В простейшем сценарии супервизор перезапускает актера, прерванного из-за необработанного исключения:

1
2
3
4
5
6
7
'Actor terminated due to uncaught exception is restarted by the supervisor' in {
   val worker = new SimpleWorker('simple_worker')
   Supervisor('basic_supervisor', OneForOne(),
             List(ChildSpecification(worker))).start
   worker !? (1000, Divide(0))
   (worker !? (1000, Divide(1))).asInstanceOf[Option[Int]] must be equalTo Some(1)
}

Вывод спецификации:

1
2
3
4
5
6
Starting worker simple_worker
Worker [simple_worker] has failed due to [UncaughtException(com.vasilrem.linked.
SupervisorSpec$SimpleWorker@fd54ec,Some(Divide(0)),Some(scal
a.actors.Channel@16167ab),java.lang.ArithmeticException:  by zero)]
Restarting worker [simple_worker]...
[info]   + Actor terminated due to uncaught exception is restarted by the supervisor

В более сложном сценарии, когда супервизоры связаны в дереве, высокоуровневый супервизор перезапускает низкоуровневый супервизор, когда он умирает, что вызывает перезапуск связанных с ним рабочих:

01
02
03
04
05
06
07
08
09
10
11
'High-level supervisor restarts low-level supervisor and the wrokers linked to it' in{
   val worker = new SimpleWorker('simple_worker')
   val lowLevel = Supervisor('lowlevel_supervisor', OneForOne(),
                            List(ChildSpecification(worker)))
   val highLevel = Supervisor('lowlevel_supervisor', OneForOne(),
                             List(ChildSpecification(lowLevel))).start
   worker.getState must not be equalTo(State.Terminated)
   lowLevel ! Terminate('Kill lowlevel')
   Thread.sleep(1000)
   worker.getState must not be equalTo(State.Terminated)
}

Результат теста следующий:

1
2
3
4
5
6
7
Starting worker lowlevel_supervisor
Starting worker simple_worker
Supervisor terminated with reason [Kill lowlevel]
Worker [lowlevel_supervisor] has failed due to [Kill lowlevel]
Restarting worker [lowlevel_supervisor]...
Starting worker simple_worker
[info]   + High-level supervisor restart low-level supervisor

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

Приятного кодирования и не забудьте поделиться!

Ссылка: отказоустойчивые примитивы в Scala: ссылки и ловушки от нашего партнера JCG Василя Ременюка в блоге Василя Ременюка .