Часть 8. Акторы Akka

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

План

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

Пересекли экватор - половина курса пройдена

Akka и акторы

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

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

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

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

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


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

в Play! зависимость уже и так есть

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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


object Counter {
  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
    case Get(replyto) =>
      replyto ! value
      Behaviors.same
  }
}
					

"Immutable" актор


private def counter(value: Long): Behavior[Protocol] = 
  Behaviors.receiveMessage {
    case Incr(n) =>
      counter(value + n)
    case Get(replyto) =>
      replyto ! value
      Behaviors.same
}

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

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

  system ! Counter.Incr(1)

  system.terminate()
}
					

Используем Ask


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

object Main extends App {
  implicit val system: ActorSystem[Counter.Protocol] = ???
// ^^^^^^

  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, _))
					

// для foreach
implicit val ec: ExecutionContext = system.executionContext

counterF.foreach { counter =>
  counter ! Counter.Incr(1)

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

  system.terminate()
}
					

Диспетчеры

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

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

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

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

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

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


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

default-blocking-io-dispatcher {
  type = "Dispatcher"
  executor = "thread-pool-executor"
  throughput = 1

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

Перерыв 5 минут

Статус по практическому заданию

План задания

  1. Классификатор текстов
  2. Reads для vk.com (только reads! writes не нужно)
  3. Стемминг и диагностика
  4. Получаем сообщения API vk.com
  5. Сервис категоризации
    -------- мы находимся здесь -------
  6. Опрос новых записей
  7. Хранение состояния на диске
  8. Realtime обновление
  • Экранирование спецсимволов
  • Классификация ленты: /newsfeed
  • Нейтральный класс

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

Пример


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
      
        source.onComplete(???)
      
        Behaviors.same
    }
  }

case RefreshAndGet(replyto) =>
  import ctx.executionContext

  source.onComplete {
    case Success(v) =>
      ctx.self ! Refresh(v)
      replyto ! v
    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)
}
					

Для ask есть "ctx.ask"

Stash

Можно временно отложить сообщения:

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

    Behaviors.withStash(capacity = 1000) { stash =>
      def waiting: Behavior[Protocol] = ???
      def working: Behavior[Protocol] = ???

      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
  }
 
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
}
					

Пример: последовательная обработка и Future

  • Запрашиваем Future, переходим в waiting
  • В waiting кладем запросы в Stash
  • При вычислении Future - unstash и обратно в ready
  • Не забываем про ошибки, таймауты и пр.

Вместо Stash иногда эффективнее
накапливать запросы в state

Таймеры и Deadline

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


import scala.concurrent.duration._

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

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

// величина таймаута
val timeout: Duration = deadline.timeLeft
					

Таймеры


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

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

  timers.cancelAll()

  ...
					

Зачем?

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

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

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

ctx.setReceiveTimeout(5 minutes, ReceiveTimeout)

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

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

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

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

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

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

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

Иерархия: DriverActor → ConnectionActor → TransactionActor

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

  • resume — игнорируем ошибку.
  • restart — незаметный перезапуск.
  • restartWithBackoff — перезапуск с задержкой
  • stop — остановка (по-умолчанию)

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
}					
	

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

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

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

Напоминаю: