Привет, Хабр. Это снова Алексей Деев, backend программист в компании Avanpost. Сейчас я хочу предложить вам отойти немного от написания кода и погрузиться в теорию: рассмотрим архитектуру Akka.net и проблемы, которые акторы помогут нам решить. Перед прочтением советую хотя бы бегло прочитать статью Введение в Akka.NET], чтобы иметь базовое представление о модели акторов в целом. Эту статью я не позиционирую как часть цикла, скорее, это небольшой спин-офф к основному сюжету.
Инкапсуляция и параллельное исполнение
Для начала давайте попробуем выявить проблемы в использовании традиционных подходов программирования к реалиям параллельных вычислений. Разберем понятие инкапсуляции. Определение говорит нам, что внутренние данные объекта недоступны напрямую извне, для работы с ними используется вызов методов. Подразумевается, что объект сам заботится о сохранении инвариантности инкапсулированных данных.
Теперь представим, что у нас есть цепочка вызовов методов разных объектов для получения определенного состояния системы. Например, вот так:

А теперь положим объекты на условные таймлайны и синей линией нарисуем цепочку вызовов методов:

И все будет работать отлично – пока у нас для выполнения используется один поток, инвариантность будет сохраняться. Только вот в реальных продуктах однопоточная обработка уже становится редкостью, а вот многопоточная обработка и асинхронность – уже почти стандарт. Даже Microsoft методы фрейморка постепенно переводит на асинхронные. Давайте нарисуем поток выполнения уже в более реальной системе с параллельными потоками:

Тут мы видим, что к одному объекту идет параллельное обращение из разных потоков, и в этом случае инкапсуляция уже не гарантирует сохранения инвариантности данных. Для сохранения инвариантности нужно делать дополнительные действия по координации между потоками. Обычно для этого используют блокировки, которые исключают одновременную работу разных потоков с одним методом. Однако блокировка тянет за собой приличное количество накладных расходов. Переключение контекста, на которое ЦПУ тратит дополнительное время. К этому еще накладываются накладные расходы со стороны операционной системы для приостановки потока и его восстановления позже. При блокировке поток вызывающего кода заблокирован, поэтому он не может выполнять никакую другую работу, а, значит, мы можем потерять отзывчивость пользовательских частей приложений (интерфейса), когда выполняется длительная фоновая задача. Можно создавать больше потоков для сохранения отзывчивости, но и накладные расходы на обслуживание потоков тоже будут расти.
Помимо того, когда дело доходит до координации между несколькими машинами, нужно дополнительно применять распределенные блокировки. "Бонусом" у нас добавляются риски взаимной блокировки (deadlock) и состояние гонки. Получается замкнутый круг:
* Без достаточного количества блокировок состояние теряет инвариантность.
* При наличии множества блокировок страдает производительность и растут риски взаимной блокировки и состояния гонки.
В итоге мы имеем следующее:
* Объекты могут гарантировать инкапсуляцию (защиту инвариантов) только при однопоточном доступе; многопоточное выполнение почти всегда приводит к повреждению внутреннего состояния. Каждый инвариант может быть нарушен наличием двух конкурирующих потоков в одном участке кода.
* Хотя блокировки кажутся естественным средством поддержания инкапсуляции при наличии нескольких потоков, на практике они увеличивают сложность и создают риски дополнительных проблем.
* Блокировки работают локально, для многонодовой архитектуры нужно отдельно продумывать распределенные блокировки.
Обработка ошибок
Предположим, что нам нужно распараллелить некое количество задач разных типов. Каждый тип задачи обрабатывается по своей логике. При этом каждый тип задач должен выполняться по одной, т.е. должен быть некий диспетчер, который раздает в потоки единицу работы. А еще каждая задача может порождать подзадачи другого типа, которые не обслуживаются текущим потоком, поэтому их необходимо передать соответствующим потокам.
Вот так это можно представить картинкой:

Первая проблема заключается в том, как "вызывающий" может быть уведомлен о завершении подзадачи, которая ушла на обработку из потока, отличного от потока диспетчера. Но более серьезная проблема возникает, когда задача завершается с исключением. Куда распространяется исключение? Оно будет распространяться до обработчика исключений рабочего потока, полностью игнорируя, кто был фактическим "вызывающим". Эта ситуация ухудшается, когда дела идут совсем плохо, и рабочий поток сталкивается с ошибкой, например, внутреннее исключение, вызванное ошибкой поднимается до корня потока и заставляет поток завершиться. В этом случае мы теряем текущее состояние всех потоков. Тут сразу возникает вопрос: кто должен перезапустить нормальную работу службы, и как должны быть восстановлены до известного стабильного состояния все потоки с задачами. На первый взгляд, вроде бы все неплохо, но мы внезапно сталкиваемся с новым явлением: задача, над которой работал поток, больше не находится в нашем хранилище, откуда разбирались задачи. Мы потеряли сообщение, даже несмотря на то, что это локальная связь без участия сети (где потеря сообщений ожидаема).
Конкурентные системы с делегированием работы должны обрабатывать ошибки служб и иметь принципиальные средства восстановления от них. Клиенты таких служб должны знать, что задачи/сообщения могут теряться во время перезапусков. Даже если потеря не происходит, ответ может задерживаться произвольно из-за ранее поставленных задач (длинная очередь), задержек вызванных сборкой мусора и так далее. В подобных условиях конкурентные системы должны обрабатывать сроки ответа в виде тайм-аутов, так же как сетевые/распределенные системы.
Как Akka.Net может помочь
Как и было описано в предыдущих разделах, общие практики программирования не могут должным образом удовлетворить потребности современных параллельных и распределенных систем. Посмотрим, что нам предлагает Akka.Net.
Использование передачи сообщений позволяет избегать блокировок
Вместо вызова методов акторы отправляют сообщения друг другу. Актор может отправить сообщение и продолжить работу без блокировки. Таким образом, он может выполнять больше работы – отправлять и получать сообщения.

