Русский
Русский
English
Статистика
Реклама

Конечный автомат

Разделяй и властвуй Использование FSM в Unity

23.04.2021 22:09:05 | Автор: admin

Грамотная архитектура играет ключевую роль при разработке любого программного продукта. Корни большинства распространенных проблем с производительностью, расширяемостью или понятностью кода растут именно из ее отсутствия. Отсутствие строго определенной структуры проекта лишает разработчиков возможности мыслить абстракциями, понимать написанный коллегой код с первого взгляда и предугадывать место возникновения ошибки. А в некоторых случаях человек может запутаться даже в своем собственном коде, перенасыщенном сущностями и компонентами. Но практически каждый программист рано или поздно, сам ли или с помощью умной книжки, знакомится с решениями, которые хороши вне зависимости от контекста. Они настолько эффективны и универсальны, что находят место в решении множества задач, и... Да, знаю, можно не продолжать, все уже и так поняли, что я о шаблонах проектирования. Одни на них молятся, другие находили среди них свои велосипеды. Некоторые утверждают на собеседовании, что изучили их вдоль и поперек и уличили в полной бесполезности. Но все, так или иначе, слышали о них. Сегодня речь пойдет об одном из паттернов - "Состояние". А точнее, о конечных автоматах. Даже если вы относитесь к последней из перечисленных выше групп, вы наверняка сталкивались со следующим инструментом:

Минимальный аниматор главного героя в платформереМинимальный аниматор главного героя в платформере

Аниматоры в Unity построены как раз на конечных автоматах. Каждая анимация группы объектов представлена в виде состояния. Условия и порядок переходов между ними определяется в аниматоре, который является конечным автоматом. Также, неоднократно поднималась тема использования конечных автоматов для описания логики работы объектов со сложным поведением. AI ботов, управление главным героем, вот это все.

Я бы хотел сделать акцент на менее изъезженной теме. Конечный автомат позволяет эффективно управлять состоянием игры в целом. Более того, с его помощью можно однажды описать бизнес-логику абстрактной игры, и отталкиваться в дальнейшем от этой заготовки при разработке прототипов. Так, многие ГК игры, построенные по такому принципу могут отличаться друг от друга на полсотни строк и структуру сцены, реализуя всю логику на автомате. А в сложнейших и запутанных проектах новый функционал может быть внедрен максимально просто путем добавления в существующий автомат нового состояния и добавления переходов в него, исключая возможность возникновения багов и неожиданного поведения. Также, среди плюсов построения бизнес-логики приложения на автоматах можно выделить следующее:

  • Каждое состояние описывает свою логику максимально просто. Нет никаких условных блоков, каждое состояние представляет собой последовательность действий на входе и выходе и список сигналов, которые автомат обрабатывает в текущем состоянии. Таким образом, мы отображаем джойстик на входе в состояние Play и скрываем на выходе. Но даже если каким-то чудом игрок увидел джойстик на экране паузы, он не сможет двигать персонажа. И не потому, что активен еще один флаг isPaused, а просто потому, что в состоянии паузы не предусмотрено принимать сигналы с джойстика.

  • В каждый момент времени однозначно видно, в каком состоянии мы сейчас находимся. Логируя переходы между состояниями и сигналы, посылаемые в автомат мы видим четкую последовательность событий, и состояние, в котором мы находимся.

  • В дополнение к предыдущему пункту, следует отметить, что это также очень удобно в контексте дебага. Посмотрев логи, мы сразу видим, что бот сломался потому, что был инициализирован не после старта уровня, а во время генерации сцены, когда еще не был готов контекст для работы его AI.

  • Многие баги становятся просто невозможны, потому что мы строго определяем условия переходов. Мы точно не попадем в состояние Play, пока состояние WaitMatch не получит сигнал "match_ready", а если мы захотим вернуться в лобби, мы сначала отправим серверу команду об этом, и только после сигнала "room_left" выполним переход.

  • Сама идея логики на автоматах максимально прозрачна. Видя ТЗ мы сразу понимаем, каким будет список состояний, логика, реализованная в каждом из них и граф переходов. Причем, как было отмечено выше, "внешняя" часть графа в большинстве игр будет оставаться неизменной.

Но давайте по порядку. А начну я пожалуй с одного важного принципа, встречающегося в программировании на всех уровнях. Разделение реализации и интерфейса. Логики и визуализации. Разница между серверной архитектурой и клиентской. На каждом уровне мы изолируем сложность внутренней системы, обеспечивая корректную ее работу и предоставляя удобный интерфейс для работы с ней.

Теория автоматов - довольно крупный раздел дискретной математики. Автоматов бывает много, хороших и разных. Так что попытаемся сформулировать наши требования к абстрактному автомату, который будет лежать в основе архитектуры приложения и будет наилучшим образом реализовывать алгоритм его работы.

Определим, что из себя представляет наш конечный автомат. Для этого опишем его структуру:

FSM

AState

- public FSM(AState initState)

- public void Signal(string name, object data = null)

- private void ChangeState(AState newState)

- void Enter()

- void Exit()

- AState Signal()

Итак, мы имеем 2 сущности:

Конечный автомат. Интерфейс у него невелик. Начиная работу в некотором состоянии, он может принимать сигналы извне, передавая их в текущее состояние. И если оно в ответ на сигнал вернуло новое состояние, автомат выполняет переход в него. В момент перехода мы выполняем Exit для последнего состояния и Enter для следующего. Таким образом, у нас получается следующая конструкция:

 public class FSM {   private AState currentState;   public FSM(AState initState) => ChangeState(initState);      private void ChangeState(AState newState)   {     if (newState == null) return;     currentState?.Exit();     currentState = newState;     currentState.Enter();   }   public void Signal(string name, object arg = null)   {     var result = currentState.Signal(name, arg);     ChangeState(result);   } }

Абстрактное состояние. Из соображений удобства и микрооптимизации его методы имеют нехитрую реализацию, которая наследуется потомками, в которых оные не определены.

public class AState{  public virtual void Enter() => null;  public virtual void Exit() => null;  public virtual AState Signal(string name, object arg) => null;}

А в самих состояниях мы просто описываем логику

public class SLoad : AState{    public override void Enter()    {        Game.Data.Set("loader_visible",true);        var load = SceneManager.LoadSceneAsync("SceneGameplay");        load.completed+=a=>Game.Fsm.Signal("scene_loaded");    }    public override void Exit()    {        Game.Data.Set("loader_visible",false);    }        public override AState Signal(string name, object arg)    {        if (name == "scene_loaded")            return new SLobby();        return null;    }    }

Как вы могли заметить, я описал логику переходов между состояниями, но даже вскользь не затронул тему хранения и изменения их списка. Дело в том, что в этом нет смысла, мы можем просто создавать новые состояния при переходе. Экземпляр состояния в большинстве случаев представляет собой 3 ссылки на функции, хранящиеся статически. И переходы между состояниями - не такое частое действие, чтобы дать сколько-то существенный оверхед. Зато мы получаем гору синтаксического сахара и статический анализ в подарок! При желании, проблема аллокаций решается очень легко, но это будет не так удобно. Кроме того, вот пример того, как такое состояние можно легко параметризовать, не усложняя при этом общий интерфейс

public class SMessage : AState{    private string msgText;    private AState next;    public SMessage(string messageText, AState nextState)    {        msgText = messageText;        btnText = buttonText;        next = nextState;    }        public override void Enter()    {        Game.Data.Set("message_text", msgText);        Game.Data.Set("window_message_visible",true);    }    public override void Exit()    {        Game.Data.Set("window_message_visible",false);    }        public override AState Signal(string name, object arg)    {        if (name == "message_btn_ok")             return next;        return null;    }}

Таким образом вместо того, чтобы делать усложненную систему переходов c аргументами, мы передаем их в конструктор и создаем экземпляр состояния с желаемыми параметрами.

