Введение в Akka.NET

от автора

Введение

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


ссылка на оригинал статьи https://habr.com/ru/articles/905428/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *