Акторы Akka

Страничка курса: https://maxcom.github.io/scala-course-2022/

План

  1. Актор — асинхронный объект.
  2. Диспетчеры.
  3. Future и акторы
  4. Stash
  5. Таймеры и deadline
  6. Обработка исключительных ситуаций.
  7. Death watch и death pact

Akka и акторы

Akka - набор библиотек.
Сегодня рассмотрим ядро - акторы Akka.

Модель акторов - 1973 год,
использовалась как модель для описания параллельных систем.

Позже стала применяться в качестве базы для практических реализаций.

Популярность получила благодаря Erlang:
языку программирования и платформе, разработанной Ericsson (1987 г.)

Erlang динамический + другая модель многопоточности

Дальше будем говорить про реализацию модели в библиотеке Akka.

Подключаем в проект: build.sbt


libraryDependencies += 
  "com.typesafe.akka" %% "akka-actor-typed" % "2.6.19"
libraryDependencies += // или другой backend Slf4j
  "ch.qos.logback" % "logback-classic" % "1.2.11"
					

Два варианта API:

  • «Классический» - копия Erlang, без типов
  • «Typed» - типы и много улучшений

"Классические" акторы в лекции 2018 года.

Правильные import'ы:


import akka.actor.typed.scaladsl.Behaviors
import akka.actor.typed.{ActorRef, Behavior}
					

Не перепутайте с javadsl и "классическими" api

Разделяемое изменяемое состояние + параллелизм = проблемы

Разделяемое изменяемое состояние + параллелизм = «Share nothing» архитектура

Актор — асинхронный объект

  • Приватное изменяемое состояние
  • Mailbox - входящая очередь сообщений,
    вместо явного вызова методов
  • Логика обработки сообщений (behavior),
    вместо реализации методов
  • Логика обработки ошибок
Объект Актор
Вызов counter.incr(n) counter ! Incr(n)
Запрос counter.get()
⇒ Int
counter ? Get
counter.ask(Get)
⇒ Future[Int]

В ООП логика моделируется взаимодействием объектов.

В модели акторов - взаимодействием акторов путем обмена сообщениями.

В ООП методы можно вызывать из разных потоков.

Потокобезопасность?

  • реализована при помощи блокировок, atomic и т.п.
  • отсутствует, т.е. перекладывается на пользователя

У актора обработка сообщений строго последовательна.

Разные акторы работают независимо в разных потоках.

Общение двух акторов последовательно
(очередь не перемешивается).

Пример: актор счетчика


object Counter {
  sealed trait Protocol

  case class Incr(n: Long) extends Protocol
  case class Get(replyto: ActorRef[Long]) extends Protocol

  def behavior: Behavior[Protocol] = ???
}					
					

Актор с изменяемым состоянием


def behavior: Behavior[Protocol] = Behaviors.setup { ctx =>
  // изменяемое состояние
  var value: Long = 0

  Behaviors.receiveMessage {
    case Incr(n) =>
      value += n
      Behaviors.same // не меняем behavior
    case Get(replyto) =>
      replyto ! value
      Behaviors.same
  }
}
					

"Immutable" актор


private def counter(value: Long): Behavior[Protocol] = 
  Behaviors.receiveMessage {
    case Incr(n) =>
      counter(value + n) // новый behavior
    case Get(replyto) =>
      replyto ! value
      Behaviors.same
}

def behavior: Behavior[Protocol] = counter(0)
					

"Immutable" предпочтительнее, но не всегда эффективен

mutable.Queue и многие другие структуры эффективнее immutable

Запускаем Akka


object Main extends App {
  val system: ActorSystem[Counter.Protocol] = // корневой актор
    ActorSystem(Counter.behavior, "counter") // "counter" - имя

  // оправка в system это отправка корневому актору
  system ! Counter.Incr(1)

  system.terminate()
}
					

запуск множества акторов будет дальше

Используем Ask

import akka.actor.typed.scaladsl.AskPattern._

object Main extends App {
  implicit val system: ActorSystem[Counter.Protocol] = ???
// ^^^^^^ ask создаем временные акторы

  system ! Counter.Incr(1)

  implicit val timeout: Timeout = 10.seconds

  val result: Future[Long] = system ? Counter.Get
  println(Await.result(result, 1.minutes))

  system.terminate()
}
					

Приватное состояние - не только переменные.

Актор может скрывать за собой внешние сущности:

  • RandomAccessFile (seek/read).
  • данные во внешнем хранилище.
  • и др.

Актор должен иметь приватное состояние

Исключение - отказоустойчивость или балансировка нагрузки

Что можно отправлять актору?

  • Неизменяемые данные
  • Ссылки на акторы - ActorRef

Изменяемые объекты посылать нельзя!

Можно ли посылать функции?

  • Можно, потому что в функции тоже неизменяемые
  • Нельзя, если в замыкании мутабельные структуры.
    И еще это сомнительно с точки зрения модели.