С объектами, когда метод возвращается, он освобождает контроль над выполняющим потоком. В этом отношении акторы ведут себя очень похоже на объекты: они реагируют на сообщения и возвращают выполнение, когда заканчивают обработку текущего сообщения. Таким образом, акторы фактически достигают выполнения, которое мы представляли для объектов.
Важно отметить, что передача сообщений вместо вызова методов означает отсутствие возвращаемого значения. Отправляя сообщение, актор делегирует работу другому актору. Если ожидалось возвращаемое значение, отправляющий актор должен был либо заблокироваться, либо выполнить работу другого актора в том же потоке, но вместо этого получающий актор передает результаты в ответном сообщении.
Акторы сохраняет инвариантность – независимо от отправителей сообщений реагируют на входящие сообщения последовательно, по одному за раз. В то время как каждый актор обрабатывает поступающие к нему сообщения последовательно, разные акторы работают параллельно друг с другом, так что система актеров может обрабатывать столько сообщений одновременно, сколько доступно ядер процессора на машине. Поскольку всегда обрабатывается не более одного сообщения за раз для каждого актора, инварианты актора могут сохраняться без синхронизации. Это происходит автоматически, без использования блокировок.

Вот что происходит, когда актор получает сообщение:
Актор добавляет сообщение в конец очереди.
Если актор не был запланирован для выполнения, он помечается как готовый к выполнению.
Скрытый планировщик берет актор и начинает его выполнение.
Актор выбирает сообщение из начала очереди.
Актор изменяет внутреннее состояние и отправляет сообщения другим акторам.
Актор снимается с расписания.
Для достижения этого поведения у акторов есть:
Почтовый ящик (очередь, куда попадают сообщения).
Поведение (состояние актора, внутренние переменные и т.д.).
Сообщения (части данных, представляющие сигнал; аналогично вызовам методов и их параметрам).
Среда выполнения (механизм, который берет актеров с сообщениями для обработки и вызывает их код обработки сообщений).
Адрес.
Сообщения помещаются в так называемые почтовые ящики акторов. Поведение актора описывает то, как он реагирует на сообщения (например, отправляя больше сообщений или изменяя состояние). Среда выполнения организует пул потоков для управления всеми этими действиями полностью прозрачно.
Это очень простая модель и она решает ранее перечисленные проблемы:
Инкапсуляция сохраняется за счет декомпозиции выполнения от сигнализации (вызовы методов передают выполнение; передача сообщений — нет).
Нет необходимости в блокировках. Изменение внутреннего состояния актора возможно только через сообщения, которые обрабатываются по одному за раз; это исключает гонки при попытке сохранить инварианты.
Блокировки нигде не используются; отправители не блокируются. Миллионы акторов могут эффективно планироваться на десятке потоков, достигая полного потенциала современных ЦПУ. Делегирование задач является естественным режимом работы для актеров.
Состояние актеров локально и не разделяется: изменения и данные передаются через сообщения.
Обработка ошибок акторами
Поскольку у нас больше нет общего стека вызовов между акторами, которые отправляют друг другу сообщения, нам нужно по-другому обрабатывать ситуации с ошибками. Есть два типа ошибок, которые нужно учитывать:
Первый случай — когда делегированная задача на целевом акторе завершилась с ошибкой из-за ошибки в задаче (обычно какая-то проблема проверки данных, например, несуществующий идентификатор пользователя). В этом случае сервис инкапсулированный целевым актором остается целым, только сама задача является ошибочной. Сервисный актор должен ответить отправителю сообщением о том, что произошла ошибка.
Второй случай — когда сам сервис сталкивается с внутренней ошибкой. Akka.NET требует, чтобы все акторы были организованы в древовидную иерархию; то есть актор, который создает другого актора, становится родителем нового актора. Когда актор терпит неудачу, его родительский актор узнает об этом и может отреагировать на сбой. Кроме того, если родительский актор остановлен, все его дочерние актеры также рекурсивно останавливаются. Эта служба называется супервизией и является одной из ключевых для Akka.NET.
Структура подчинения акторов:

Супервизор (родитель) может решить перезапустить свои дочерние акторы при определенных типах ошибок или полностью остановить их в других случаях. Дочерние акторы никогда не завершаются без уведомления (за исключением случая попадания в бесконечный цикл). Всегда есть ответственный актор за управление другим актором, при этом “наружу” эти сообщения не выходят, если это не реализовано на уровне логики. При этом другие акторы могут продолжать отправлять сообщения между собой, пока упавший актор перезапускается.
Подвальчик
В статье я намеренно зацепился за некоторые основы. Можно ли сказать, что проблемы надуманные? Конечно, можно. Но именно на таких вещах я и хотел показать еще один способ решения задач параллельной обработки данных, используя модель акторов.
Мы в нашем продукте Avanpost IDM за семь лет использования акторов перенесли уже огромное количество логики в акторы. За счет акторов мы полностью отказались от брокера сообщений rabbitmq и придумали достаточно простую, но при этом крайне эффективную кластеризацию бизнес-логики для разделении нагрузки горизонтально. При этом еще раз уточню: акторы – это не волшебная пилюля ? Бежать переделывать свои решения не стоит. Но, если сейчас идет поиск решения, настойчиво рекомендую рассмотреть акторную модель.
onets
А как вы сохраняете очереди из акторов в хранилище, чтобы восстановиться после сбоев?