Введение
Привет, Хабр! Это Алексей Деев, backend-разработчик в компании Avanpost. В этой статье я коротко расскажу о мире параллельной обработки данных с помощью акторной модели, приведу примеры кода на разных реализациях акторов под .net.
Модель акторов появилась достаточно давно. Первое ее формальное описание сделали Carl Hewitt, Peter Bishop, Richard Steiger в статье 1973 года “A Universal Modular Actor Formalism for Artificial Intelligence”. Она была разработана для решения проблем, связанных с традиционными потоковыми архитектурами, которые часто сталкиваются с блокировками и состояниями гонки при одновременном доступе к общим ресурсам. В основе акторной модели лежит концепция акторов — изолированных сущностей, которые взаимодействуют только посредством обмена сообщениями. Это значительно снижает вероятность возникновения проблем с синхронизацией и улучшает управляемость сложных систем. Со временем акторная модель получила широкое распространение в различных областях, включая распределенные системы, высоконагруженные веб-приложения и системы реального времени.
Основные принципы акторной модели включают:
Изоляция: Каждый актор является независимой сущностью с собственным состоянием, которое не может быть напрямую изменено другими акторами.
Асинхронное взаимодействие: Акторы общаются между собой исключительно посредством отправки и получения сообщений, что устраняет необходимость в блокировках и синхронизирующих примитивах.
Последовательная обработка: Актор обрабатывает поступающие сообщения одно за другим, обеспечивая предсказуемость и упрощая управление состоянием.
Управление сбоями: Иерархия супервизоров отвечает за мониторинг и восстановление акторов в случае сбоев, повышая устойчивость всей системы.
Если максимально упростить формальное определение, то акторную модель можно описать так:
Представим, что у нас есть обычный велосипед:

У нас есть самый обычный велосипед с переключателями скоростей и двумя тормозами.
Опишем базовые системы велосипеда:
Переключатель скорости передний — ShifterFront
Переключатель скорости задний — ShifterRear
Тормоз передний — BrakeFront
Тормоз задний — BrakeRear
Опишем базовые задачи велосипеда:
Переключение передачи вниз/вверх на переднем переключателе
Переключение передачи вниз/в��ерх на заднем переключателе
Тормоз передний задействован на n%
Тормоз передний задействован на m%
Теперь распишем вышеперечисленное в виде акторов и сообщений (команд).
Для переключателей скоростей можно использовать одну базовую модель Shifter, отличие будет только в свойстве MaxGear (максимально возможная передача):
Shifter { int CurrentGear; int MinGear = 1; } ShifterFront : Shifter { int MaxGear = 3; } ShifterRear : Shifter { int MaxGear = 7; }
Для тормозова можно использовать одну базовую модель Brake:
Brake { int CurrentPressure; // абстрактное тормозное усилие в попугаях } BrakeFront : Brake { } BrakeRear: Brake { }
В каждый момент времени модели находятся в определенном конечном состоянии, например, если велосипед стоит на месте, состояние будет такое:
BrakeFront { int CurrentPressure = 0 } BrakeRear { int CurrentPressure = 0 } ShifterFront { int CurrentGear = 1; int MinGear = 1; int MaxGear = 3; } ShifterRear { int CurrentGear = 1; int MinGear = 1; int MaxGear = 7; }
Теперь предположим что наш велосипед движется с какой-то скоростью. Определим состояние моделей:
Brake { int CurrentPressure = 0 } ShifterFront { int CurrentGear = 3; int MinGear = 1; int MaxGear = 3; } ShifterRear { int CurrentGear = 5; int MinGear = 1; int MaxGear = 7; }
И мы хотим понизить передачу заднего переключателя, а потом затормозить. Для этого нам нужно сказать (послать сообщения) переднему переключателю и тормозу. И заодно обозначим модели для элементов управления — для ручки тормоза и выбора передач:
BrakeHandle — ручка тормоза
ShifterHandle — ручка выбора передачи
Для упрощения примера этим моделям не будем выделять собственное состояние.
И определим сообщения:
BrakeMessage { int Pressure; } GearChangeMessage { int Gear; }
Теперь, если мы хотим повысить передачу, нам нужно на ручке переключения установить нужную и сказать об этом непосредственно переключателю:
ShifterHandle{ public void ChangeGear(){ ShifterFront.Tell(new GearChangeMessage() {Gear=2} ); } }
Здесь мы послали переднему переключателю скоростей сообщение с новым значением текущей передачи. И если он “понимает” такой тип сообщений, он должен его обработать и перейти в новое состояние:
ShifterFront { int CurrentGear = 3; int MinGear = 1; int MaxGear = 3; public void Receive(GearChange msg){ CurrentGear= msg.Gear; } }
Теперь у нас на переднем переключателе выставлена скорость 2.
При этом одновременно со сменой передачи мы можем затормозить:
BrakeHandle{ public void Brake(){ BrakeFront.Tell(new BrakeMessage() {Pressure=50} ); } } BrakeFront { int CurrentPressure = 0 public void Receive(BrakeMessage msg){ CurrentPressure= msg.Pressure; } }
Таким образом у нас выстроилась связь между механизмами велосипеда, которыми мы можем управлять параллельно. При этом по определению акторной модели входящие сообщения должны обрабатываться последовательно, в порядке их отправки. Конечно это лишь самые базовые понятия но для общего представления – вполне достаточно. Некоторые языки программирования имеют синтаксис, похожий на акторную модель, — Smalltalk например. Есть языки, которые сами по себе являются реализацией акторной модели: Elixir, Erlang и другие. Erlang, наверное, один из самых известных.
Далее предлагаю кратко пройтись по реализациям акторов которые, на мой взгляд, заслуживают внимания на м��мент публикации статьи. Поскольку я работаю разработчиком на платформе .net, рассматривать реализации буду под язык C#. Для каждой библиотеки сделаю очень простой, но рабочий пример кода.
Microsoft Orleans
Microsoft Orleans был разработан в Microsoft Research и впервые представлен в 2014 году. Изначально он создан для упрощения разработки масштабируемых облачных приложений, Orleans предлагает модель виртуальных акторов (Grains), автоматизируя управление состоянием и масштабированием. Проект активно развивается и поддерживается Microsoft, что обеспечивает его стабильность и интеграцию с облачными сервисами Azure. Исходный код Microsoft Orleans есть на github.
Основные компоненты Orleans включают:
Grain: Виртуальные акторы с уникальными ключами, представляющие логические единицы обработки.
Silo: Узлы выполнения, на которых размещаются Grain, обеспечивающие масштабируемость и отказоустойчивость.
Orleans Runtime: Движок, управляющий маршрутизацией сообщений и жизненным циклом Grain.
Storage Providers: Модули для сохранения состояния Grain во внешних хранилищах, таких как базы данных или облачные сервисы.
Orleans применяется в следующих сценариях:
Облачные сервисы: Для создания масштабируемых и устойчивых к сбоям приложений в облаке.
Игровые серверы: Для управления состоянием игроков и игрового мира.
Интернет-решения: Где требуется динамическое масштабирование и надежное хранение состояния. Теперь напишем немного кода.
Начнем с описания нашего велосипе��а как актора, в терминологии Orleans это будет Grain:
public interface IBicycleGrain : IGrainWithStringKey { Task Accelerate(int increment); Task GearUp(); Task GearDown(); Task Brake(int decrement); Task<string> GetStatus(); } public class BicycleGrain : Grain, IBicycleGrain { private int _speed = 0; private int _gear = 1; private bool _isMoving = false; public async Task Accelerate(int increment) { _speed += increment; _isMoving = _speed > 0; } public async Task GearUp() { _gear--; } public async Task GearDown() { _gear++; } public async Task Brake(int decrement) { _speed = Math.Max(0, _speed - decrement); _isMoving = _speed > 0; } public async Task<string> GetStatus() { return ($"Велосипед {this.GetPrimaryKeyString()}: " + $"Скорость: {_speed} км/ч, " + $"Передача: {_gear}, " + $"Состояние: {(_isMoving ? "движется" : "стоит")}"); } }
В интерфейсе IBicycleGrain я определил несколько команд:
{ Task Accelerate(int increment); // Разгоняемся Task GearUp(); // Повышаем передачу Task GearDown(); // Понижаем передачу Task Brake(int decrement); // Тормозим Task<string> GetStatus(); // Текущий статус }
Сразу под интерфейсом идет его реализация. Как можно видеть — каждая команда приводит объект в одно конечное состояние.
Теперь этот актор нужно опубликовать и «запустить». Как я писал выше — Orleans всегда работает как кластер, поэтому даже для запуска одно локального актора нам нужен и хост(Silo) и клиент.
// Создаем хост (Silo) var host = new HostBuilder() .UseOrleans(builder => { builder.UseLocalhostClustering(); }) .Build(); // Запускаем хост (Silo) await host.StartAsync(); //Получаем из контейнера экземпляр клиента var client = host.Services.GetRequiredService<IClusterClient>(); //Создаем экземпляр нашего велосипеда var bike = client.GetGrain<IBicycleGrain>("Bike1"); //Отправляем велосипеду команду await bike.Brake(5);
И на этом все. Базовое приложение готово. Orleans хорошо подойдет, если опыта в “акторной” разработке нет и хочется без особых временных затрат запустить свой первый код на акторах. У реализации низкий (по меркам акторной модели 🙂) порог входа и хорошая документация.
Proto.Actor
Реализация акторной модели для платформы .NET, вдохновленная Akka.NET и Erlang.
Proto.Actor изначально разработан для языка Go, но также поддерживает .NET и Kotlin. Проект ориентирован на производительность и простоту интеграции в различные типы приложений, включая микросервисы и веб-приложения. Первично был создан и развивался Roger Johansson — одним из создателей Akka.Net, ушедшим из команды из-за разногласий в подходе к разработке. Akka.NET шла по пути полной кастомизации: создания своих пулов потоков, пользовательских сетевых слоёв, пользовательской сериализации, поддержки пользовательской конфигурации и многого другого. По словам Roger Johansson, всё это занимало слишком много времени и не давало сосредоточится, по его мнению, на более важный вещах.
Proto.Actor сосредоточен исключительно на решении актуальных проблем, связанных с конкурентностью и распределенным программированием, используя существующие проверенные решения для всех второстепенных аспектов (например, для сериализации используется Protobuf). Исходный код Proto.Actor есть на github.
По философии подхода к разработке похож на Microsoft Orleans. Так же как и Orleans поддерживает virtual actors.
Ключевые компоненты Proto.Actor:
Actors: Независимые сущности, взаимодействующие через сообщения. Contexts: Среда исполнения для акторов, обеспечивающая их изоляцию и обработку сообщений.
Pipelines: Механизмы для обработки сообщений перед их доставкой актору.
Cluster: Поддержка распределённых систем с автоматическим обнаружением и маршрутизацией
Ну и попишем код. Proto.Actor, как и Akka.NET, по реализации близки к оригинальной акторной модели. Все взаимодействие тут происходить путем отправки сообщений.
Определим их:
public class AccelerateMessage(int increment) { public int Increment { get; } = increment; } public class BrakeMessage(int decrement) { public int Decrement { get; } = decrement; } public class GearChangeMessage(GearChangeAction action) { public readonly GearChangeAction Action = action; } public enum GearChangeAction { Up, Down } public class StatusMessage { public static StatusMessage Instance { get; } = new StatusMessage(); private StatusMessage() { } }
А теперь велосипед:
public class BicycleActor : IActor { private readonly string _bikeId; private int _speed = 0; private int _gear = 1; private bool _isMoving = false; public BicycleActor(string bikeId) { _bikeId = bikeId; } public async Task ReceiveAsync(IContext context) { switch (context.Message) { case AccelerateMessage msg: { _speed += msg.Increment; _isMoving = _speed > 0; break; } case BrakeMessage msg: { _speed = Math.Max(0, _speed - msg.Decrement); _isMoving = _speed > 0; break; } case StatusMessage: { context.Respond(new BicycleStatus( _bikeId, _speed, _gear, _isMoving)); break; } case GearChangeMessage msg: { switch (msg.Action) { case GearChangeAction.Down: { _gear--; break; } case GearChangeAction.Up: { _gear++; break; } } break; } } } }
Сразу обращувнимание на различие в коде — тут у нас изменение состояние присходит не обычным вызовом метода класса а именно посылкой сообщения и его обработкой в зависимости от его типа.
Вот так будет выглядеть код для работы с нашим актором:
//Создаем локальную систему акторов var system = new ActorSystem(new ActorSystemConfig()); //Создаем экземпляр актора var props = Props.FromProducer(() => new BicycleActor("Bike1")); var bikePid = system.Root.Spawn(props); //Отправляем актору сообщение system.Root.Send(bikePid, new AccelerateMessage(5));
Proto.Actor и Akka.Net поддерживают как локальные так и распределенные системы акторов, и для запуска локальной нам не нужно поднимать хост, как это было выше в Orleans.
Akka.Net
Akka.NET — это порт оригинального фреймворка Akka, разработанного для JVM (Java Virtual Machine), адаптированный для платформы .NET. Проект начал развиваться в начале 2010-х годов и быстро стал одним из наиболее популярных решений для реализации акторной модели в мире .NET. Из рассмотренных в этой статье это самая “сложная” реализация с высоким порогом входа, но при этом обладает огромными возможностями для кастомизации под свои задачи. У проекта достаточно бедная документация, описаны только самые базовые примитивы, да еще и не всегда актуальна для последних релизов. Скорее всего это из-за того что компания (Petabridge), которая спонсирует разработку, продает платные курсы по Akka.NET и им экономически невыгодно содержать вести документацию по всем нюансам работы с их реализацией.
Основные компоненты Akka.NET:
Actor: Базовый примитив, представляющий собой изолированную единицу обработки.
ActorSystem: Контейнер для акторов, управляющий их жизненным циклом и разрешением.
Props: Конфигурационные объекты, определяющие характеристики актора.
Mailbox: Очереди сообщений для акторов, обеспечивающие последовательную обработку входящих сообщений.
Dispatchers: Компоненты, распределяющие исполнение акторов по потокам.
Код для Akka.Net будет очень похож на код из примера для Proto.Actor.
Сообщения:
public class AccelerateMessage(int increment) { public int Increment { get; } = increment; } public class BrakeMessage(int decrement) { public int Decrement { get; } = decrement; } public class GearChangeMessage(GearChangeAction action) { public readonly GearChangeAction Action = action; } public enum GearChangeAction { Up, Down } public class StatusMessage { public static StatusMessage Instance { get; } = new StatusMessage(); private StatusMessage() { } }
Велосипед:
public class BikeActor : ReceiveActor { private readonly string _bikeId; private int _speed = 0; private int _gear = 1; private bool _isMoving = false; public BikeActor(string bikeId) { _bikeId = bikeId; Receive<AccelerateMessage>(msg => { _speed += msg.Increment; _isMoving = _speed > 0; }); Receive<BrakeMessage>(msg => { _speed = Math.Max(0, _speed - msg.Decrement); _isMoving = _speed > 0; }); Receive<StatusMessage>(_ => { Sender.Tell(new BicycleStatus( _bikeId, _speed, _gear, _isMoving)); }); Receive<GearChangeMessage>(_ => { switch (_.Action) { case GearChangeAction.Down: { _gear--; break; } case GearChangeAction.Up: { _gear++; break; } } }); } }
Отличия от реализации для Proto.Actor минимальны.
Ну и непосредственно запуск:
//Создаем локальную систему акторов using var system = ActorSystem.Create("BikeSystem", Config.Empty); //Создаем экземпляр актора var bikeActor = system.ActorOf(Props.Create(() => new BikeActor("Bike1")), "bike"); //Отправляем актору сообщение bikeActor.Tell(new AccelerateMessage(5));
Заключение
Именно Akka.NET мы выбрали для внедрения распределенных вычислений в один из продуктов в уже далеком 2018 году. Выбор был между Microsoft Orleans и Akka.NET. Proto.Actor не рассматривался, поскольку на тот момент был относительно молодым проектом (в 2018 был всего год с момента первого релиза библиотеки под.net). Сохранялось опасение, что проект забросят, а мы планировали внедрять акторы глубоко и надолго 🙂. Несмотря на общую сложность реализации и крайне скудную на тот момент (да, и сейчас тоже много моментов не расписано) документацию, именно возможность кастомизации «под себя» меня привлекла больше. Даже через обычный конфиг акторной системы можно было кардинально менять ее поведение, без внесения изменений в код и пересборки проекта. Ну и еще момент: для запуска изолированной (локальной) системы акторов на Akka.NET не требовалось поднимать «сервер», на котором акторы хостятся, в отличие от Microsoft Orleans, где все акторы живут внутри silo — мне это показалось излишним усложнением кода. И о своем решении мы в конечном итоге не пожалели, хотя в процессе погружения было огромное количество проблем, и исходники реализации я читал чаще, чем документацию.
Эта статья предлагается как вводная к курсу статей по реализации акторов Akka.Net. В течение курса я планирую рассмотреть Akka.Net от самых базовых вещей до конкретных технических нюансов реализации (от самого простого общения двух акторов до реализации шины обмена данными внутри кластера), естественно с рабочими примерами кода. Код из этой статьи можно найти на Github.