Можно ли посылать внешние объекты, вроде коннектов к БД, сокетов, т.п.?

  • Нельзя, это нарушение модели
  • Можно, когда понимаем, что делаем и зачем.
    Так делают на "границе" между акторами и внешним миром.

Пример актора: классификатор с обучением

Один актор на классификацию и обучение - просто, но не эффективно

Актор для обучения, get запрос для модели
(нужна immutable модель или функция копирования)

Location transparency

Акторы могут быть расположены в разных виртуальных машинах и на разных серверах.

Взаимодействие акторов по сети не похоже на RPC (Remote Procedure Call).

  • Классический RPC — удаленные вызовы подобны локальным
  • Акторы — локальные «вызовы» подобны удаленным

Дочерние акторы и SpawnProtocol

  • Актор может запустить дочерние акторы
    context.spawn(HelloWorld(), "greeter")
    (об этом позже)
  • На верхнем уровне - SpawnProtocol

SpawnProtocol - актор, порождающий акторы


implicit val system: ActorSystem[SpawnProtocol.Command] = 
  ActorSystem(SpawnProtocol(), "root")

val counterF: Future[ActorRef[Counter.Protocol]] =
  system.ask(Spawn(Counter.behavior, "counter", Props.empty, _))
// _ для replyTo, иногда нужно явно указать тип
					

Диспетчеры

  • Runtime для акторов
  • Работают поверх Executor'ов
  • Рабочие треды — разделяемый ресурс

Диспетчеры — проблемы

  • Не все акторы одинаковы
  • Акторы с долгими задачами могут занять все треды
  • Диалоги с большим количеством сообщений занимают диспетчер
  • В Akka 2.6 сама Akka использует internal dispatcher

Диспетчеры под задачи

  • Интерактивные задачи vs фоновая пакетная обработка.
  • Блокирующиеся вызовы (JDBC, файловый ввод-вывод, долгие вычисления).
  • Свой «отсек» на каждую подсистему (bulkheading).
  • Своя JVM и свой сервер на каждую подсистему (Akka Remote / Cluster).

Факт: «Титаник» утонул из-за плохого разделения на отсеки.

Указываем диспетчер при создании актора


context.spawn(yourBehavior, "BlockingDispatcher", 
  DispatcherSelector.blocking())
context.spawn(yourBehavior, "DispatcherFromConfig", 
  DispatcherSelector.fromConfig("your-dispatcher"))
					

application.conf


default-blocking-io-dispatcher {
  type = "Dispatcher"
  executor = "thread-pool-executor"
  throughput = 1 // переключение после N сообщений

  thread-pool-executor {
    fixed-pool-size = 16
  }
}

Конец первой части

Работа с Future в акторе

Обращаемся к асинхронным API в акторе

Пример

object CacheActor {
  trait Protocol

  // отдать из кеша
  case class Get(replyto: ActorRef[String]) 
    extends Protocol

  // обновить кеш и отдать новое
  case class RefreshAndGet(replyto: ActorRef[String]) 
    extends Protocol

  def behavior(source: => Future[String]): Behavior[Protocol] = 
    ???
}
def behavior(source: => Future[String]): Behavior[Protocol] = 
  Behaviors.setup { ctx =>
    var current = "undefined"

    Behaviors.receiveMessage {
      case Get(replyto) =>
        replyto ! current
        Behaviors.same
      case RefreshAndGet(replyto) =>
        import ctx.executionContext // для onComplete
        source.onComplete(???)
        Behaviors.same
    }
  }

Можно ли из onComplete менять state актора?

добавляем приватный Refresh(value)

case RefreshAndGet(replyto) =>
  import ctx.executionContext

  source.onComplete {
    case Success(v) =>
      ctx.self ! Refresh(v)
      replyto ! v 
      // или Refresh(v, replyto) и отправка после Refresh
    case Failure(ex) => // handle failure
  }

  Behaviors.same
case Refresh(value) =>
  current = value
  Behaviors.same

pipeTo: посылаем результат себе самому

// преобразуем результат в протокол
ctx.pipeToSelf(source) {
  case Success(v) =>
    Refresh(v)
  case Failure(ex) =>
    RefreshFailed(ex)
}
					

AskPattern внутри актора: "context.ask"

// преобразуем результат в протокол
ctx.ask(sourceActor, Get) {
  case Success(v) =>
    Refresh(v)
  case Failure(ex) =>
    RefreshFailed(ex)
}
					

Stash

Механизм, позволяющий временно отложить сообщения

Пример: откладываем Get до окончания Refresh

В синхронном варианте проблемы нет.

Задаем stash и state машину

 
def behavior(source: => Future[String]): Behavior[Protocol] = 
  Behaviors.setup { ctx =>
    var current = "undefined"

    Behaviors.withStash(capacity = 1000) { stash =>
      def working: Behavior[Protocol] = ??? // ждем запрос
      def waiting: Behavior[Protocol] = ??? // ждем refresh

      working
    }
  }
					