...case "iap_ok":return new SMessage("Item purchased! Going back to store.", new SStore());...

В этой статье я не буду задерживаться на устройстве структуры Game.Data, используемой в состояниях, достаточно просто отметить, что это словарь, в элементах которого реализован шаблон "Наблюдатель". Объекты сцены, например, элементы UI, могут подписываться на изменения в нем и отображать гарантированно актуальные данные. Она играет ключевую роль в связи между визуализацией и логикой в моих примерах, но не является необходимым условием для работы с конечными автоматами. Сигналы в автомат же можно отправлять откуда угодно, но одним из самых универсальных примеров будет следующий скрипт.

public class ButtonFSM : MonoBehaviour, IPointerClickHandler{    public string key;        public override void OnPointerClick(PointerEventData eventData)    {        Game.Fsm.Signal(key);    }}

Иными словами, мы при клике по кнопке(на самом деле, любому CanvasRenderer) передаем соответствующий сигнал в автомат. При переходе между состояниями мы можем любым удобным нам способом включать и выключать разные Canvas, менять маски, используемые в Physics.Raycast и даже иногда менять Time.timeScale! Как бы ужасно и бескультурно это ни казалось на первый взгляд, пока сделанное в Enter отменяется в Exit, оно гарантированно не может доставить каких-либо неудобств, так что вперед! Главное - не переусердствуйте.

Подробнее..

Конечные автоматы на страже порядка

26.11.2020 12:17:06 | Автор: admin


При разработке сложных систем часто сталкиваешься с проблемой прозрачности кода, точным описанием бизнес-логики и масштабирования решения. Однажды нам поставили задачу: реализовать функциональность тарифов, в которой много бизнес-логики. При этом сроки были сжаты, да ещё и повышенные финансовые риски. Чтобы решить эту задачу быстро, эффективно и прозрачно, мы решили использовать конечные автоматы (state machine).

Суть задачи


Тариф в Юле это набор платных опций по размещению и продвижению объявлений. Они дают пользователям дополнительные преимущества, вроде расширенного личного кабинета в веб-версии сервиса, использования раздела портфолио и прочего.

Также у нас есть понятие пакета платный набор из определённого количества размещений объявлений. Удельно получается дешевле, чем когда оплачиваешь размещения разово.

Бизнес попросил добавить в мобильное приложение возможность всячески редактировать услугу тарифов. Продакт-менеджеры придумали очень крутую и гибкую схему логики, под которую пришлось бы сделать очень много экранов и переходов. Вот лишь четверть всей схемы, это только редактирование, а есть ещё создание, оплата, планирование и так далее.



Естественно, мы заподозрили, что решение получится очень громоздким. Например, с тарифами задача подразумевала 7 полноценных экранов, массу различных диалогов и уведомлений. От сервера необходимо было сразу получать определенные данные. К этому добавилась и обработка различных состояний доступности редактирования выбранных значений; предвыбранные значения, которые нам приходят с сервера; возможность выбирать значения только на увеличение (речь о возможности запланировать тариф с большими значениями относительно текущих настроек тарифа). И многое другое. С пакетами была похожая картина, но меньше масштабом.

К тому же было еще два небольших условия от бизнеса:

  • Дедлайны близко.
  • Решение точно будет расширяться. Когда мы приступали к разработке, еще не было В2В-сегмента. Но мы знали, что он появится, и расширяться будет очень интенсивно.

Естественно, переписывать времени не будет, потому что решение должно быть лёгким в сопровождении.

Выбор решения


Первый вариант самый очевидный: флаги. Их можно описать очень много. Например, вот небольшое условие, которое отображает шапку тарифа:

if (hasTariff) {if (hasErrorTariff) {           // Ошибка оплаты тарифа} else if (isProcessedTariff) {           // тариф ожидает оплаты} else {           //тариф активен}} else {//нет тарифа}

Увы, такой вариант тяжело расширять. Когда добавится новое условие, придётся ветвить схему еще сильнее.

Второй вариант: добавление переменных с состояниями. На их основе можно решать, что отрисовывать. Но такому способу не хватает гибкости.

enum class State {PROCESS, ERROR, ACTIVE}when (state) {   PROCESS -> // тариф ожидает оплаты   ERROR -> // Ошибка оплаты тарифа   ACTIVE -> //тариф активен}

Третий вариант: найти что-то более описываемое, понятное и масштабируемое. Конечно же, это конечные автоматы (машины состояний).

Конечный автомат это модель дискретного устройства, которое имеет в себе определенный набор правил, обычно один вход и один выход. И в каждый момент времени автомат находится в одном состоянии из множества описанных. У автомата есть API, по которому можно переключить состояние, и если это некорректное переключение, то мы узнаем об ошибке. Следуя этой концепции очень легко структурировать код и сделать его читаемым. Такой код проще отлаживать и контролировать на всех этапах. Простенький конечный автомат может выглядеть так, и его очень легко расширять:


Конечные автоматы


Конечные автоматы прекрасно помогают в реализации бизнес-логики. Ведь мы точно описываем поведение системы при любом событии. Поэтому мы решили использовать этот подход. Описали нашу схему:


В ней можно иногда запутаться, однако всё, что было необходимо на момент реализации, здесь есть. Но нарисовав эту схему, мы поняли, что всё-таки надо понять, что и как мы будем реализовывать.

Есть несколько вариантов. Первый: пишем всё сами. Второй: берём одну из своих старых узкоспециализированных реализаций и дорабатываем. И третий вариант: используем готовое решение.

У самописного решения есть очевидные достоинства и недостатки. К первым относится лёгкость изменения и язык Kotlin. Правда, на разработку требуется немало времени. К тому же могут быть баги, которые придётся исправлять.

Начали смотреть на сторонние решения. Сначала выбрали библиотеку Polidea. Но у неё оказалось довольно много недостатков на наш взгляд: она написана на Java, имеет проблемы с поддержкой и трудно дорабатывается.

Тогда мы обратили внимание на библиотеку Tinder. Достоинств у неё оказалось больше, чем недостатков, что и сыграло позднее в её пользу. Она написана на Kotlin, у неё удобная DSL, библиотеку регулярно обновляют. А её главный недостаток трудно дорабатывается. Но всё же мы остановились на Tinder.

Библиотека Tinder


Код библиотеки:

val stateMachine = StateMachine.create<State, Event, SideEffect> {    initialState(State.Solid)    state<State.Solid> {        on<Event.OnMelted> {            transitionTo(State.Liquid, SideEffect.LogMelted)        }    }    state<State.Liquid> {        on<Event.OnFroze> {            transitionTo(State.Solid, SideEffect.LogFrozen)        }        on<Event.OnVaporized> {            transitionTo(State.Gas, SideEffect.LogVaporized)        }    }    state<State.Gas> {        on<Event.OnCondensed> {            transitionTo(State.Liquid, SideEffect.LogCondensed)        }    }    onTransition {        val validTransition = it as? StateMachine.Transition.Valid ?: return@onTransition        when (validTransition.sideEffect) {            SideEffect.LogMelted -> logger.log(ON_MELTED_MESSAGE)            SideEffect.LogFrozen -> logger.log(ON_FROZEN_MESSAGE)            SideEffect.LogVaporized -> logger.log(ON_VAPORIZED_MESSAGE)            SideEffect.LogCondensed -> logger.log(ON_CONDENSED_MESSAGE)        }    }}

Здесь есть состояния, в которых можно хранить какие-то данные, если, например, надо переходить с какими-то условиями. Также есть различные события, на которые мы можем реагировать: в данном случае OnFroze. SideEffect мы не использовали, не понадобилось.

Состояния переключаются просто: передаём в Transition объекта stateMachine событие, которое хотим отправить. В stateMachine есть описание всех возможных состояний. А внутри них мы можем описать те события, которые могут произойти.

Также в библиотеке есть важная конструкция OnTransition. В ней можно определить, из какого состояния в какое мы перешли, и определить корректность перехода. Мы использовали эту конструкцию, и при некорректных событиях просто выбрасывали пользователя в начало, чтобы он заново прошел по всему пути.

Реализация


Чтобы реализовать нашу бизнес-логику, кроме состояний нужно было описать и данные. Мы решили использовать один объект, который станет постепенно заполняться, пока пользователь идет по конечному автомату. В объекте есть набор параметров, либо влияющих на часть нашей функциональности, либо отражающих предустановку каких-то данных, либо содержащих какие-то вспомогательные данные.

По мере реализации схема разрослась: получилось около 30 состояний и 100 переходов. И поскольку всё содержалось в одном файле, ориентироваться стало довольно сложно. А искать баги ещё тяжелее, потому что когда из одного состояния перешел в другое, то появились какие-то данные и не можешь понять, в чём проблема.

На помощь пришла декомпозиция. Раз мы смогли сделать один конечный автомат, то сможем сделать ещё. Так мы из одного автомата сделали шесть.


С одной стороны, кажется, что мы увеличили себе работу. С другой стороны, мы стали лучше ориентироваться в коде. Стали понимать бизнес-схему и логику нашего приложения. Всё стало проще.

class TariffFlowStateMachine constructor(       val selectedStateMachine: TariffSelectedStateMachine,       val presetStateMachine: TariffPresetStateMachine,       val packageStateMachine: TariffPackageStateMachine,       val tariffStateMachine: TariffStateMachine,       val paymentStateMachine: TariffPaymentStateMachine) {   private val initialState = State.Init      val state: State       get() = when (stateMachine.state) {           is State.RootsState.RootSelectedState -> selectedStateMachine.state           is State.RootsState.RootPresetState -> presetStateMachine.state           is State.RootsState.RootPackageState -> packageStateMachine.state           is State.RootsState.RootTariffState -> tariffStateMachine.state           is State.RootsState.RootPaymentState -> paymentStateMachine.state           else -> State.Init   }

У нас есть базовый автомат, который управляет несколькими маленькими. Каждый из них отвечает за свой фрагмент функциональности. И когда продакт-менеджеры просят добавить что-то ещё, нам не приходится менять все переходы большого автомата. Достаточно поменять один маленький.

Например, так выглядит автомат выбора данных:


Автомат сборки пакета:


Автомат сборки тарифа:


А это автомат оплаты:


Приятный бонус


Кроме лёгкости расширения модульные конечные автоматы сильно упростили нам тестирование. Чтобы начать покрывать их тестами, можно написать небольшие обёртки, позволяющие указать начальное состояние, переход и ожидаемое состояние. Пример теста:

stateMachine = flowStateMachine.stateMachinestateFlowable = flowStateMachine.stateMachine.state//region utilityprivate fun assertTransition(initial: State, event: Event, expected: State) {  //given  val stateMachine = givenStateIs(initial)  val stateSubscriber = stateFlowable.test()  //when  stateMachine.transition(event)  //assert  stateSubscriber.assertLast(expected)}private fun givenStateIs(state: State): StateMachine<State, Event, SideEffect> {  return stateMachine.with { initialState(state) }}private fun TestSubscriber<State>.assertLast(expected: State) {  this.assertValueAt(this.valueCount() - 1, expected)}@Testfun `given state PaidPromotion on Error should result in PaymentMethods`() {  assertTransition(        initial = State.PaidPromotion(paymentMethod = PaymentMethod.CARD),        event = Event.Error(),        expected = State.PaymentMethods(navigateBack = true, reload = false)  )}

Было очень приятно осознать, что авторы библиотеки позаботились и о простоте тестирования.

В заключение


Если вы уже сталкивались с конечными автоматами и не понимали, зачем их использовать, то надеюсь, что мой рассказ помог вам разобраться в этом. Иногда они действительно нужны, и с ними очень приятно работать.

Да, конечные автоматы не всегда оправданы. Поэтому к их использованию надо подходить, взвесив все за и против.
Подробнее..

Достучаться до небес, или FSM на шаблонах

05.02.2021 02:04:48 | Автор: admin

Здравствуйте! Меня зовут Александр, я работаю инженером-программистом микроконтроллеров.

Пишу на С/С++, причем предпочитаю плюсы, ибо верую в их эволюционную неизбежность в embedded.

Мир встроенного ПО, язык С++ динамично развиваются, поэтому разработчикам важно не отставать и поддерживать свои скиллы и наработки актуальными моменту.

Я стараюсь следовать этому очевидному посылу, благо небожители ведущие С++ программисты и консультанты щедро делятся своим опытом и идеями на разных площадках (например здесь, или здесь).

Некоторое время назад я посмотрел мощный доклад Сергея Федорова про построение конечного автомата с таблицей переходов на шаблонах.

Если внезапно: "а что такое конечный автомат?"

Конечный автомат, или FSM(finite state maсhine) - один из самых востребованных и популярных приемов в программировании на МК. В свое время за кратким и практическим руководством по готовке FSM я ходил заброшенные, земли.

Одна из идей доклада - определить состояния, эвенты и действия через пользовательские типы, а таблицу переходов реализовать через шаблонный параметр, меня очень

впечатлила
// Transition table definitionusing transitions =  transition_table<  /*  State       Event       Next       */  tr< initial,    start,      running    >,  tr< running,    stop,       terminated >>;};// State machine objectusing minimal = state_machine<transitions>;minimal fsm;//...and then callfsm.process_event(start{});fsm.process_event(stop{});

А если добавить к этому перенос части функциональности кода в компайл тайм, заявленную автором потокобезопасность, улучшенные по сравнению с Boost::MSM выразительность, читаемость кода и скорость сборки, header only модель библиотеки, то - надо брать, решил я.

Вот только попытка собрать и запустить даже простейший пример на STM-ке закончилась матерком компилятора: "cannot use 'typeid' with "-fno-rtti" и "exception handling disabled".

Да, все так. Более того, помимо отключенной поддержки RTTI и исключений, у меня также выставлены флаги -fno-cxa-atexit, -fno-threadsafe-static. А еще в линкере применены настройки --specs=nano.specs (используем урезанную версию стандартной библиотеки с++ newlib-nano), --specs=nosys.specs (применяем легковесные заглушки для системных вызовов).

Зачем же таскать на себе вериги?

Embedded разработчикам хорошо известны особенности и ограничения при разработке встроенного ПО, а именно:

  • лимитированная память с недопустимостью фрагментации;

  • детерменированность времени выполнения;

  • штатно исполняющаяся программа никогда не выходит из main

С++ имеет в своем могучем арсенале средства и методы, неосторожное использование которых может войти в критическое противоречие с указанными выше условиями.

Как закружить в гармоничном танце С++ и bare metal отлично разъяснено у этого автора. Также порекомендую этот доклад.

Исходники проекта докладчика, включая зависимости, - это двадцать файлов со смертоноснейшей шаблонной магией. Перекроить их так, чтобы не юзать typeid и exceptions, простому смертному в моем лице - too much.

Делать нечего, поступимся принципами, включим поддержку RTTI, а вместо throw в исходниках автора проставим заглушки.

На этот раз все собралось. Вот только при использовании тулчейна gcc-arm-none-eabi-9-2020-q2-update и уровне оптимизации -O3, размер исполняемого файла превысил 200Кб.

Это несколько огорчительно, хотя какие могут быть претензии - библиотека изначально разрабатывалась под "большого брата".

И хотя старшие камни, например у STM, имеют на борту флеш от 1Мб, отдавать почти четверть только под конечный автомат, пусть и на закиси азота, как у докладчика, согласитесь, довольно расточительно.

Итак, с наскоку взять высоту не удалось, и я на некоторое время переключился на другие задачи. Но красота идеи меня не отпускала, и на днях я все-таки решился "достучаться до небес" - написать extra light embedded версию FSM из упомянутого доклада самостоятельно.

Уточню свои хотелки:

  • Оперировать состояниями, эвентами и действиями как пользовательскими типами.

  • Таблицу переходов реализовать в виде шаблонного параметра

  • Перетащить что возможно в компайл тайм

  • Асинхронно и атомарно постить эвенты

  • Переключать состояния за константное время

  • Выйти в итоге на приемлемый по меркам встроенного ПО размер кода

  • Повторить header only модель библиотеки

Забегая вперед, скажу, что в итоге что-то получилось и даже взлетело.

А вот как - не сочтите за труд, давайте посмотрим вместе. И не сдерживайте себя в метании тухлых яиц, ибо лучшей питательной среды для самосовершенстования трудно представить.

Первым делом опишем базовые сущности:

Состояние/State
struct StateBase{};template <base_t N, typename Action = void>struct State : StateBase{  static constexpr base_t idx = N;  using action_t = Action;  };

Здесь и далее base_t - платформозависимый тип, машинное слово. В моем случае это unsigned int.

Состояния пусть будут двух типов - пассивное, в котором никаких действий не происходит, и активное - при нахождении в котором будет выполнятся переданный шаблонным параметром функтор, action_t.

Цель статического члена idx уточню далее по тексту.

Событие/Event
struct EventBase{};template <base_t N>struct Event : EventBase{  static constexpr base_t idx = N;};

Элементарная структура, все ясно.

Действие при наступлении события и смене состояний:

Action
struct action{  void operator()(void){    // do something};

Безусловно, сигнатура operator() может и должна варьироваться от задач приложения, пока же для упрощения остановимся на самом легковесном варианте.

Сторож состояния:

Guard
enum class Guard : base_t{  OFF,  CONDITION_1,  CONDITION_2,  //etc.};

Идея сторожа - допустить переход в новое состояние, только если в данный момент выполнения программы текущее значение сторожа соответствует заданному пользователем значению в типе перехода/transition-a. Если такого соответствия нет, то переход в новое состояние не происходит. Но тут возможны варианты. Например, все же переходить, но не выполнять действие, переданное в состояние. Up to you.

Итак, пока все тривиально. Идем дальше.

Переход:

Transition
struct TrBase{};template <typename Source,          typename Event,          typename Target,          typename Action,          Guard G,          class =          std::enable_if_t<std::is_base_of_v<StateBase, Source>&&          std::is_base_of_v<EventBase, Event> &&          std::is_base_of_v<StateBase, Target>>          >  struct Tr : TrBase{  using source_t = Source;  using event_t  = Event;  using target_t = Target;  using action_t = Action;    static constexpr Guard guard = G;};

Структура Tr тоже элементарна. Она параметризуется типом исходного состояния - Source, типом события Event, по наступлению которого произойдет переход в целевое состояние Target, и типом Guard.

Также прояснилась причина наследования рассмотренных сущностей от базового типа. Страхуем себя от передачи в шаблон некорректного параметра через отлов ошибки на этапе компиляции.

Таблица переходов:

Transition table
struct TransitionTableBase{};template<typename... T>struct TransitionTable : TransitionTableBase{    using test_t = typename NoDuplicates<Collection<T...>>::Result;    static_assert(std::is_same_v<test_t, Collection<T...>>,                "Repeated transitions");    using transition_p = type_pack<T...>;    using state_collection = typename NoDuplicates   <Collection<typename T::source_t... ,typename T::target_t...>   >::Result;    using event_collection = typename NoDuplicates  <Collection<typename T::event_t...>    >::Result;    using state_v = decltype(get_var(state_collection{}));  using event_v = decltype(get_var(event_collection{}));  using transition_v = std::variant<T...>;};

Нуу, тут я набросил на вентилятор, конечно. Хотя все не настолько пугающе, как выглядит.

Структура TransitionTable параметризуется списком переходов/transition-ов, которые собственно и описывают всю логику конечного автомата.

Первым делом нам необходимо подстраховать себя от копипаста и просигналить при компиляции, что у нас повторы в списке. Исполняем это с помощью алгоритма NoDuplicates из всем известной библиотеки Loki. Результирующий тип под псевдонимом test_t сравниваем в static_assert-e с исходным списком переходов.

Далее, допуская что static_assert пройден, параметризуем некую структуру type_pack списком переходов и выведенному типу назначаем псевдоним transition_p. Структура type_pack, а также современные алгоритмы и методы по работе со списками типов собраны в файле typelist.h. Данный хедер написан под чутким руководством этого продвинутого парня.

Тип transition_p понадобится нам далее в конструкторе класса StateMachine.

Следом проходим по списку переходов, вытаскиваем, очищаем от повторов и сохраняем в отдельные коллекции состояния и эвенты. Эти коллекции alias-им как state_collection и event_collection соответственно.

К чему эта эквилибристика?

Нам необходимо как-то хранить и процессировать в ходе работы программы информацию о событиях, эвентах, действиях и их взаимодействии при переходах, выраженную в типах.

Удобным вариантом для этой цели является std::variant (тафтология умышленна).

Последовательно параметризуем std::variant списком переходов (выведенному типу назначим псевдоним transition_v); списком состояний и списком эвентов и назначаем для удобства псевдонимы state_v и event_v соответственно.

Тут нюанс. Чтобы вывести transition_v нам достаточно пробросить в шаблонный параметр std::variant variadic pack (T...) из шаблонного параметра класса TransitionTable.

А вот чтобы вывести state_v и event_v мы используем

вспомогательную constexpr функцию
template<typename... Types>constexpr auto get_var (th::Collection<Types...>){return std::variant<Types...>{};}

Далее мы инстанцируем получившиеся типы в конструкторе класса StateMachine и сохраним их для дальнейшего использования в подходящих контейнерах, о чем совсем скоро.

Оставшихся к этому моменту читателей я не обрадую - начинается основной замес.

Целиком приводить класс StateMachine не буду, он громоздок, прокомментирую его для удобства восприятия по частям.

Контейнер transitions
template<typename Table>class StateMachine{//other stuffprivate:using map_type =std::unordered_map < Key, transition_v, KeyHash, KeyEqual>;Key key;map_type transitions;};

Основной контейнер, в котором мы храним информацию о переходах. Unordered - потому что мы хотим константное время для переключения между событиями. Память под контейнер выделяем стандартным аллокатором из кучи, но делаем это единожды, на этапе инициализации, до входа в основной цикл.

Объект типа Key хранит у себя значения индексов состояния и эвента:

Key
struct Key{  base_t state_idx = 0;  base_t event_idx = 0;};

Теперь стало понятно назначение статических членов idx в базовых сущностях. Я просто не знаю, как писать хэшеры для пустых структур. Тащить в строку название самого типа через typeid и _cxa_demangle для нас не вариант, мы же условились, что не пользуем RTTI.

Контейнер events
template<typename Table>class StateMachine{//other stuffprivate:using queue_type =  RingBufferPO2 <EVENT_STACK_SIZE, event_v, Atomic>;    queue_type events;};

events - очередь, в которую будут прилетать эвенты. Так как это чисто рантаймовая история, необходимо избежать динамических аллокаций. Поэтому реализуем ее на базе статического кольцевого буффера RingBufferPO2, который я позаимствовал здесь (отличная для своего времени работа!).

Помимо указанных контейнеров, в объекте типа StateMachine мы будем хранить текущее состояние/state и значение сторожа/guard:

state and guard
template<typename Table>class StateMachine{//other stuffprivate:state_v current_state;Guard guard = Guard::OFF;};

Саспенс уже не за горами.

Конструктор
template<typename Table>class StateMachine{public:using transition_pack = typename Table::transition_p;StateMachine(){  set(transition_pack{});} // other stuff};

В конструкторе метод set принимает аргументом объект с информацией о списке переходов, пробегается по нему, достает инфо о каждом состоянии и эвенте, заполняет контейнер transitions, а также запоминает начальные состояние и значение сторожа:

Метод set
template <class... Ts>void set (type_pack<Ts...>){(set_impl(just_type<Ts>{}), ...);};template <typename T>void set_impl (just_type<T> t){using transition = typename decltype(t)::type;using state_t = typename transition::source_t;using event_t = typename transition::event_t;Guard g = transition::guard;Key k;k.state_idx = state_t::idx;k.event_idx = event_t::idx;transitions.insert( {k, transition{}} );if (0 == key.state_idx) {key.state_idx = k.state_idx;guard = g;current_state = state_t{};}}

Итак, объект StateMachine сконструирован, пора его как-то шевелить.

Но перед этим забудем как страшный сон суммируем что уже рассмотрели к этому моменту:

  • Определили типы компонентов конечного автомата: состояние/state, событие/event, действие/action, сторож/guard

  • Определили тип переход/transition, который должен параметризоваться типами source state, event, target state, guard.

  • Определили тип таблицы переходов. В качестве шаблонных параметров ему передается список переходов/transition-ов, который и определяет алгоритмы работы автомата.

  • При компиляции в классе TransitionTable, на основе std::variant выводятся типы-коллекции переходов, состояний и эвентов, которые впоследствии при конструировании объекта StateMachine инстанцируются и сохраняются в контейнеры, с которыми уже можно работать в рантайме.

Стержневая идея моей имплементации автомата такова (вдохнули): при наступлении события, мы достаем из его типа индекс (idx), объединяем его с индексом текущего состояния в объекте Key, по которому в контейнере transitions находим нужный нам переход, где получаем знания о целевом состоянии, стороже и действии, которое требуется выполнить в этом переходе, а также сверяем значения сторожа с текущим, для подтверждения или отмены перехода/действия(выдохнули).

Теперь рассмотрим методы API нашего автомата, реализующие эту логику.

Переключать состояния мы можем двумя способами: вызывать немедленный переход методом fsm.on_event(event{}) (шаблонная версиия fsm.on_event<Event>() если тип события известен на этапе проектирования), или можем складывать события в очередь методом fsm.push_event(event{}), чтобы потом, например в основном цикле, разобрать ее методом fsm.process(). Также, если в состояние передано какое-то действие, то мы можем вызывать его методом fsm.state_action().

Рассмотрим их детальнее, начиная с последнего

Метод state action
template <typename... Args>void state_action (const Args&... args){state_v temp_v{current_state};    auto l = [&](const auto& arg){      using state_t =  std::decay_t<decltype(arg)>;    using functor_t = typename state_t::action_t;        if constexpr (!std::is_same_v<functor_t, void>){    functor_t{}(args...);      }  };    std::visit(l, temp_v);}  

В методе мы создаем локальную переменную типа std::variant<State...> temp_v и инициализируем ее текущим состоянием. Далее определяем лямбду, которая послужит аргументом в методе std::visit.

"Нырнув" с ее помощью в variant, мы выведем тип текущего состояния, из него в свою очередь вытащим тип переданного функтора, инстанцируем его (проверив, если его тип не void) и вызовем с паком аргументов, захваченных лямбдой по ссылке, и проброшенных с головного вызова.

Знаю, что иногда лямбда может юзать кучу, но похоже, это не мой случай. Поправьте меня, пожалуйста, если я заблуждаюсь. В этом случае не сложно будет заменить лямбду на callable object.

Метод on_event
template <typename Event,class = std::enable_if_t<std::is_base_of_v<EventBase, Event>>>void on_event(const Event& e){Key k;  k.event_idx = e.idx;  k.state_idx = key.state_idx;  on_event_impl(k);}void on_event_impl (Key& k){transition_v tr_var = transitions[k];    Key &ref_k = key;  Guard &ref_g = guard;  state_v &ref_state = current_state;    auto l = [&](const auto& arg){    using tr_t =  std::decay_t<decltype(arg)>;    using functor_t = typename tr_t::action_t;        if ( GuardEqual{}(ref_g, tr_t::guard) ){          using target_t = typename tr_t::target_t;            ref_k.state_idx = target_t::idx;      ref_state = target_t{};            functor_t{}();      }   };      std::visit(l, tr_var);}

Здесь, как я уже описывал, мы достаем индекс из эвента, объединяем его в Key с индексом текущего состояния, и в качестве ключа передаем в приватный метод on_event_impl(Key& k).

Там мы по принятому ключу достаем из контенера transitions объект типа std::variant<Tr...> и инициализируем им локальную переменную tr_var. Ну а далее - логика, схожая с предыдущим примером. Вызываем std::visit c tr_var и лямдой l, в которой из типа Tr получаем сведения о состоянии, в которое нужно перейти (target_t), стороже (tr_t::guard)и типе действия (functor_t) к исполнению.

Сверив значение сторожа перехода с текущим сторожем, мы или оcуществляем переход, инстанцируя и вызывая functor_t, и сохраняя target_t в переменную с текущим состоянием(current_state), или возвращаемся в исходное состояние. Где ждем смены значения сторожа и нового события.

Метод push_event
template <unsigned int N>void push_event (const Event<N>& e){  events.push_back(e);}

Тут все просто.

Метод set_guard
void set_guard (const Guard& g){  guard = g;}

Вызываем, когда в программе сложились условия для перехода в следующее состояние.

Метод process
void process (void){    state_action();    auto it = transitions.begin();    Key k;  k.state_idx = key.state_idx;    for (uint32_t i = 0; i != events.size(); ++i){        auto v = events.front();     auto l = [&](const auto& arg){      using event_t =  std::decay_t<decltype(arg)>;      k.event_idx = event_t::idx;      it = transitions.find(k);    }        std::visit(l, v);        if ( it != transitions.end() ){            events.pop_front();      on_event_impl(k);      return;        } else {      events.push_back(v);      events.pop_front();    }  }}

При вызове метода мы первым делом выполняем некое полезное действие (если не void), переданное в состояние, state_action().

Ну а далее пробегаемся по очереди эвентов и просто воспроизводим логику, уже описанную для метода fsm.on_event(event{}).

Разумеется, работу с событиями можно значительно ускорить, при этом расширив функционал автомата. Тип Event модернизируем

так
template <base_t N, base_t Priority>struct Event : EventBase{  static constexpr base_t idx = N;  static constexpr base_t pri = Priority;};

Теперь мы можем не пушить все события в одну очередь, а завести, скажем, std::array<queue_t, PRIRITY_NUM>, где индексом ячейки будет служить приоритет события. Тогда у нас получится приняв эвент, вытащить его приоритет, по нему, как по индексу за константное время попасть в нужную очередь событий, которая будет гораздо меньше, чем общая и быстрее в обработке.

И, что не менее важно, так мы сможем прыгать между состояниями не по очередности принятых эвентов, но по их приоритету.

На самом деле в следующей версии своего FSM у меня так и реализовано, но здесь я привожу упрощенный вариант.

Хорошо, каков же будет практический результат этой разнузданной шаблонной вакханалии?

Детектор нейтрино(нет)
struct green_a {/*toogle green led every 50ms*/}struct yellow_a {/*toogle yellow led every 50ms*/}struct red_a {/*toogle red led every 50ms*/}struct green_f {/*toogle green led every 150ms*/}struct yellow_f {/*toogle yellow led every 150ms*/}struct red_f {/*toogle red led every 150ms*/}using STATE_A(green_s, green_f);using STATE_A(yellow_s, yellow_f);using STATE_A(red_s, red_f);using EVENT(green_e);using EVENT(yellow_e);using EVENT(red_e);using fsm_table = TransitionTable    <    Tr<green_s, yellow_e, yellow_s, yellow_a, Guard::NO_GUARD>,    Tr<yellow_s, red_e, red_s, red_a, Guard::NO_GUARD>,    Tr<red_s, green_e, green_s, green_a, Guard::NO_GUARD>    >;int main(void){  //some other stuff  StateMachine<fsm_table> fsm;  fsm.push_event(red_e{});  fsm.push_event(yellow_e{});  fsm.push_event(green_e{});  while (1){    fsm.process();  }}

В этом примере структуры типа color_a(ction) - это действия при переходе; color_f(unctor) - функторы, которые будут выполняться каждый раз при заходе в стейт, ну и далее понятно.

Объявляем стейты, эвенты, переходы, таблицу переходов. Конструируем из класса StateMachine<fsm_table> наш конечный автомат fsm. Пушим события, заходим в while и наблюдаем аквасветодискотеку на нашей отладке.

Обращу еще ваше внимание на макросы, через которые организована декларация состояний и событий. Задача была исхитриться и не делать так:

using even_t = Event<1, 15>;

using state_t = State<1, state_functor>;

Очевидно, почему это плохо. Ручная индексация - практически неизбежные ошибки и очепятки.

Что ж, реализуем constexpr функцию, которая будет преобразовывать переданное ей желаемое название типа в число, которым и будем параметризовать шаблон. Ну и спрячем это все в удобную обертку.

Как-то так
#define STATE_A(str, act) str = State<name(#str), act>#define EVENT(str) str = Event<name(#str)>constexpr base_t name (const char* n){    base_t  res = 0;    for (base_t i = 0; n[i] != '\0'; i++){        char data = n[i];        for (base_t j = sizeof (char) * 8; j > 0; j--){            res = ((res ^ data) & 1) ? (res >> 1) ^ 0x8C : (res >> 1);      data >>= 1;    }  }  return res;};

После крайнего проекта на работе у меня на руках осталась отладка NUCLEO-H743ZI2, на ней я и запилил тестовый вариант (забирайте здесь).

С оптимизацией -O3 реализация приведенного примера (только сам FSM) заняла 6,8Кб, с HAL-ом и моргалками - 14,4Кб.

Конечно же, пока это не более чем эксперимент, проверка концепции. Но агрегат завелся, черт его дери.

Будет очень здорово, если сообщество укажет на неизбежные факапы и укажет путь к улучшениям. Также смею надеяться, что кто-то выделит из материала и что-то полезное для себя.

Спасибо за внимание!

Подробнее..

Многопоточный HTTP-сервер с ThreadPoolом и конечным автоматом

23.05.2021 12:20:57 | Автор: admin

Сегодня я расскажу вам про довольно простую, но интересную реализацию многопоточности в HTTP-сервере без создания потока для каждого клиента. На мое удивление информацию про такую реализацию я нашёл с трудом, поэтому решил поделиться с вами. Начнем с описание проблемы.

Проблемы решения "один поток = один клиент"

Проблемы, которые описаны ниже, справедливы как для потоков, так и для процессов, поэтому "один поток = один клиент" также можно расценивать как и "один процесс - один клиент" в данном контексте.

Первая проблема - количество потоков, которые могут быть созданы в программе, ограничено. Следствием этого ограничено и количество пользователей, подключённых к нашему серверу. Такая проблема есть, например, у Apache.

Проблема вторая - один поток занят только одним клиентом. В связи с этим мы получаем неэффективное использование ресурсов. (поток может простаивать, пока ждёт события от клиента)

Плюсом ко всему этому нужно понимать, что создание потока (или процесса) - это довольно тяжелая операция, и иногда она требует затрат больше, чем само обслуживание клиента.

Решение, которое я приведу ниже, закрывает эти проблемы.

Решение есть

Первая проблема решается тем, что количество потоков мы сделаем независимым от количества наших клиентов. Количеством потоков мы управляем сами (оно может устанавливаться как статистически, при запуске сервера, так и динамически, подстраиваясь под нагрузку, например).

Вторая проблема закрывается тем, что потоки не прикреплены к клиентам. Фактически, обслуживание клиента разбивается на задачи, которые решают потоки. Таким образом, мы избавляемся от простоев потоков, если работа для них есть.

Конечный автомат

Первое, что нам понадобится, ввести состояние клиента. Таким образом поток (далее, воркер) будет знать, какой хэндлер нужно вызвать для текущего состояния. Хэндлером может выступать метод, который выполняет характерные для состояния действия. После обработки очередного состояния мы, в зависимости от условий, меняем его.

На каждое состояние у нас есть свой хэндлер. Рассмотрим пример. У клиента четыре состояния: readRequest, generateResponse, sendResponse и closeConnection (чтение запроса, создание ответа, отправка ответа и закрытие соединения, соответственно). На каждое состояние мы имеем хэндлер. readRequest читает и парсит запрос и, в зависимости от успеха чтения и парсинга (например, в зависимости от того, что вернула функция чтения запроса), переключает состояние либо на generateResponse, либо на closeConnection. generateResponse отвечает за генерацию ответа и переключает состояние клиента на sendResponse. sendResponse отправляет ответ клиенту и либо возвращет клиента на состояние readRequest, либо переключает на closeConnection. closeConnection, в свою очередь, просто отключает клиента и удаляет его.

Этот примитивный пример показывает суть принципа. Мы можем добавлять новые состояния клиентов (причем в коде делается это довольно просто: мы просто реализуем новый метод) и переключать их как угодно в зависимости от условий. Вы можете с легкостью разбивать состояние на два отдельных, если чувствуете в этом необходимость. В нашем примере парсинг запроса включен в состояние readRequest и его можно вынести в отдельное состояние - parsingRequest, например.

Это довольно гибкое решение с точки зрения архитектуры. Обратите внимание, что конечный автомат в целом может существовать и без потоков, но с ними ведь все интереснее:)

Довольно неплохая лекция на тему конечных автоматов. Также информации об этом методе в интернете полно, поэтому заострять внимание на деталях мы не будем.

ThreadPool (или пул потоков)

Пул потоков как таковым пулом потоков не является. Скорее он представляет собой пул (в виде очереди, например) задач для этих потоков.

Механика проста: при создании клиента главный процесс добавляет его в пул. Клиентов воркеры рассматривают как некоторую задачу, которую им нужно взять из пула, решить и вернуть обратно. Воркеры находятся в ожидании (активном или нет - решать вам) появления задач в пуле, и как только она появляется там, по принципу кто успел, тот и съел, один из них получает ее (гонку за получение клиента мы конечно же обрамляем мутексами, семафорами и чем угодно ещё). Воркер, в зависимости от состояния клиента, вызывает необходимый хэндлер, переключает состояние клиента и кладёт его обратно в пул. Дальнейшая судьба клиента воркеру неизвестна. Задача воркера - обработать текущее состояние клиента.

Если наш клиент отправил нам только часть запроса, поток в сервере формата "один клиент=один поток" будет ожидать оставшуюся часть запроса. (то есть простаивать) В нашем же случае поток обработает часть запроса и пойдет обрабатывать следующих клиентов, если такие есть (простаивания потока не происходит).

Статья, которая мне в свое время помогла разобраться в пуле потоков. Я реализовывал все это в более упрощенном варианте, но это лишний раз доказывает, что тут есть где разгуляться и шаблон можно с легкостью подстроить под свои задачи:)

Заключение

Как я уже упомянул выше, решение довольно простое и, возможно, его не стоит брать в качестве основы для реализации, но этот вариант отлично подойдёт как шаблон, который можно усовершенствовать и подстроить под себя и свою задачу.

В этот раз я в общих чертах рассказал только про суть подхода. Если вам интересно увидеть продолжение статьи уже с практической частью (подходы к реализации и их подводные камни) - дайте знать об этом:)

На этом все. Делитесь своими вариантами, предложениями, дополнениями и критикой в комментариях! Благодарю за прочтение:)

Несколько полезных ссылок:

http://personeltest.ru/aways/habr.com/ru/post/260065/

http://personeltest.ru/aways/habr.com/ru/company/latera/blog/273283/

http://www.aosabook.org/en/nginx.html

Подробнее..

Оптимизация цифрового автомата (FSM)

29.11.2020 14:18:58 | Автор: admin
О чём пост?

Данный материал представляет краткое описание проблемы в теории цифровых автоматов и объясняет один из способов решения данной проблемы, который был найден при попытке автоматизации процесса построения цифрововых автоматов.

Введение

Автомат система механизмов, устройств, в которой полностью автоматизированы процессы получения, преобразования, передачи энергии, материалов, информации.

Термин <<автомат>> в основном используется в двух аспектах:

  • техническом;

  • математическом.

При математическом подходе под автоматом понимается математическая модель, у которой должны быть входы, внутренние состояния и выходы. Детали структуры устройства не учитываюся и не рассматриваются.

В техническом подходе под автоматом понимается вполне реальное устройство, например, телефонный автомат, торговый автомат и т. д. В данном случае, естественно, известными являются детали внутреннего строения устройства.

С точки зрения сигналов цифровой автомат (ЦА) система, которая может принимать входные сигналы, под их воздействием переходить из одного состояния в другое, сохранять его до прихода следующего входного сигнала, выдавать выходные сигналы.

В данной работе рассматриваются цифровые сигналы и двоичная логика на базе логических элементов.

Структурно-функциональная схема цифрового конечного автоматаСтруктурно-функциональная схема цифрового конечного автомата

Применение

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

Другое важнейшее применение теории автоматов математически строгое нахождение разрешимости и сложности задач.

Автоматы Мура и Мили широко применяются при проектировании цифровых устройств на основе программируемых логических интегральных схем (ПЛИС). Наличие минимальной выходной задержки, связанной с переключением выходного регистра, отсутствие нестабильности переходного процесса на выходе автомата, отсутствие сквозного распространения сигнала через комбинационную схему от входа до выхода автомата, простота описания на языках описания аппаратуры делает автомат Мура практически незаменимым. Также автоматы Мура и взаимодействующие автоматы Мили используются в генетическом программировании.

Описание проблемы

Построение цифрового автомата -- довольно трудоёмкий процесс. Можно выделить следующие этапы разработки ЦА:

1) Очень часто разработка ЦА начинается с реализации графа, который отражает закладываемую логику в простом и понятном для человека виде.

2) Оптимизация графа -- с этой задачей человек может справиться довольно быстро.

3) Определение разрядности памяти. Минимальное число триггеров можно вычислить по формуле:

n=ceil(log_2(S))

где, S -- это число состояний, ceil -- функция приведения значения до ближайшего целого числа, которое не меньше исходного.

4) Присвоение состояниям кодов. Алгоритма для правильного задания кодов для состояний нет. Именно от этого зависит сложность уравнений, которые мы получим для входов триггеров и количество элементов необходимых для сборки схемы.

5) Составление таблицы состояний-переходов.

6) Составление булевых арифметических уравнений для входов триггеров. Карты Карно составляются по таблице состояний-переходов, уравнения минимизируются.

7) Преобразование уравнений для согласования с элементной базой.

8) Разработка электрической схемы.

Основная проблема -- отсутствие алгоритма для задания кодов состояниям автомата таким образом, чтобы уравнения для входов триггеров были как можно проще.

Решение

Была разработана программа для построения цифровых автоматов. На вход программа принимает граф. В программе граф представляется в наборе вершин и рёбер(вершина, входной сигнал, вершина для перехода). Итерируясь по рёбрам составляются таблицы истинности для каждого разряда в СКНФ и СДНФ. Методом Куайна-Мак-Класки минимизируются обе формы уравнений. Для каждого разряда выбирается выражение с минимальным количеством логических операций <<И>>, <<ИЛИ>>. Общее количество этих операций является критерием качества данной кодировки.

Количество возможных вариантов задания состояний можно рассчитать зная разрядность памяти(M) и количество состояний(S).

Количество кодов:

C=2^M;

Количество вариантов выборки(V) нужного количества состояний(S) из всего количества кодов(C), формула из комбинаторики:

V=\frac{C!}{(C-S)! \cdot S!};

Количество возможных вариантов задания состояний(A) равно:

A=S! \cdot V= \frac{C!}{(C-S)!};

Если перебирать все варианты и потом отобрать лучший, то в зависимости от графа программа может исполняться слишком долго. Такой вариант подойдёт для автоматов с небольшим числом вариантов задания кодов состояний.

