Введение
Привет, Хабр! Это Алексей Деев, 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.