gen_statem

gen_statem — обобщённое поведение моделирования событийно-управляемых конечных автоматов.

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

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

То же самое касается и выходного действия. Оно является функцией входного события и предыдущего состояния.

Основные понятия из области конечных автоматов:

Входные события приходят из внешней среды, выходные действия автомата уходят туда же.

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

Событие, переход состояния и действие

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

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

Представим элементарный кодовый замок. Дверь откроется, если последовательно нажать кнопки “1”, “2” и “3”. Если последовательность нарушена, сразу же происходит сброс, и надо будет заново вводить код. На диаграмме состояний это можно представить так:

Кодовый замок, диаграмма состояний

Начальное состояние — С. Начальное состояние С плюс входное событие “1” переводят кодовый замок в состояние С1. Состояние С1 плюс событие “2” переводят замок в состояние С12. Ну и С12 плюс “3” приводят к С123, а также действию “Открыть задвижку”.

Код может быть введён неверно, и тогда, например, С1 плюс “9” переведёт кодовый замок в начальное состояние С.

Диаграммы состояний, когда они небольшие, являются достаточно наглядным инструментом для того, чтобы определить необходимость и достаточность всех состояний и переходов между ними. В частности, в нашем примере с кодовым замком видна проблема. Из состояния С123 наш автомат уже никогда не выберется. Нам придётся решить эту проблему. Или мы признаем, что наш кодовый замок одноразовый (что тоже не надо исключать), или добавим событие вроде “Дверь захлопнулась”, переводящее автомат в состояние С.

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

@startuml
[*] --> С
hide empty description
С --> С1 : "1"
С1 --> С12 : "2"
С12 --> С123 : "3"
С123 -> С : "Дверь захлопнулась"
С --> С : Др.
С1 --> С : Др.
С12 --> С : Др.
С123 : Открывается задвижка
@enduml

А на выходе получаем нужную нам диаграмму. (Мы добавили событие “Дверь захлопнулась”.)

Кодовый замок, диаграмма plantuml

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

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

Взять наш пример с кодовым замком. И так, в принципе, понятно, что надёжность его низкая. Диаграмма может подсказать слабое звено: как преступник будет подбирать код. Если он будет получать обратную связь при переходе в состояние С, он очень быстро найдёт путь от состояния С к состоянию С1 (код “1”). Обладая новым знанием, он будет вводить “10”, “11”, “12” и т.д. Очень скоро он обнаружит путь С1 -> С12, а потом и С12 -> С123. Значит, сделаем мы вывод, обратную связь давать нельзя.

Но тут возникнет сомнение другого рода. Допустим, подошёл к кодовому замку новый человек, а кодовый замок в это время находится не в состоянии С, а в состоянии С1 (предыдущий человек нажал единицу и ушёл). Новый человек вводит “123” и не попадает — с каждым нажатием автомат переходит в состояние С. Если человек снова введёт “123”, кодовый замок откроет дверь, но впечатление от кодового замка будет подпорчено. А если код будет не 123, а 121? Даже если человек несколько раз подряд наберёт “121”, дверь не откроется, если первоначальное состояние было С1. Мы тогда вынуждены будем принять какое-то конструктивное решение: или по таймауту переводить автомат в состояние С, или заставить пользователя перед вводом кода нажать звёздочку на цифровой клавиатуре, которая переведёт автомат в состояние С.

Обладает ли наш кодовый замок памятью? В каком-то смысле да. Если он находится в состоянии С12, значит, до этого были введены цифры “12”. Однако в узком смысле — нет. Нет у кодового замка ячейки памяти, в которой сохраняются введённые цифры. А нам, возможно, как раз захочется иначе определить наш автомат: чтобы он сначала просто запоминал череду цифр, а потом — когда ввод завершён — сравнивал эту череду с образцом и открывал задвижку или нет. Если ячейка памяти вмещает три цифры, автомат сможет находиться в тысяче разных возможных состояний, четыре цифры — в десяти тысячах состояний.

Это уже будет не совсем конечный автомат, однако gen_statem умеет работать с разными автоматами.

Минимальная реализация gen_statem

Поведение gen_statem требует, чтобы в модуле были как минимум следующие две функции: callback_mode/0 и init/1. Первая задаёт режим работы машины состояний в самом начале её работы, одно из двух:

Начинать, пожалуй, лучше с первого варианта. В нём, мне кажется, более наглядно проявляется особенность программирования машины состояний. Раньше в Эрланге был популярен модуль gen_fsm, предшественник gen_statem (FSM — finite state machine, машина конечных состояний). Там как раз использовался первый подход.