Для автоматов с большим числом возможных вариантов задания состояний был разработан генетический алгоритм перебора вариантов состояний.

Схема генетического алгоритмаСхема генетического алгоритма

Результаты

Для исследования был спроектирован автомат с числом возможных вариантов задания состояний равным 6720. Для каждого варианта было рассчитано количество необходимых элементов для реализации.

Данный ЦА описывает поведение пчелы (для простоты восприятия), входной сигнал представляет 0(всё спокойно) или 1(пчела видит шершня).

Граф цифрового автомата, описывающий поведение пчелыГраф цифрового автомата, описывающий поведение пчелы

Описание ЦА:

  • Количество состояний: 5

  • Разрядность памяти: ceil(log2(5)) = 3

  • Разрядность входного сигнала: 1

Пример расчёта числа всех возможных вариантов построения автомата:

C=2^M=2^3=8;V=\frac{C!}{(C-S)! \cdot S!}=\frac{8!}{(8-5)! \cdot 5!}=56;A=S! \cdot V= 5! \cdot 56=6720;

Для любой выборки(V) нашлось не менее X(X<S!) перестановок с наилучшим исходом. Наилучший исход -- исход с минимальным числом элементов необходимых для реализации данного автомата. Для поиска способа кодирования c наилучшим исходом достаточно перебрать S! вариантов.