обработка запроса

def working: Behavior[Protocol] = 
  Behaviors.receiveMessagePartial {
    case Get(replyto) =>
      replyto ! current
      Behaviors.same
    case RefreshAndGet(replyto) =>
      ctx.pipeToSelf(source) {
        case Success(v) =>
          Refresh(v)
        case Failure(ex) =>
          RefreshFailed(ex)
      }

      waiting // переход в waiting
  }

ожидание Refresh

 
def waiting: Behavior[Protocol] = Behaviors.receiveMessage {
  case Refresh(value) =>
    current = value
    stash.unstashAll(working)
  case RefreshFailed(ex) => // нужна обработка
    stash.unstashAll(working)
  case other =>
    // помним, что stash может переполниться
    stash.stash(other)
    Behaviors.same
}
					

Какой размер Stash?

Сколько не жалко

Сколько имеет смысл накапливать

Число операций O(N2)
N - размер Stash

Иногда эффективнее накапливать
запросы в другой структуре

Таймеры и Deadline

Как реализовать таймауты в акторах?

Deadline - точка во времени


import scala.concurrent.duration._

val deadline: Deadline = 5.minutes.fromNow
					
  • Использует монотонные часы:
    не зависят от системного времени
  • Можно передавать внутри JVM,
    нельзя по сети и на диск

deadline.hasTimeLeft() // проверки
deadline.isOverdue()

// величина таймаута, например для ask
val timeout: Duration = deadline.timeLeft
					

еще их можно использовать для вычисления длительности операций

Совет: в REST передавайте таймаут в заголовке

Плохая ситуация: клиент делает retry раньше, чем заканчивается обработка

Таймеры


Behaviors.withTimers { timers =>
  // key для уникальности таймера
  // msg - отправляемое сообщение
  timers.startSingleTimer(key = CheckTimeout, 
                          msg = CheckTimeout, 
			  1.minute)

  timers.startPeriodicTimer(key = Tick, msg = Tick, 1.minute)
  
  timers.cancel(Tick)
  timers.cancelAll()
					

Зачем?

  • Обработка таймаутов
  • Сборка batch
  • Периодическое обновление, очистка
  • Печать статистики работы

Receive Timeout - реакция на отсутствие сообщений

Защитный механизм для коротокоживущих акторов

ctx.setReceiveTimeout(5.minutes, ReceiveTimeout)

// отправляет ReceiveTimeout при отсутствии активности
					

Игнорирует сообщения, расширяющие NotInfluenceReceiveTimeout

Обработка исключительных ситуаций

В ООП и ФП ошибки обрабатывает тот,
кто вызывает функцию.

Правильно ли это?

Пример: виды ошибок в автомате с газировкой:

  • «Недостаточно средств», «закончилась кола» — покупателю
  • NullPointerException, CocaColaJamException — владельцу автомата (супервизору)

Пример: драйвер базы данных

  • Дублирование первичного ключа, нарушение foreign key — пользователю драйвера
  • «БД перезагружается», «Соединение разорвано» — супервизору

Родитель - супервизор дочернего актора

DriverActor → ConnectionActor → TransactionActor

Стратегии супервизора

  • resume — игнорируем ошибку,
    сообщение теряется
  • restart — незаметный для клиента перезапуск,
    сообщение теряется, пересоздается state
  • restartWithBackoff — перезапуск с задержкой
  • stop — остановка

По умолчанию: остановка.
Не забываем про это!!!

При остановке и рестарте актора все его дочерние акторы останавливаются

Задаем Supervisor


def behavior(source: => Future[String]): Behavior[Protocol] =
  Behaviors
    .supervise[Protocol](...)
    .onFailure[Throwable](SupervisorStrategy.restart)
					

разная стратегия для разных исключений

Пример: ConnectionActor


Behaviors
  .supervise[Protocol](...)
  .onFailure[ConnectionResetException]
    (SupervisorStrategy.restartWithBackoff(
        minBackoff = 100 millis,
        maxBackoff = 30 seconds,
        randomFactor = 0.2))
  .onFailure[DatabaseWasDeletedException]
    (SupervisorStrategy.stop)
						

Death pact & death watch

Пример: один запрос – три иерархии.

Ожидание остановки актора:


context.watch(anotherActorRef)
					

Ловим сообщение Terminated(actorRef)


.receiveMessage {
  ...
}.receiveSignal {
  case (ctx, Terminated(actor)) =>
    Behaviors.same
} // другие сигналы например: PostStop, PreRestart
	

Не обработали: работает «death pact» — останавливаемся вместе (DeathPactException).

В следующий раз:

  • Гарантии доставки сообщений
  • CircuitBreaker
  • Back pressure
  • Роутеры

Напоминаю: