查看源码 gen_statem 行为

建议您同时阅读 STDLIB 中 gen_statem 的参考手册。

事件驱动状态机

已建立的自动机理论并没有过多关注*状态转换*是如何触发的,而是假设输出是输入(和状态)的函数,并且它们是某种类型的值。

对于事件驱动状态机,输入是触发*状态转换*的*事件*,输出是在*状态转换*期间执行的动作。类似于有限状态机的数学模型,它可以被描述为以下形式的一组关系:

State(S) x Event(E) -> Actions(A), State(S')

这些关系解释如下:如果我们处于状态 S,并且发生事件 E,我们将执行动作 A,并转换到状态 S'。请注意,S' 可以等于 S,并且 A 可以为空。

gen_statem 中,我们将*状态更改*定义为新的状态 S' 与当前状态 S 不同的*状态转换*,其中“不同”意味着 Erlang 的严格不等式:=/= 也称为“不匹配”。gen_statem 在*状态更改*期间执行的操作比其他*状态转换*期间多。

由于 AS' 仅取决于 SE,此处描述的状态机是一种米利机(例如,请参阅维基百科文章 米利机)。

与大多数 gen_ 行为类似,gen_statem 除了状态之外,还保留一个服务器 Data 项。由于此数据项,并且由于对状态数量(假设有足够的虚拟机内存)或不同输入事件的数量没有限制,因此使用此行为实现的状态机是图灵完备的。但它感觉更像是一个事件驱动的米利机。

日常状态机

一个可以用状态机建模的日常设备示例是经典的圆珠笔,即可伸缩型,您按下笔端以露出笔尖,按下侧面以缩回笔尖。(按压式圆珠笔也是一个例子,但那种类型只有一个事件,因此不太有趣)

Ballpoint Pen

---
title: Ballpoint Pen State Diagram
---
stateDiagram-v2
    [*]       --> Retracted
    Retracted --> Retracted : push-side
    Retracted --> Exposed   : push-end\n* Expose tip
    Exposed   --> Retracted : push-side\n* Retract tip
    Exposed   --> Exposed   : push-end

状态图显示了状态、事件和带有转换操作的状态转换。请注意,当笔尖露出时按下笔端,或者当笔尖缩回时按下侧面,都不会改变状态或导致任何操作,这通过返回到同一状态的箭头建模。

何时使用 gen_statem

如果您的进程逻辑方便描述为状态机,并且您需要以下 gen_statem 的任何关键功能,则应考虑使用 gen_statem 而不是 gen_server

对于不需要这些功能的简单状态机,gen_server 非常适合。它的调用开销也更小,但我们在这里讨论的是大约 2 微秒和 3.3 微秒的调用往返时间,因此如果服务器回调的操作稍微多一点,或者如果调用不非常频繁,那么这种差异将很难被注意到。

回调模块

回调模块 包含实现状态机的函数。当事件发生时,gen_statem 行为引擎使用事件、当前状态和服务器数据调用 回调模块 中的函数。此回调函数执行事件的操作,并返回新状态和服务器数据,以及行为引擎要执行的操作。

行为引擎保存状态机状态、服务器数据、定时器引用、推迟的消息队列和其他元数据。它接收所有进程消息,处理系统消息,并使用状态机特定事件调用 回调模块

可以使用任何 转换操作 {change_callback_module, NewModule}, {push_callback_module, NewModule}, 或 pop_callback_module 为正在运行的服务器更改 回调模块

注意

切换回调模块是一件非常深奥的事情...

此功能的起源是一个协议,该协议在版本协商后,根据协议版本分支到完全不同的状态机。可能还有其他用例。请注意,新的回调模块会完全替换之前的回调模块,因此所有相关的回调函数都必须处理来自之前回调模块的状态和数据。

回调模式

gen_statem 行为支持两种 回调模式

回调模式回调模块 的一个属性,在服务器启动时设置。它可能会因代码升级/降级而更改,或者在更改 回调模块 时更改。

请参阅 状态回调 部分,其中描述了事件处理回调函数。

通过实现一个强制性回调函数 Module:callback_mode() 来选择 回调模式,该函数返回 回调模式 之一。

Module:callback_mode() 函数还可以返回包含 回调模式 和原子 state_enter 的列表,在这种情况下,状态进入调用 会为 回调模式 激活。

选择回调模式

简而言之:选择 state_functions - 它最像 gen_fsm。但是,如果您不希望状态必须是原子的限制,或者您不希望为每个状态编写一个 状态回调 函数,请继续阅读...

两种 回调模式 提供了不同的可能性和限制,但有一个共同的目标:处理事件和状态的所有可能组合。

例如,可以通过一次关注一个状态,并确保每个状态都处理所有事件来完成此操作。或者,您可以一次关注一个事件,并确保在每个状态中都处理它。您也可以混合使用这些策略。

使用 state_functions,您只能使用原子状态,并且 gen_statem 引擎会根据状态名称为您进行分支。这鼓励 回调模块 将特定于一个状态的所有事件操作的实现放在代码中的同一位置,从而一次关注一个状态。

当您有一个规则的状态图时,此模式非常适合,例如本章中的状态图,它以可视方式描述属于一个状态的所有事件和操作,并且每个状态都有其唯一的名称。

使用 handle_event_function,您可以自由地混合策略,因为所有事件和状态都在同一个回调函数中处理。

当您想要一次关注一个事件或一次关注一个状态时,此模式同样有效,但函数 Module:handle_event/4 会快速变得太大,以至于无法在不分支到辅助函数的情况下处理。

该模式允许使用非原子状态,例如复杂状态,甚至是分层状态。请参阅 复杂状态 部分。例如,如果协议的客户端和服务器端的状态图大致相同,则可以具有状态 {StateName, server}{StateName, client},并使 StateName 确定在代码中何处处理状态中的大多数事件。然后,元组的第二个元素用于选择是否处理特殊的客户端或服务器端事件。

状态回调

状态回调 是处理当前状态中的事件的回调函数,并且该函数是什么取决于 回调模式

状态是状态回调本身的名称,或者是 handle_event() 回调的参数。其他参数是 EventType 和依赖于事件的 EventContent,两者都在 事件类型和事件内容 部分中描述,最后一个参数是当前的服务器 Data

状态进入调用 (请参阅该部分)也由事件处理程序处理,并且具有略微不同的参数。

状态回调 的返回值在 Module:StateName/3 的描述中定义,该描述位于 gen_statem 中。下面是一个可能更易读的列表。

  • {next_state, NextState, NewData [, Actions]} 设置下一个状态并更新服务器数据。如果使用了 Actions 字段,则执行 转换动作 (请参阅该部分)。空的 Actions 列表等同于不返回该字段。

    如果 NextState =/= State,则表示发生了状态更改,并且 gen_statem 会执行一些额外的操作:事件队列会从最早的 延迟事件 重新启动,任何当前的 状态超时 都会被取消,并且如果启用了 状态进入调用,则会执行该调用。当前的 State状态进入调用中会变为 OldState

  • {keep_state, NewData [, Actions]}next_state 值相同,其中 NextState =:= State,即没有发生状态更改

  • keep_state_and_data | {keep_state_and_data, Actions}keep_state 值相同,其中 NextData =:= Data,即服务器数据没有更改。

  • {repeat_state, NewData [, Actions]} | repeat_state_and_data |{repeat_state_and_data, Actions}keep_statekeep_state_and_data 值相同,但是如果启用了 状态进入调用,则会重复该调用,就像再次进入此状态一样。在这种情况下,StateOldState 在重复的状态进入调用中会相等,因为状态是从自身重新进入的。

  • {stop, Reason [, NewData]}Reason 原因停止服务器。如果使用了 NewData 字段,则先更新服务器数据。

  • {stop_and_reply, Reason, [NewData, ] ReplyActions}stop 值相同,但首先执行给定的 转换动作,这些动作只能是回复动作。

初始状态

为了确定初始状态,在调用任何 状态回调 之前,会先调用 Module:init(Args) 回调函数。此函数的行为类似于状态回调函数,但是它会从 gen_statemstart/3,4start_link/3,4 函数中获取其唯一参数 Args,并返回 {ok, State, Data}{ok, State, Data, Actions}。如果您在此函数中使用 postpone 动作,则该动作将被忽略,因为没有事件可以延迟。

转换动作

在第一部分(事件驱动的状态机)中,动作被视为通用状态机模型的一部分。这些通用动作是通过回调模块 gen_statem 在事件处理回调函数中执行的代码来实现的,这些代码在返回到 gen_statem 引擎之前执行。

还有更具体的转换动作,回调函数可以命令 gen_statem 引擎在回调函数返回后执行这些动作。这些动作通过从 回调函数 返回的 返回值 中的 动作 列表来命令。以下是可能的转换动作

有关详细信息,请参阅模块 gen_statem 的类型 action()。例如,您可以回复多个调用者,生成多个下一个事件,并设置超时以使用绝对时间而不是相对时间(使用 Opts 字段)。

在这些转换动作中,唯一立即执行的动作是 reply,用于回复调用者。其他动作会在状态转换期间稍后收集并处理。插入事件 会被存储并一起插入,其余的动作会设置转换选项,其中特定类型的最后一个动作会覆盖之前的动作。有关类型 transition_option() 的描述,请参阅模块 gen_statem 中对状态转换的描述。

不同的 超时next_event 动作会生成具有相应 事件类型和事件内容 的新事件。

事件类型和事件内容

事件被分为不同的 事件类型。给定状态的所有类型的事件都在同一回调函数中处理,该函数将 EventTypeEventContent 作为参数。 EventContent 的含义取决于 EventType

以下是事件类型及其来源的完整列表:

状态进入调用

如果启用了 gen_statem 行为,无论回调模式如何,都可以在状态更改时自动调用具有特殊参数的 状态回调,以便您可以在其余状态转换规则附近编写状态进入动作。它通常看起来像这样:

StateName(enter, OldState, Data) ->
    ... code for state enter actions here ...
    {keep_state, NewData};
StateName(EventType, EventContent, Data) ->
    ... code for actions here ...
    {next_state, NewStateName, NewData}.

由于状态进入调用不是一个事件,因此对允许的返回值和状态转换动作有限制。您不能更改状态,推迟此非事件,插入任何事件,或更改回调模块

gen_statem:init/1之后进入的第一个状态将获得一个状态进入调用,其中OldState等于当前状态。

您可以使用状态回调返回的{repeat_state,...}返回值重复状态进入调用。在这种情况下,OldState也将等于当前状态。

根据您的状态机的指定方式,这可能是一个非常有用的功能,但它强制您处理所有状态下的状态进入调用。另请参阅状态进入动作部分。

超时

gen_statem中的超时是从状态转换期间的转换动作开始的,即从状态回调退出时。

gen_statem中有 3 种类型的超时

当启动一个超时时,任何正在运行的相同类型的超时(state_timeout{timeout, Name}timeout)都会被取消,也就是说,超时会使用新的时间和事件内容重新启动。

所有超时都有一个EventContent,它是启动超时的转换动作的一部分。不同的EventContent不会创建不同的超时。当超时到期时,EventContent会被传递给状态回调

取消超时

使用infinity时间值启动超时将永远不会超时,这是通过甚至不启动它来优化的,并且任何具有相同标记的正在运行的超时都将被取消。在这种情况下,EventContent将被忽略,因此将其设置为undefined是有意义的。

取消超时的更明确的方法是在转换动作中使用{TimeoutType, cancel}的形式。

更新超时

在超时运行时,可以使用转换动作{TimeoutType, update, NewEventContent}的形式更新其EventContent

如果在没有运行此类TimeoutType时使用此功能,则会立即传递超时事件,就像启动零超时一样。

零超时

如果超时启动时时间为0,则实际上不会启动。相反,超时事件将立即插入,以便在任何已排队事件之后以及在任何尚未接收的外部事件之前进行处理。

请注意,某些超时会自动取消,因此,如果您例如将推迟 状态更改中的事件与启动时间为0事件超时相结合,则不会插入超时事件,因为事件超时会被由于状态更改而传递的已推迟事件取消。

示例

带有密码锁的门可以看作是一个状态机。最初,门是锁着的。当有人按下按钮时,会生成一个{button, Button}事件。在下面的状态图中,“收集按钮”表示存储按钮,最多存储到正确代码中的按钮数量;附加到长度限制的列表。如果正确,门将解锁 10 秒。如果不正确,我们将等待按下新按钮。

---
title: Code Lock State Diagram
---
stateDiagram-v2
    state check_code <<choice>>

    [*]         --> locked : * do_lock()\n* Clear Buttons

    locked      --> check_code : {button, Button}\n* Collect Buttons
    check_code  --> locked     : Incorrect code
    check_code  --> open       : Correct code\n* do_unlock()\n* Clear Buttons\n* Set state_timeout 10 s

    open        --> open   : {button, Digit}
    open        --> locked : state_timeout\n* do_lock()

此密码锁状态机可以使用gen_statem来实现,并使用以下回调模块

-module(code_lock).
-behaviour(gen_statem).
-define(NAME, code_lock).

-export([start_link/1]).
-export([button/1]).
-export([init/1,callback_mode/0,terminate/3]).
-export([locked/3,open/3]).

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

button(Button) ->
    gen_statem:cast(?NAME, {button,Button}).

init(Code) ->
    do_lock(),
    Data = #{code => Code, length => length(Code), buttons => []},
    {ok, locked, Data}.

callback_mode() ->
    state_functions.
locked(
  cast, {button,Button},
  #{code := Code, length := Length, buttons := Buttons} = Data) ->
    NewButtons =
        if
            length(Buttons) < Length ->
                Buttons;
            true ->
                tl(Buttons)
        end ++ [Button],
    if
        NewButtons =:= Code -> % Correct
	    do_unlock(),
            {next_state, open, Data#{buttons := []},
             [{state_timeout,10_000,lock}]}; % Time in milliseconds
	true -> % Incomplete | Incorrect
            {next_state, locked, Data#{buttons := NewButtons}}
    end.
open(state_timeout, lock,  Data) ->
    do_lock(),
    {next_state, locked, Data};
open(cast, {button,_}, Data) ->
    {next_state, open, Data}.
do_lock() ->
    io:format("Lock~n", []).
do_unlock() ->
    io:format("Unlock~n", []).

terminate(_Reason, State, _Data) ->
    State =/= locked andalso do_lock(),
    ok.

代码将在下一节中解释。

启动 gen_statem

在上一节的示例中,gen_statem是通过调用code_lock:start_link(Code)来启动的。

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

start_link/1调用函数gen_statem:start_link/4,该函数会生成并链接到一个新进程,即gen_statem

  • 第一个参数{local,?NAME}指定名称。在这种情况下,gen_statem通过宏?NAME在本地注册为code_lock

    如果省略名称,则不会注册gen_statem。相反,必须使用其 pid。该名称也可以指定为{global, Name},然后使用 Kernel 中的global:register_name/2注册gen_statem

  • 第二个参数?MODULE回调模块的名称,即回调函数所在的模块,即此模块。

    接口函数(start_link/1button/1)与回调函数(init/1locked/3open/3)位于同一模块中。通常,良好的编程实践是将客户端代码和服务器端代码包含在同一模块中。

  • 第三个参数Code是一个数字列表,它是传递给回调函数init/1的正确解锁代码。

  • 第四个参数[]是一个选项列表。有关可用选项,请参阅gen_statem:start_link/3

如果名称注册成功,则新的gen_statem进程将调用回调函数code_lock:init(Code)。此函数应返回{ok, State, Data},其中Stategen_statem的初始状态,在本例中为locked;假设门一开始是锁着的。Datagen_statem的内部服务器数据。这里的服务器数据是一个map(),其中键code存储正确的按钮序列,键length存储其长度,键buttons存储收集到的按钮,直到相同的长度。

init(Code) ->
    do_lock(),
    Data = #{code => Code, length => length(Code), buttons => []},
    {ok, locked, Data}.

函数gen_statem:start_link/3,4是同步的。它在gen_statem初始化并准备好接收事件之前不会返回。

如果gen_statem是监管树的一部分,即由监管器启动,则必须使用函数gen_statem:start_link/3,4。函数gen_statem:start/3,4可用于启动独立的gen_statem,这意味着它不是监管树的一部分。

函数Module:callback_mode/0回调模块选择CallbackMode,在本例中为state_functions。也就是说,每个状态都有自己的处理函数

callback_mode() ->
    state_functions.

处理事件

使用gen_statem:cast/2来实现通知密码锁按钮事件的函数

button(Button) ->
    gen_statem:cast(?NAME, {button,Button}).

第一个参数是gen_statem的名称,并且必须与启动它时使用的名称一致。因此,我们使用与启动时相同的宏?NAME{button, Button}是事件内容。

事件将发送到gen_statem。当接收到事件时,gen_statem调用StateName(cast, Event, Data),该函数应返回一个元组{next_state, NewStateName, NewData},或者{next_state, NewStateName, NewData, Actions}StateName是当前状态的名称,NewStateName是下一个状态的名称。NewDatagen_statem的服务器数据的新值,Actionsgen_statem引擎要执行的操作列表。

locked(
  cast, {button,Button},
  #{code := Code, length := Length, buttons := Buttons} = Data) ->
    NewButtons =
        if
            length(Buttons) < Length ->
                Buttons;
            true ->
                tl(Buttons)
        end ++ [Button],
    if
        NewButtons =:= Code -> % Correct
	    do_unlock(),
            {next_state, open, Data#{buttons := []},
             [{state_timeout,10_000,lock}]}; % Time in milliseconds
	true -> % Incomplete | Incorrect
            {next_state, locked, Data#{buttons := NewButtons}}
    end.

locked状态下,当按下按钮时,会将该按钮与之前按下的按钮一起收集,直到正确代码的长度,然后与正确代码进行比较。根据结果,门要么解锁,gen_statem进入open状态,要么门保持在locked状态。

当切换到 open 状态时,已收集的按钮会被重置,锁被解锁,并启动一个 10 秒的状态超时

open(cast, {button,_}, Data) ->
    {next_state, open, Data}.

open 状态下,按钮事件会被忽略,保持在同一状态。这也可以通过返回 {keep_state, Data} 来实现,或者在这种情况下,由于 Data 没有改变,可以通过返回 keep_state_and_data 来实现。

状态超时

当输入正确的代码后,门被解锁,并且从 locked/2 返回以下元组

{next_state, open, Data#{buttons := []},
 [{state_timeout,10_000,lock}]}; % Time in milliseconds

10,000 是一个以毫秒为单位的超时值。经过这段时间(10秒)后,会发生超时。然后,会调用 StateName(state_timeout, lock, Data)。超时发生在门处于 open 状态 10 秒后。之后门会被再次锁定。

open(state_timeout, lock,  Data) ->
    do_lock(),
    {next_state, locked, Data};

当状态机进行状态改变时,状态超时的计时器会自动取消。

您可以重启、取消或更新状态超时。详情请参见 超时 部分。

所有状态事件

有时,事件可能在 gen_statem 的任何状态下到达。在所有状态函数调用以处理非特定于该状态的事件时,在一个通用的状态处理函数中处理这些事件是很方便的。

考虑一个返回正确代码长度的 code_length/0 函数。我们将所有非特定于状态的事件分派到通用函数 handle_common/3

...
-export([button/1,code_length/0]).
...

code_length() ->
    gen_statem:call(?NAME, code_length).

...
locked(...) -> ... ;
locked(EventType, EventContent, Data) ->
    handle_common(EventType, EventContent, Data).

...
open(...) -> ... ;
open(EventType, EventContent, Data) ->
    handle_common(EventType, EventContent, Data).

handle_common({call,From}, code_length, #{code := Code} = Data) ->
    {keep_state, Data,
     [{reply,From,length(Code)}]}.

另一种方法是通过一个方便的宏 ?HANDLE_COMMON/0 来实现

...
-export([button/1,code_length/0]).
...

code_length() ->
    gen_statem:call(?NAME, code_length).

-define(HANDLE_COMMON,
    ?FUNCTION_NAME(T, C, D) -> handle_common(T, C, D)).
%%
handle_common({call,From}, code_length, #{code := Code} = Data) ->
    {keep_state, Data,
     [{reply,From,length(Code)}]}.

...
locked(...) -> ... ;
?HANDLE_COMMON.

...
open(...) -> ... ;
?HANDLE_COMMON.

此示例使用了 gen_statem:call/2,它会等待服务器的回复。回复会在保留当前状态的 {keep_state, ...} 元组的操作列表中通过 {reply, From, Reply} 元组发送。当您想保持在当前状态,但不了解或不关心它是什么状态时,这种返回形式很方便。

如果通用的状态回调需要知道当前状态,则可以使用函数 handle_common/4 代替

-define(HANDLE_COMMON,
    ?FUNCTION_NAME(T, C, D) -> handle_common(T, C, ?FUNCTION_NAME, D)).

单一状态回调

如果使用 回调模式 handle_event_function,所有事件都会在 Module:handle_event/4 中处理,我们可以(但不是必须)使用以事件为中心的方法,首先根据事件进行分支,然后根据状态进行分支

...
-export([handle_event/4]).

...
callback_mode() ->
    handle_event_function.

handle_event(cast, {button,Button}, State, #{code := Code} = Data) ->
    case State of
	locked ->
            #{length := Length, buttons := Buttons} = Data,
            NewButtons =
                if
                    length(Buttons) < Length ->
                        Buttons;
                    true ->
                        tl(Buttons)
                end ++ [Button],
            if
                NewButtons =:= Code -> % Correct
                    do_unlock(),
                    {next_state, open, Data#{buttons := []},
                     [{state_timeout,10_000,lock}]}; % Time in milliseconds
                true -> % Incomplete | Incorrect
                    {keep_state, Data#{buttons := NewButtons}}
            end;
	open ->
            keep_state_and_data
    end;
handle_event(state_timeout, lock, open, Data) ->
    do_lock(),
    {next_state, locked, Data};
handle_event(
  {call,From}, code_length, _State, #{code := Code} = Data) ->
    {keep_state, Data,
     [{reply,From,length(Code)}]}.

...

停止

在监控树中

如果 gen_statem 是监控树的一部分,则不需要停止函数。gen_statem 会被其监控器自动终止。具体如何执行取决于监控器中设置的关闭策略

如果需要在终止前进行清理,则关闭策略必须是一个超时值,并且 gen_statem 必须在函数 init/1 中通过调用 process_flag(trap_exit, true) 将自身设置为捕获退出信号

init(Args) ->
    process_flag(trap_exit, true),
    do_lock(),
    ...

当被命令关闭时,gen_statem 然后会调用回调函数 terminate(shutdown, State, Data)

在此示例中,如果门是打开的,函数 terminate/3 会锁定门,这样我们就不会在监控树终止时意外地将门保持打开状态

terminate(_Reason, State, _Data) ->
    State =/= locked andalso do_lock(),
    ok.

独立的 gen_statem

如果 gen_statem 不是监控树的一部分,可以使用 gen_statem:stop/1(最好是通过 API 函数)停止它

...
-export([start_link/1,stop/0]).

...
stop() ->
    gen_statem:stop(?NAME).

这会使 gen_statem 调用回调函数 terminate/3,就像受监控的服务器一样,并等待进程终止。

事件超时

gen_statem 的前身 gen_fsm 继承的一个超时功能是事件超时,也就是说,如果事件到达,计时器会被取消。您只会收到事件或超时,但不会两者都收到。

它由 转换操作 {timeout, Time, EventContent} 或者只是一个整数 Time 来指定,即使没有包含在操作列表中(后者是从 gen_fsm 继承的形式)。

这种类型的超时非常有用,例如,在不活动时进行操作。如果没有按下按钮,让我们重新启动代码序列,比如说 30 秒

...

locked(timeout, _, Data) ->
    {next_state, locked, Data#{buttons := []}};
locked(
  cast, {button,Button},
  #{code := Code, length := Length, buttons := Buttons} = Data) ->
...
	true -> % Incomplete | Incorrect
            {next_state, locked, Data#{buttons := NewButtons},
             30_000} % Time in milliseconds
...

每当我们收到按钮事件时,我们都会启动一个 30 秒的事件超时,如果我们得到 timeout事件类型,我们会重置剩余的代码序列。

任何其他事件都会取消事件超时,因此您要么获得其他事件,要么获得超时事件。因此,取消、重启或更新事件超时既不可能也没有必要。您采取行动的任何事件都已经取消了事件超时,因此在执行状态回调时永远不会有正在运行的事件超时

请注意,当您例如有如 所有状态事件 部分中的状态调用,或者处理未知事件时,事件超时 效果不佳,因为所有类型的事件都会取消事件超时

通用超时

前面的状态超时示例仅在状态机在超时期间保持在同一状态时才有效。并且事件超时仅在没有发生其他不相关的干扰事件时才有效。

您可能希望在一个状态下启动计时器,并在另一个状态下响应超时,也许在不更改状态的情况下取消超时,或者可能并行运行多个超时。所有这些都可以通过 通用超时 来完成。它们可能看起来有点像 事件超时,但包含一个名称,允许同时存在任意数量的超时,并且它们不会自动取消。

以下是如何通过使用一个名为 open通用超时来完成先前示例中的状态超时

...
locked(
  cast, {button,Button},
  #{code := Code, length := Length, buttons := Buttons} = Data) ->
...
    if
        NewButtons =:= Code -> % Correct
	    do_unlock(),
            {next_state, open, Data#{buttons := []},
             [{{timeout,open},10_000,lock}]}; % Time in milliseconds
...

open({timeout,open}, lock, Data) ->
    do_lock(),
    {next_state,locked,Data};
open(cast, {button,_}, Data) ->
    {keep_state,Data};
...

特定的通用超时可以像 状态超时 一样,通过将其设置为新的时间或 infinity 来重启或取消。

在这种特殊情况下,我们不需要取消超时,因为超时事件是从 openlocked 进行状态更改的唯一可能原因。

与其费心何时取消超时,不如在已知超时已过时的情况下,通过忽略迟到的超时事件来处理它。

您可以重启、取消或更新通用超时。详情请参见 超时 部分。

Erlang 定时器

处理超时的最通用方法是使用 Erlang 定时器;请参见 erlang:start_timer/3,4gen_statem 中的超时功能可以执行大多数超时任务,但一个无法执行的例子是您需要从 erlang:cancel_timer(Tref) 中获取返回值时,即计时器的剩余时间。

以下是如何通过使用 Erlang 定时器来完成先前示例中的状态超时

...
locked(
  cast, {button,Button},
  #{code := Code, length := Length, buttons := Buttons} = Data) ->
...
    if
        NewButtons =:= Code -> % Correct
	    do_unlock(),
	    Tref =
                 erlang:start_timer(
                     10_000, self(), lock), % Time in milliseconds
            {next_state, open, Data#{buttons := [], timer => Tref}};
...

open(info, {timeout,Tref,lock}, #{timer := Tref} = Data) ->
    do_lock(),
    {next_state,locked,maps:remove(timer, Data)};
open(cast, {button,_}, Data) ->
    {keep_state,Data};
...

当我们进行状态更改locked 时,从映射中删除 timer 键不是绝对必要的,因为我们只能使用更新的 timer 映射值进入 open 状态。但是,在状态 Data 中不包含过时的值会更好。

如果您由于其他事件需要取消计时器,可以使用 erlang:cancel_timer(Tref)。请注意,在此之后不会收到超时消息(因为计时器已被显式取消),除非您之前已推迟过(请参见下一节),因此请确保您不会意外地推迟此类消息。另请注意,超时消息可能会在正在取消计时器的状态回调期间到达,因此您可能必须从进程邮箱中读取此类消息,具体取决于 erlang:cancel_timer(Tref) 的返回值。

处理迟到超时的另一种方法是不取消它,而是在已知超时已过时的情况下,忽略它。

推迟事件

如果您想忽略当前状态下的特定事件,并在未来的状态下处理它,您可以推迟该事件。在状态更改后,即 OldState =/= NewState,会重试被推迟的事件。

推迟是由 转换操作 postpone 来指定的。

在此示例中,我们可以推迟按钮事件,而不是在 open 状态时忽略它们,并在后面的 locked 状态中处理它们

...
open(cast, {button,_}, Data) ->
    {keep_state,Data,[postpone]};
...

由于被推迟的事件仅在状态更改后才会被重试,因此您必须考虑将状态数据项保存在哪里。您可以将其保存在服务器的 Data 中,或者在 State 本身中,例如,通过拥有两个或多或少相同的状态来保持一个布尔值,或者通过使用带有 回调模式 handle_event_function 的复杂状态(请参见 复杂状态 部分)。如果值的变化改变了被处理的事件集,则该值应位于 State 中。否则,由于只有服务器的 Data 发生更改,因此不会重试任何被推迟的事件。

如果事件被推迟,这很重要。但是请记住,如果引入事件推迟,那么关于什么属于状态的不正确设计决策,可能会在一段时间后成为难以发现的错误。

模糊状态图

状态图未指定如何在图中特定状态下处理未说明的事件,这种情况并不少见。希望这在相关文本或上下文中有所描述。

可能的操作:忽略如丢弃事件(可能记录它),或在其他状态下处理事件如推迟它。

选择性接收

Erlang 的选择性 receive 语句通常用于在简单的 Erlang 代码中描述简单的状态机示例。以下是第一个示例的可能实现

-module(code_lock).
-define(NAME, code_lock_1).
-export([start_link/1,button/1]).

start_link(Code) ->
    spawn(
      fun () ->
	      true = register(?NAME, self()),
	      do_lock(),
	      locked(Code, length(Code), [])
      end).

button(Button) ->
    ?NAME ! {button,Button}.
locked(Code, Length, Buttons) ->
    receive
        {button,Button} ->
            NewButtons =
                if
                    length(Buttons) < Length ->
                        Buttons;
                    true ->
                        tl(Buttons)
                end ++ [Button],
            if
                NewButtons =:= Code -> % Correct
                    do_unlock(),
		    open(Code, Length);
                true -> % Incomplete | Incorrect
                    locked(Code, Length, NewButtons)
            end
    end.
open(Code, Length) ->
    receive
    after 10_000 -> % Time in milliseconds
	    do_lock(),
	    locked(Code, Length, [])
    end.

do_lock() ->
    io:format("Locked~n", []).
do_unlock() ->
    io:format("Open~n", []).

在这种情况下,选择性接收导致 open 隐式地将任何事件推迟到 locked 状态。

永远不应从 gen_statem 行为(或任何 gen_* 行为)中使用全捕获接收,因为 receive 语句位于 gen_* 引擎本身内。sys 兼容的行为必须响应系统消息,因此在它们的引擎接收循环中执行此操作,并将非系统消息传递给回调模块。使用全捕获接收可能会导致系统消息被丢弃,进而导致意外行为。如果必须使用选择性接收,则应格外小心,以确保仅接收与操作相关的消息。同样,回调必须及时返回,以使引擎接收循环处理系统消息,否则它们可能会超时,从而也导致意外行为。

转换动作 postpone 旨在模拟选择性接收。选择性接收隐式地推迟任何尚未接收到的事件,而 postpone 转换动作显式地推迟单个接收到的事件。

两种机制具有相同的理论时间和内存复杂度,但请注意,选择性接收语言构造具有更小的常数因子。

状态进入动作

假设您有一个使用状态进入操作的状态机规范。虽然您可以使用插入的事件(在下一节中描述)来编写此代码,尤其是在只有一个或几个状态具有状态进入操作时,这是内置的 状态进入调用 的完美用例。

您从 callback_mode/0 函数返回一个包含 state_enter 的列表,并且 gen_statem 引擎将在每次执行状态更改时,使用事件 (enter, OldState, ...) 调用您的状态回调一次。然后,您只需要在所有状态下处理这些类似事件的调用。

...
init(Code) ->
    process_flag(trap_exit, true),
    Data = #{code => Code, length = length(Code)},
    {ok, locked, Data}.

callback_mode() ->
    [state_functions,state_enter].

locked(enter, _OldState, Data) ->
    do_lock(),
    {keep_state,Data#{buttons => []}};
locked(
  cast, {button,Button},
  #{code := Code, length := Length, buttons := Buttons} = Data) ->
...
    if
        NewButtons =:= Code -> % Correct
            {next_state, open, Data};
...

open(enter, _OldState, _Data) ->
    do_unlock(),
    {keep_state_and_data,
     [{state_timeout,10_000,lock}]}; % Time in milliseconds
open(state_timeout, lock, Data) ->
    {next_state, locked, Data};
...

您可以通过返回 {repeat_state, ...}{repeat_state_and_data, _}repeat_state_and_data 之一来重复状态进入代码,否则它们的行为与 keep_state 兄弟项完全相同。请参阅参考手册中的类型 state_callback_result()

插入的事件

有时,能够为自己的状态机生成事件会很有益。这可以使用 转换动作 {next_event, EventType, EventContent} 来完成。

您可以生成任何现有 类型 的事件,但 internal 类型只能通过操作 next_event 生成。因此,它不能来自外部源,因此您可以确定 internal 事件是从您的状态机到其自身的事件。

一个例子是预处理传入的数据,例如解密块或收集字符直到换行符。

纯粹主义者可能会争辩说,这应该使用单独的状态机建模,该状态机将预处理的事件发送到主状态机。

但是,为了提高效率,可以将小型预处理状态机集成到主状态机的公共事件处理中。这种集成涉及使用一些状态数据项将预处理的事件作为内部事件分派到主状态机。

使用内部事件还可以使状态机更易于同步。

一个变体是使用具有 一个状态回调复杂状态,例如,使用元组 {MainFSMState, SubFSMState} 对状态进行建模。

为了说明这一点,我们举一个例子,其中按钮改为生成向下和向上(按下和释放)事件,并且锁仅在相应的向下事件之后才响应向上事件。

...
-export([down/1, up/1]).
...
down(Button) ->
    gen_statem:cast(?NAME, {down,Button}).

up(Button) ->
    gen_statem:cast(?NAME, {up,Button}).

...

locked(enter, _OldState, Data) ->
    do_lock(),
    {keep_state,Data#{buttons => []}};
locked(
  internal, {button,Button},
  #{code := Code, length := Length, buttons := Buttons} = Data) ->
...
handle_common(cast, {down,Button}, Data) ->
    {keep_state, Data#{button => Button}};
handle_common(cast, {up,Button}, Data) ->
    case Data of
        #{button := Button} ->
            {keep_state,maps:remove(button, Data),
             [{next_event,internal,{button,Button}}]};
        #{} ->
            keep_state_and_data
    end;
...

open(internal, {button,_}, Data) ->
    {keep_state,Data,[postpone]};
...

如果您使用 code_lock:start([17]) 启动此程序,则可以使用 code_lock:down(17), code_lock:up(17). 解锁。

示例回顾

本节包含在大多数提到的修改之后以及使用状态进入调用的更多修改之后的示例,这需要一个新的状态图

---
title: Code Lock State Diagram Revisited
---
stateDiagram-v2
    state enter_locked <<choice>>
    state enter_open   <<choice>>
    state check_code   <<choice>>

    [*] --> enter_locked

    enter_locked --> locked     : * do_lock()\n* Clear Buttons
    locked       --> check_code : {button, Button}\n* Collect Buttons
    locked       --> locked     : state_timeout\n* Clear Buttons
    check_code   --> locked     : Incorrect code\n* Set state_timeout 30 s
    check_code   --> enter_open : Correct code

    enter_open --> open         : * do_unlock()\n* Set state_timeout 10 s
    open       --> enter_locked : state_timeout

请注意,此状态图未指定如何在 open 状态下处理按钮事件。因此,您需要阅读一些旁注,即:这里,未指定的事件应被推迟(在稍后的状态中处理)。此外,状态图未显示必须在每个状态中处理 code_length/0 调用。

回调模式:state_functions

使用状态函数

-module(code_lock).
-behaviour(gen_statem).
-define(NAME, code_lock_2).

-export([start_link/1,stop/0]).
-export([down/1,up/1,code_length/0]).
-export([init/1,callback_mode/0,terminate/3]).
-export([locked/3,open/3]).

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

down(Button) ->
    gen_statem:cast(?NAME, {down,Button}).
up(Button) ->
    gen_statem:cast(?NAME, {up,Button}).
code_length() ->
    gen_statem:call(?NAME, code_length).
init(Code) ->
    process_flag(trap_exit, true),
    Data = #{code => Code, length => length(Code), buttons => []},
    {ok, locked, Data}.

callback_mode() ->
    [state_functions,state_enter].

-define(HANDLE_COMMON,
    ?FUNCTION_NAME(T, C, D) -> handle_common(T, C, D)).
%%
handle_common(cast, {down,Button}, Data) ->
    {keep_state, Data#{button => Button}};
handle_common(cast, {up,Button}, Data) ->
    case Data of
        #{button := Button} ->
            {keep_state, maps:remove(button, Data),
             [{next_event,internal,{button,Button}}]};
        #{} ->
            keep_state_and_data
    end;
handle_common({call,From}, code_length, #{code := Code}) ->
    {keep_state_and_data,
     [{reply,From,length(Code)}]}.
locked(enter, _OldState, Data) ->
    do_lock(),
    {keep_state, Data#{buttons := []}};
locked(state_timeout, button, Data) ->
    {keep_state, Data#{buttons := []}};
locked(
  internal, {button,Button},
  #{code := Code, length := Length, buttons := Buttons} = Data) ->
    NewButtons =
        if
            length(Buttons) < Length ->
                Buttons;
            true ->
                tl(Buttons)
        end ++ [Button],
    if
        NewButtons =:= Code -> % Correct
            {next_state, open, Data};
	true -> % Incomplete | Incorrect
            {keep_state, Data#{buttons := NewButtons},
             [{state_timeout,30_000,button}]} % Time in milliseconds
    end;
?HANDLE_COMMON.
open(enter, _OldState, _Data) ->
    do_unlock(),
    {keep_state_and_data,
     [{state_timeout,10_000,lock}]}; % Time in milliseconds
open(state_timeout, lock, Data) ->
    {next_state, locked, Data};
open(internal, {button,_}, _) ->
    {keep_state_and_data, [postpone]};
?HANDLE_COMMON.

do_lock() ->
    io:format("Locked~n", []).
do_unlock() ->
    io:format("Open~n", []).

terminate(_Reason, State, _Data) ->
    State =/= locked andalso do_lock(),
    ok.

回调模式:handle_event_function

本节介绍如何更改示例以使用一个 handle_event/4 函数。先前使用的首先依赖于事件进行分支的方法在这里效果不佳,因为存在状态进入调用,因此此示例首先依赖于状态进行分支

-export([handle_event/4]).
callback_mode() ->
    [handle_event_function,state_enter].
%%
%% State: locked
handle_event(enter, _OldState, locked, Data) ->
    do_lock(),
    {keep_state, Data#{buttons := []}};
handle_event(state_timeout, button, locked, Data) ->
    {keep_state, Data#{buttons := []}};
handle_event(
  internal, {button,Button}, locked,
  #{code := Code, length := Length, buttons := Buttons} = Data) ->
    NewButtons =
        if
            length(Buttons) < Length ->
                Buttons;
            true ->
                tl(Buttons)
        end ++ [Button],
    if
        NewButtons =:= Code -> % Correct
            {next_state, open, Data};
	true -> % Incomplete | Incorrect
            {keep_state, Data#{buttons := NewButtons},
             [{state_timeout,30_000,button}]} % Time in milliseconds
    end;
%%
%% State: open
handle_event(enter, _OldState, open, _Data) ->
    do_unlock(),
    {keep_state_and_data,
     [{state_timeout,10_000,lock}]}; % Time in milliseconds
handle_event(state_timeout, lock, open, Data) ->
    {next_state, locked, Data};
handle_event(internal, {button,_}, open, _) ->
    {keep_state_and_data,[postpone]};
%% Common events
handle_event(cast, {down,Button}, _State, Data) ->
    {keep_state, Data#{button => Button}};
handle_event(cast, {up,Button}, _State, Data) ->
    case Data of
        #{button := Button} ->
            {keep_state, maps:remove(button, Data),
             [{next_event,internal,{button,Button}},
              {state_timeout,30_000,button}]}; % Time in milliseconds
        #{} ->
            keep_state_and_data
    end;
handle_event({call,From}, code_length, _State, #{length := Length}) ->
    {keep_state_and_data,
     [{reply,From,Length}]}.

请注意,将 open 状态的按钮推迟到 locked 状态对于密码锁来说似乎很奇怪,但至少说明了事件推迟。

过滤状态

本章中的示例服务器到目前为止在错误日志中打印完整的内部状态,例如,当被退出信号杀死或由于内部错误时。该状态包含密码锁代码以及要解锁的剩余数字。

此状态数据可以被认为是敏感的,并且可能不是您由于某些不可预测的事件而希望在错误日志中看到的内容。

过滤状态的另一个原因可能是状态太大而无法打印,因为它会在错误日志中填充不感兴趣的详细信息。

为了避免这种情况,您可以通过实现函数 Module:format_status/2 来格式化错误日志中显示的内部状态,并从 sys:get_status/1,2 返回,例如这样

...
-export([init/1,terminate/3,format_status/2]).
...

format_status(Opt, [_PDict,State,Data]) ->
    StateData =
	{State,
	 maps:filter(
	   fun (code, _) -> false;
	       (_, _) -> true
	   end,
	   Data)},
    case Opt of
	terminate ->
	    StateData;
	normal ->
	    [{data,[{"State",StateData}]}]
    end.

并非必须实现 Module:format_status/2 函数。如果您不这样做,则会使用默认实现,该实现与此示例函数执行相同的操作,而不过滤 Data 项,即 StateData = {State, Data},在此示例中包含敏感信息。

复杂状态

回调模式 handle_event_function 允许使用非原子状态,如 回调模式 部分中所述,例如,像元组这样的复杂状态项。

使用此方法的一个原因是,当您有一个状态项在更改时应取消 状态超时,或者一个影响事件处理并结合推迟事件的状态项。我们将选择后者,并通过引入可配置的锁定按钮(这是有问题的状态项)来使先前的示例复杂化,该锁定按钮在 open 状态下立即锁定门,并使用 API 函数 set_lock_button/1 来设置锁定按钮。

假设现在我们在门打开时调用 set_lock_button,并且我们已经推迟了一个作为新锁定按钮的按钮事件

1> code_lock:start_link([a,b,c], x).
{ok,<0.666.0>}
2> code_lock:button(a).
ok
3> code_lock:button(b).
ok
4> code_lock:button(c).
ok
Open
5> code_lock:button(y).
ok
6> code_lock:set_lock_button(y).
x
% What should happen here?  Immediate lock or nothing?

我们可以说该按钮按下的时间太早,因此不应将其识别为锁定按钮。或者我们可以使锁定按钮成为状态的一部分,以便当我们然后在锁定状态下更改锁定按钮时,该更改将成为状态更改,并且所有推迟的事件都将重试,因此门立即锁定!

我们将状态定义为 {StateName, LockButton},其中 StateName 与之前相同,LockButton 是当前的锁定按钮

-module(code_lock).
-behaviour(gen_statem).
-define(NAME, code_lock_3).

-export([start_link/2,stop/0]).
-export([button/1,set_lock_button/1]).
-export([init/1,callback_mode/0,terminate/3]).
-export([handle_event/4]).

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

button(Button) ->
    gen_statem:cast(?NAME, {button,Button}).
set_lock_button(LockButton) ->
    gen_statem:call(?NAME, {set_lock_button,LockButton}).
init({Code,LockButton}) ->
    process_flag(trap_exit, true),
    Data = #{code => Code, length => length(Code), buttons => []},
    {ok, {locked,LockButton}, Data}.

callback_mode() ->
    [handle_event_function,state_enter].

%% State: locked
handle_event(enter, _OldState, {locked,_}, Data) ->
    do_lock(),
    {keep_state, Data#{buttons := []}};
handle_event(state_timeout, button, {locked,_}, Data) ->
    {keep_state, Data#{buttons := []}};
handle_event(
  cast, {button,Button}, {locked,LockButton},
  #{code := Code, length := Length, buttons := Buttons} = Data) ->
    NewButtons =
        if
            length(Buttons) < Length ->
                Buttons;
            true ->
                tl(Buttons)
        end ++ [Button],
    if
        NewButtons =:= Code -> % Correct
            {next_state, {open,LockButton}, Data};
	true -> % Incomplete | Incorrect
            {keep_state, Data#{buttons := NewButtons},
             [{state_timeout,30_000,button}]} % Time in milliseconds
    end;
%%
%% State: open
handle_event(enter, _OldState, {open,_}, _Data) ->
    do_unlock(),
    {keep_state_and_data,
     [{state_timeout,10_000,lock}]}; % Time in milliseconds
handle_event(state_timeout, lock, {open,LockButton}, Data) ->
    {next_state, {locked,LockButton}, Data};
handle_event(cast, {button,LockButton}, {open,LockButton}, Data) ->
    {next_state, {locked,LockButton}, Data};
handle_event(cast, {button,_}, {open,_}, _Data) ->
    {keep_state_and_data,[postpone]};
%%
%% Common events
handle_event(
  {call,From}, {set_lock_button,NewLockButton},
  {StateName,OldLockButton}, Data) ->
    {next_state, {StateName,NewLockButton}, Data,
     [{reply,From,OldLockButton}]}.
do_lock() ->
    io:format("Locked~n", []).
do_unlock() ->
    io:format("Open~n", []).

terminate(_Reason, State, _Data) ->
    State =/= locked andalso do_lock(),
    ok.

休眠

如果您的一个节点中有许多服务器,并且它们的生命周期中有一些状态,在这些状态下,服务器有望闲置一段时间,并且所有这些服务器所需的堆内存量是一个问题,那么可以通过 proc_lib:hibernate/3 休眠服务器来最小化服务器的内存占用。

注意

休眠进程的成本相当高;请参阅 erlang:hibernate/3。这不是您希望在每次事件后都执行的操作。

在此示例中,我们可以在 {open, _} 状态下休眠,因为通常在该状态下发生的是状态超时在一段时间后触发到 {locked, _} 的转换

...
%%
%% State: open
handle_event(enter, _OldState, {open,_}, _Data) ->
    do_unlock(),
    {keep_state_and_data,
     [{state_timeout,10_000,lock}, % Time in milliseconds
      hibernate]};
...

进入 {open, _} 状态时,最后一行操作列表中的原子 hibernate 是唯一的更改。如果任何事件到达 {open, _}, 状态,我们不会费心再次休眠,因此服务器在任何事件之后都保持唤醒状态。

要更改这一点,我们需要在更多位置插入操作 hibernate。例如,与状态无关的 set_lock_button 操作将必须使用 hibernate,但只能在 {open, _} 状态下使用,这会使代码混乱。

另一个常见的情况是使用 事件超时 在一段时间不活动后触发休眠。还有一个服务器启动选项 {hibernate_after, Timeout} 用于 start/3,4start_link/3,4enter_loop/4,5,6,该选项可用于自动休眠服务器。

此特定服务器可能不会使用值得休眠的堆内存。要从休眠中获得任何好处,您的服务器必须在回调执行期间产生不可忽略的垃圾,对于此示例服务器而言,这可以作为一个不好的例子。