Анализ показал, что наибольшая вероятность встретить автомат с наилучшим исходом -- если количество 0 и 1 в кодах состояний будет равнозначным.

Для сложных автоматов, где перебор занимает много времени, эффективным решением будет применить генетический алгоритм, он не обязательно найдёт наилучший исход, но позволит быстро найти решение близкое к нему.

Подробнее..

Из песочницы Создаем конечный автомат в Elixir и Ecto

20.07.2020 18:22:00 | Автор: admin
Существует много полезных шаблонов проектирования и концепция конечного автомата входит в число полезных шаблонов проектирования.

Конечный автомат отлично подходит в случаях, когда когда вы моделируете сложный бизнес-процесс, в котором происходит переход состояний из предопределенного набора состояний и каждое состояние должно обладать своим предопределенным поведением.

В этой публикации вы узнаете, как реализовать этот шаблон с помощью Elixir и Ecto.

Случаи использования


Конечный автомат может быть отличным выбором когда вы моделируете сложный, многошаговый бизнес-процесс, и где к каждому шагу предъявляются определенные требования.

Примеры:

  • Регистрация в личном кабинете. В этом процессе пользователь сначала регистрируется, потом добавляет некоторую дополнительную информацию, затем подтверждает свою электронную почту, затем включает 2FA, и только после этого получает доступ в систему.
  • Корзина для покупок. Сперва она пустая, потом в неё можно добавить товары и после чего пользователь может перейти к оплате и доставке.
  • Конвейер задач в системах управления проектами. Например: изначально задачи в статусе "создана", потом задача может быть "назначена" исполнителю, затем статус изменится на "в процессе", а затем в "выполнено".

Пример конечного автомата


Приведем небольшой учебный пример, иллюстрирующий работу конечного автомата: работа двери.

Дверь может быть заблокирована или разблокирована. Она также может быть открыта или закрыта. Если она разблокирована, то её можно открыть.

Мы можем смоделировать это как конечный автомат:

image

Этот конечный автомат имеет:

  • 3 возможных состояния: заблокирована, разблокирована, открыта
  • 4 возможных перехода состояния: разблокировать, открыть, закрыть, заблокировать

Из диаграммы можно сделать вывод, что невозможно перейти от заблокирована к открыта. Или простыми словами: сначала нужно разблокировать дверь, а уже потом открыть. Данная диаграмма описывает поведение, но как реализовать это?

Конечные автоматы как Elixir процессы


Начиная с OTP 19, Erlang предоставляет модуль :gen_statem, который позволяет реализовывать процессы, подобные gen_server, которые ведут себя как конечные автоматы (в которых текущее состояние влияет на их внутреннее поведение). Давайте посмотрим, как это будет выглядеть для нашего примера с дверью:

defmodule Door do  @behaviour :gen_statem # Стартуем сервис def start_link do   :gen_statem.start_link(__MODULE__, :ok, []) end  # начальное состояние, вызываемое при старте, locked - заблокировано @impl :gen_statem def init(_), do: {:ok, :locked, nil}  @impl :gen_statem def callback_mode, do: :handle_event_function  # обработка приходящего события: разблокируем заблокированную дверь # next_state - новое состояние - дверь разблокирована @impl :gen_statem def handle_event({:call, from}, :unlock, :locked, data) do   {:next_state, :unlocked, data, [{:reply, from, {:ok, :unlocked}}]} end  # блокировка разблокированной двери def handle_event({:call, from}, :lock, :unlocked, data) do   {:next_state, :locked, data, [{:reply, from, {:ok, :locked}}]} end  # открытие разблокированной двери def handle_event({:call, from}, :open, :unlocked, data) do   {:next_state, :opened, data, [{:reply, from, {:ok, :opened}}]} end  # закрытие открытой двери def handle_event({:call, from}, :close, :opened, data) do   {:next_state, :unlocked, data, [{:reply, from, {:ok, :unlocked}}]} end  # возвращение ошибки при неопределеном поведении def handle_event({:call, from}, _event, _content, data) do   {:keep_state, data, [{:reply, from, {:error, "invalid transition"}}]} endend

