3  gen_statem 行为

3 gen_statem 行为

本节应与 STDLIB 中的 gen_statem(3) 手册页一起阅读,其中详细描述了所有接口函数和回调函数。

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

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

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。因此,由于对状态数量(假设有足够的虚拟机内存)或不同输入事件数量没有限制,因此使用此行为实现的状态机实际上是图灵完备的。但它感觉更像一个事件驱动的米利机。

如果您的进程逻辑方便描述为状态机,并且您希望使用以下任何 gen_statem 关键功能

如果是这样,或者在将来可能需要,那么您应该考虑使用 gen_statem 而不是 gen_server

对于不需要这些功能的简单状态机,gen_server 工作得很好。它的调用开销也更小,但我们谈论的是大约 2 vs 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 决定在代码中的哪个位置处理状态中的大多数事件。元组的第二个元素用于选择是处理特殊客户端事件还是服务器端事件。

状态回调是处理当前状态事件的回调函数,具体使用哪个函数取决于回调模式

事件由以下内容处理:
Module:StateName(EventType, EventContent, Data)

此形式主要用于 示例 部分。

事件由以下内容处理:
Module:handle_event(EventType, EventContent, State, Data)

参见 单个状态回调 部分的示例。

状态要么是函数本身的名称,要么是传递给函数的参数。其他参数是 EventType 和事件相关的 EventContent,这两个参数都在 事件类型和事件内容 部分有描述,还有当前服务器的 Data

状态进入调用也由事件处理程序处理,并且参数略有不同。参见 状态进入调用 部分。

状态回调返回值在 gen_statem 手册页中 Module:StateName/3 的描述中定义,但这里提供了一个更易读的列表

设置下一个状态并更新服务器数据。如果使用了 Actions 字段,则执行转换动作。空 Actions 列表等同于不返回该字段。

参见 转换动作 部分,了解可能的转换动作列表。

如果 NextState =/= State,则表示发生了状态变化gen_statem 会执行以下额外操作:从最旧的 延迟事件 重新启动事件队列,取消任何当前的 状态超时,并且如果已启用,则执行 状态进入调用

next_state 值相同,其中 NextState =:= State,即没有状态变化

keep_state 值相同,其中 NextData =:= Data,即服务器数据没有变化。

keep_statekeep_state_and_data 值相同,并且如果启用了 状态进入调用 ,则重复状态进入调用,就像再次进入此状态一样。

如果从状态进入调用中使用这些返回值,OldState 不会发生变化,但如果从事件处理的状态回调中使用,新状态进入调用OldState 将是当前状态。

Reason 为原因停止服务器。如果使用了 NewData 字段,则先更新服务器数据。

stop 值相同,但先执行给定的 转换动作 ,这些动作可能仅是回复动作。

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

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

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

如果设置,则延迟当前事件,参见 延迟事件 部分。
如果设置,则使 gen_statem 进入休眠状态,在 休眠 部分有介绍。
启动、更新或取消状态超时,在 超时状态超时 部分可以了解更多信息。
启动、更新或取消通用超时,在 超时通用超时 部分可以了解更多信息。
启动事件超时,在 超时事件超时 部分可以了解更多信息。
回复调用方,在 所有状态事件 部分的末尾有介绍。
生成要处理的下一个事件,参见 插入事件 部分。
更改运行服务器的 回调模块 。这可以在任何状态转换期间完成,无论是否发生状态变化,但不能状态进入调用 中完成。
将当前回调模块推入内部回调模块堆栈的顶部,并将新的 回调模块 设置为运行服务器的回调模块。其他方面与上面的 {change_callback_module, NewModule} 相同。
从内部回调模块堆栈的顶部弹出模块,并将其设置为运行服务器的新的 回调模块 。如果堆栈为空,则服务器会失败。其他方面与上面的 {change_callback_module, NewModule} 相同。

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

在这些转换动作中,只有回复调用方是立即执行的动作。其他动作会被收集起来,并在稍后的状态转换期间处理。 插入事件 会被存储起来,并一起插入,而其他动作会设置转换选项,其中最后出现的特定类型的选项会覆盖之前的选项。有关状态转换的描述,请参阅 gen_statem(3) 手册页中的类型 transition_option()

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

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

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

gen_statem:cast(ServerRef, Msg) 生成,其中 Msg 会成为 EventContent
gen_statem:call(ServerRef, Request) 生成,其中 Request 会成为 EventContentFrom 是回复地址,用于通过转换动作 {reply,From,Reply} 或从回调模块调用 gen_statem:reply(From, Reply) 进行回复。
由发送到 gen_statem 进程的任何常规进程消息生成。进程消息会成为 EventContent
转换动作 {state_timeout,Time,EventContent} 的状态计时器超时生成。在 超时状态超时 部分可以了解更多信息。
转换动作 {{timeout,Name},Time,EventContent} 通用计时器超时生成。有关更多信息,请参阅部分 超时通用超时
转换动作 {timeout,Time,EventContent} (或其简写形式 Time)事件计时器超时生成。有关更多信息,请参阅部分 超时事件超时
转换动作 {next_event,internal,EventContent} 生成。所有上述事件类型也可以使用 next_event 动作生成:{next_event,EventType,EventContent}

如果启用此功能,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}.

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

进入的第一个状态将获得一个状态进入调用,其中OldState 等于当前状态。

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

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

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

gen_statem 中有 3 种类型的超时

有一个状态超时,它会由状态更改自动取消。
有任意数量的通用超时,它们由其Name 区分。它们没有自动取消功能。
有一个事件超时,它会由任何事件自动取消。请注意,推迟插入 的事件会像外部事件一样取消此超时。

当启动超时时,任何相同类型的运行超时;state_timeout{timeout, Name}timeout 将被取消,即超时将使用新时间重新启动。

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

如果使用时间 infinity 启动超时,它将永远不会超时,实际上它甚至不会启动,并且任何使用相同标签运行的超时都将被取消。EventContent 在这种情况下将被忽略,所以为什么不将其设置为 undefined

取消计时器的更明确方法是使用 转换动作 ,其形式为 {TimeoutType, cancel} ,这是 OTP 22.1 中引入的功能。

当超时正在运行时,可以使用 转换动作 更新其EventContent,其形式为 {TimeoutType, update, NewEventContent} ,这是 OTP 22.1 中引入的功能。

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

如果使用时间 0 启动超时,它实际上不会启动。相反,超时事件将立即插入,以便在任何已入队的事件之后和任何尚未接收的外部事件之前处理。请注意,某些超时会自动取消,因此如果您例如将推迟状态更改中的事件与使用时间 0 启动事件超时 相结合,则不会插入任何超时事件,因为由于状态更改而传递的推迟事件会取消事件超时。

带密码锁的门可以看作是一个状态机。最初,门是锁着的。当有人按下按钮时,会生成一个事件。按下的按钮会被收集起来,最多收集到与正确密码中按钮数量相同的按钮。如果正确,门将解锁 10 秒。如果不正确,我们将等待按下新按钮。

图 3.1:  密码锁状态图

可以使用 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,10000,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 是通过调用 code_lock:start_link(Code) 启动的

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

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

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

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

  • 第二个参数 ?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 是同步的。它不会返回,直到 gen_statem 初始化并准备好接收事件。

函数 gen_statem:start_link 必须在 gen_statem 是监督树的一部分(即由主管启动)时使用。另一个函数 gen_statem:start 可用于启动独立的 gen_statem,即不是监督树一部分的 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,10000,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,10000,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,它等待来自服务器的回复。回复使用 {reply,From,Reply} 元组在一个动作列表中发送,该动作列表位于保持当前状态的 {keep_state, ...} 元组中。当您想保持在当前状态但不知道或不关心它是什么时,此返回形式很方便。

如果通用**状态回调**需要知道当前状态,则可以使用函数 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,10000,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:stop 停止它,最好是通过 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},
             30000} % 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},10000,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:start_timer/3,4。大多数超时任务可以通过 gen_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(
                     10000, 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 的选择性接收语句通常用于在直接的 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 10000 -> % 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_* 行为)中使用通配符接收,因为接收语句位于 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,10000,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). 解锁。

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

图 3.2:  代码锁状态图重述

请注意,该状态图未指定如何在 open 状态中处理按钮事件。 因此,您需要阅读一些旁注,也就是说,这里:未指定事件将被推迟(在以后的某个状态中处理)。 此外,状态图没有显示必须在每个状态中处理 code_length/0 调用。

使用状态函数

-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,30000,button}]} % Time in milliseconds
    end;
?HANDLE_COMMON.
open(enter, _OldState, _Data) ->
    do_unlock(),
    {keep_state_and_data,
     [{state_timeout,10000,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/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,30000,button}]} % Time in milliseconds
    end;
%%
%% State: open
handle_event(enter, _OldState, open, _Data) ->
    do_unlock(),
    {keep_state_and_data,
     [{state_timeout,10000,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,30000,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 状态推迟按钮对于代码锁来说感觉很奇怪,但它至少说明了事件推迟。

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

该状态数据可以被视为敏感数据,并且可能不是您希望在错误日志中看到的内容,因为存在一些不可预测的事件。

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

为了避免这种情况,您可以格式化进入错误日志的内部状态,以及从 sys:get_status/1,2 返回的内部状态,方法是实现函数 Module:format_status/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,30000,button}]} % Time in milliseconds
    end;
%%
%% State: open
handle_event(enter, _OldState, {open,_}, _Data) ->
    do_unlock(),
    {keep_state_and_data,
     [{state_timeout,10000,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,10000,lock}, % Time in milliseconds
      hibernate]};
...

原子 hibernate 位于进入 {open,_} 状态时的最后一行上的操作列表中,是唯一的更改。 如果在 {open,_}, 状态下出现任何事件,我们将不费心重新休眠,因此服务器在任何事件后都会保持唤醒状态。

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

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

该特定服务器可能不会使用值得休眠的堆内存。 为了从休眠中获益,您的服务器必须在回调执行期间产生非微不足道的垃圾,而该示例服务器可以作为反面例子。