init/1 — колбек, запускаемый автоматически при создании актора (после его успешной регистрации). Эта функция принимает терм аргументов, который определяет некоторую специфику. В нашем случае её не будет.

Всегда интереснее запускать акторы в составе древа надзора, поэтому будем использовать gen_statem:start_link, а не gen_statem:start. Первый аргумент у gen_statem:start_link — имя в системе, задаваемая с помощью кортежа {local,Имя} или {global,Имя}. Во втором случае это имя будет доступно не на одной ноде, а в кластере нод. Второй аргумент — имя колбек-модуля. Третий — терм аргументов, который будет передан в init/1. Четвёртый — дополнительные опции при создании сервера, нам они тоже пока не понадобятся.

В нашем примере всё воздействие на внешнюю среду симулируется с помощью io:format. Когда она сообщает, что дверь закрылась, значит, наш автомат закрыл дверь. Первое такое событие происходит в самом начале работы машины состояний, а именно в функции init/1:

-module(code_lock).
-behaviour(gen_statem).
-export([init/1, callback_mode/0, start_link/0]).
-export([state_null/3, state_1/3, state_12/3, state_123/3]).

start_link() ->
    gen_statem:start_link({local,?MODULE}, ?MODULE, [], []).

init(_) ->
    io:format("Дверь закрылась.~n"),
    {ok, state_null, #{}}.

callback_mode() -> state_functions.

state_null(cast, {button,1}, Data) -> {next_state, state_1, Data};
state_null(cast, {button,_}, Data) -> {next_state, state_null, Data}.

state_1(cast, {button,2}, Data) -> {next_state, state_12, Data};
state_1(cast, {button,_}, Data) -> {next_state, state_null, Data}.

state_12(cast, {button,3}, Data) -> 
    io:format("Дверь открылась.~n"),
    {next_state, state_123, Data, [{state_timeout,10_000,lock}]};
state_12(cast, {button,_}, Data) -> {next_state, state_null, Data}.

state_123(cast, {button,_}, Data) -> {next_state, state_123, Data};
state_123(state_timeout, lock, Data) ->
    io:format("Дверь закрылась.~n"), 
    {next_state, state_null, Data}.

Функция init/1 делает ещё две важные вещи. Как ей и положено, она возвращает кортеж {ok, Состояние, Данные}. Состояние — это состояние, в которое надо немедленно перейти. В данном случае это state_null — состояние С на нашей диаграмме, то есть состояние, при котором замок закрыт и не введено ещё ни одной нужной цифры кода. Данные — это терм внутренних данных актора, они передаются от одного состояния к другому. Здесь могло бы быть что-нибудь полезное вроде названия этой двери или история заходов, но нам сейчас не до жиру. Мы изначально задаём внутренние данные как пустую карту, потом они не меняются.

Наш сервер, основанный на gen_statem, готов к рутине. В init мы определили, что пока наша машина состояний должна пребывать в состоянии state_null, поэтому когда будет получено какое-то событие, оно будет обработано колбеком state_null/3. Первый аргумент — тип события. В нашем случае мы ожидаем только cast-сообщения, поэтому обрабатываем только cast. Второй аргумент — сообщение. Третий — внутренние данные, которые передаются от состояния к состоянию.

Если придёт сообщение {button,1}, тогда случится переход состояния — следующее состояние будет state_1, и именно функция state_1 будет обрабатывать следующее событие. Если была нажата какая-то другая кнопка, состояние не изменится. Об этом говорит кортеж {next_state, state_null, Data}. Первый атом — команда (могут быть разные), второй — следующее состояние. Третий — внутренние данные.

Состояние state_1 похоже на state_null. Отличие разве что в том, что при нажатии неправильной кнопки происходит не сохранение текущего состояния, а откат к предыдущему состоянию.

Состояние state_12 наступает после нажатия двух правильных кнопок (1 и 2). Стоит нажать ещё 3 — и дверь откроется. Это мы и реализовали прямо в функции state_12, когда она получает тройку, — перед переходом к следующему состоянию (state_123).

Этот переход к состоянию state_123 отличается от предыдущих — в выходной кортеж добавился четвёртый элемент, а именно список. Это список так называемых действий перехода, то есть действий, совершаемых при переходе от одного состояния к другому. Каждое действие перехода задаётся кортежем. В нашем случае это {state_timeout,10_000,lock}. Действие означает следующее: будет запущен таймер, по истечении 10 секунд следующее состояние получит сообщение lock.

И вот наступило последнее из возможных состояний — state_123. Возможно, кто-то продолжит нажимать кнопки замка, хотя он уже открыт. Мы обрабатываем эти события, но они ничто не меняют. Вскоре, однако, наша функция state_123 получает новое событие. Тип этого события — state_timeout (то есть таймаут закончился). Сообщение — lock, то есть надо закрыть замок. Ну и третий аргумент — привычные внутренние данные. Когда это событие случится, мы совершаем внешнее действие (в данном случае просто пишем, что дверь закрылась), после этого автомат снова переходит в состояние state_null. И всё начинается с начала.

Посмотрим работу нашего кодового замка в действии.

$ erl +pc unicode
1> code_lock:start_link().
Дверь закрылась.
{ok,<0.85.0>}
2> gen_statem:cast(code_lock, {button,1}).
ok
3> gen_statem:cast(code_lock, {button,2}).
ok
4> gen_statem:cast(code_lock, {button,3}).
Дверь открылась.
ok
5> gen_statem:cast(code_lock, {button,3}).
ok
Дверь закрылась.
6> gen_statem:cast(code_lock, {button,7}).
ok
7> gen_statem:cast(code_lock, {button,1}).
ok
8> gen_statem:cast(code_lock, {button,1}).
ok
9> gen_statem:cast(code_lock, {button,2}).
ok
10> gen_statem:cast(code_lock, {button,3}).
ok
11> gen_statem:cast(code_lock, {button,1}).
ok
12> gen_statem:cast(code_lock, {button,2}).
ok
13> gen_statem:cast(code_lock, {button,3}).
Дверь открылась.
ok
Дверь закрылась.

Кодовый замок работает, как ему и положено. Если нам захочется, можем его развить. Например, нам может не нравиться, что код замка зашит прямо в программе. Если мы пожелаем сменить код, придётся менять колбек-модуль. В этом случае мы можем определять код при запуске актора: init будет принимать строку вида “789”, разбивать её на цифры и требуемые цифры передавать через Data. Например, из этой карты по ключу first будет доступна первая требуемая цифра. И тогда функции состояний будут выглядеть примерно так:

state_null(cast, {button,N}, #{first := N} = Data) ->
    {next_state, state_1, Data};

Если нам надо при переходе совершить какое-либо внешнее действие, для этого есть три возможности:

Может быть, возникнет потребность в синхронных вызовах (call, а не cast), которые будут посылаться с помощью gen_statem:call/2-3. При синхронных вызовах вызывающая функция будет ждать, пока не получит требуемого. Чтобы она это получила, придётся в свой код вставить явную отсылку ответа. Для этого есть два способа: ответить с помощью gen_statem:reply(From, Mess) или с помощью действия перехода {reply, From, Mess}.

Будут приходить новые события — не типа cast, а типа {call,From}. Значит, всем колбекам состояний придётся добавить кляуз для обработки этих событий. В нашем случае придётся добавить по две кляузы: на случай правильной цифры и на случай неправильной цифры:

state_null({call,From}, {button,1}, Data) ->
    gen_statem:reply(From, ok),
    {next_state, state_1, Data};
state_null({call,From}, {button,_}, Data) ->
    {next_state, state_null, Data, [{reply,From,ok}]};
state_null(cast, {button,1}, Data) -> {next_state, state_1, Data};
state_null(cast, {button,_}, Data) -> {next_state, state_null, Data}.

В первой кляузе для ответа я использовал функцию reply из gen_statem, а во второй — действие перехода.

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

gen_statem реализует машину состояний, и он удобнее как минимум в том, что позволяет группировать в колбек-функции ответы, относящиеся к одному состоянию. В ген-сервере группировка проходит по типу запроса: call, cast или info. В целом ген-статем выглядит сложнее и удобнее для реализации сложных проектов. У него более богатый инструментарий:

Если сервер на основе gen_server имеет смысл держать один (однотипный), то машину состояний иногда имеет смысл множить. Если у неё несколько клиентов (допустим, люди-игроки или люди-покупатели), тогда будет правильным для каждого клиента породить отдельный актор с отдельной машиной состояний. Ведь один клиент будет занят одним, и у него одно своё состояние, а другой клиент занят другим, и у него своё состояние.

Документация

h(gen_statem)


Copyright © 2025 Алексей Карманов