Как стать автором
Обновить
75.84
Avanpost
Безопасность начинается с управления доступом

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

Уровень сложностиСредний
Время на прочтение11 мин
Количество просмотров1.1K

Введение

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

Теги:
Хабы:
+8
Комментарии4

Публикации

Информация

Сайт
www.avanpost.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
AvanpostID