Этот процесс начинается в состоянии :locked (заблокировано). Отправляя соответствующие события, мы можем сопоставить текущее состояние с запрошенным переходом и выполнить необходимые преобразования. Дополнительный аргумент data сохраняется для любого другого дополнительного состояния, но в этом примере мы его не используем.

Мы можем вызвать его с нужным нам переходом состояния. Если текущее состояние позволяет этот переход, то он отработает. В противном случае будет возвращена ошибка (из-за последнего обработчика события, которое отлавливает всё, что не соответствует допустимым событиям).

{:ok, pid} = Door.start_link():gen_statem.call(pid, :unlock)# {:ok, :unlocked}:gen_statem.call(pid, :open)# {:ok, :opened}:gen_statem.call(pid, :close)# {:ok, :closed}:gen_statem.call(pid, :lock)# {:ok, :locked}:gen_statem.call(pid, :open)# {:error, "invalid transition"}

Если наш конечный автомат в большей степени ориентирован на данные, чем на процессы, то мы можем использовать другой подход.

Конечные автоматы как Ecto модели


Есть несколько пакетов Elixir, которые решают эту проблему. В этом посте я буду использовать Fsmx, но другие пакеты, например, Machinery, также предоставляют аналогичные функции.

Этот пакет позволяет нам моделировать точно такие же состояния и переходы, но в существующей модели Ecto:

defmodule PersistedDoor do use Ecto.Schema  schema "doors" do   field(:state, :string, default: "locked")   field(:terms_and_conditions, :boolean) end  use Fsmx.Struct,   transitions: %{     "locked" => "unlocked",     "unlocked" => ["locked", "opened"],     "opened" => "unlocked"   }end

Как мы увидеть, Fsmx.Struct получает все возможные переходы в качестве аргумента. Это позволяет ему проверять нежелательные переходы и предотвращать их возникновение. Теперь мы можем изменить состояние, используя традиционный, не-Ecto подход:

door = %PersistedDoor{state: "locked"} Fsmx.transition(door, "unlocked")# {:ok, %PersistedDoor{state: "unlocked", color: nil}}

Но мы можем также попросить то же самое в форме Ecto changeset (используемое в Elixir слово, означающее набор изменений):

door = PersistedDoor |> Repo.one()Fsmx.transition_changeset(door, "unlocked")|> Repo.update()

Этот changeset только обновляет поле :state. Но мы можем расширить его, чтобы включить дополнительные поля и проверки. Допустим, чтобы открыть дверь, нам нужно принять ее условия:

defmodule PersistedDoor do # ...  def transition(changeset, _from, "opened", params) do   changeset   |> cast(params, [:terms_and_conditions])   |> validate_acceptance(:terms_and_conditions) endend

Fsmx ищет в вашей схеме необязательную функцию transition_changeset/4 и вызывает ее как с предыдущим состоянием, так и со следующим. Вы можете сопоставить их по шаблону, чтобы добавить определенные условия для каждого переход.

Работа с побочными эффектами


Перевести конечный автомат из одного состояния в другое это общая задача для конечных автоматов. Но Еще одним большим преимуществом конечных автоматов является способность справляться с побочными эффектами, которые могут иметь место в каждом состоянии.
Допустим, мы хотим получать уведомления каждый раз, когда кто-то открывает нашу дверь. Возможно, мы захотим отправить электронную почту, когда это произойдет. Но мы хотим, чтобы эти две операции были одной атомарной операцией.

Ecto работает с атомарностью через пакет Ecto.Multi, которая группирует несколько операций внутри транзакции базы данных. Ecto также имеет функцию Ecto.Multi.run/3, которая позволяет запускать произвольный код в рамках той же транзакции.

Fsmx, в свою очередь, интегрируется с Ecto.Multi, предоставляя вам возможность выполнять переходы состояний как часть Ecto.Multi, а также предоставляет дополнительный обратный вызов, который выполняется в этом случае:

defmodule PersistedDoor do # ...  def after_transaction_multi(changeset, _from, "unlocked", params) do   Emails.door_unlocked()   |> Mailer.deliver_later() endend

Теперь вы можете выполнить переход как показано:

door = PersistedDoor |> Repo.one() Ecto.Multi.new()|> Fsmx.transition_multi(schema, "transition-id", "unlocked")|> Repo.transaction()

Эта транзакция будет использовать тот же transition_changeset/4, как было описано выше, для вычисления необходимых изменений в Ecto модели. И будет включать новый обратный вызов в качестве вызова Ecto.Multi.run. В результате электронное письмо отправляется (асинхронно, с использованием Bamboo, чтобы не запускаться внутри самой транзакции).

Если changeset (набор изменений) по какой-либо причине признан недействительным, электронное письмо никогда не будет отправлено, в результате атомарного выполнения обеих операций.

Заключение


В следующий раз, когда вы моделируете какое-то поведение с состоянием, подумайте о подходе с использование шаблона конечного автомата (машины конечных состояний), для вас этот шаблон может стать хорошим помощником. Он одновременно и прост и эффективен. Этот шаблон позволит смоделированную диаграмму переходов состояния легко выразить в виде программного кода, что ускорит разработку.

Сделаю оговорку, возможно акторная модель способствует простоте реализации конечного автомата в Elixir\Erlang, каждый актор имеет своё состояние и очередь входящих сообщений, которые последовательно изменяют его состояние. В книге Проектирование масштабируемых систем в Erlang/ОТР про конечные автоматы очень хорошо написано, в разрезе акторной модели.

Если у вас есть собственные примеры реализации конечных автоматов на вашем языке программирования, то прошу поделиться ссылкой, будет интересно изучить.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru