对抗有限状态机
它们是什么?
有限状态机 (FSM) 并不是真正的机器,但它确实具有有限数量的状态。我一直发现有限状态机更容易用图表和图形来理解。例如,以下是将一只(非常愚蠢的)狗作为状态机简化的图表
这里狗有 3 种状态:坐着、吠叫或摇尾巴。不同的事件或输入可能会迫使它改变状态。如果一只狗平静地坐着并看到一只松鼠,它会开始吠叫,并且不会停止,直到你再次抚摸它。然而,如果狗坐着,而你抚摸它,我们不知道会发生什么。在 Erlang 世界中,狗可能会崩溃(并最终被其主管重新启动)。在现实世界中,这将是一件怪异的事件,但你的狗会在被汽车撞倒后回来,所以也不全是坏事。
以下是一个猫的状态图,用于比较
这只猫只有一种状态,并且没有任何事件可以改变它。
在 Erlang 中实现 猫状态机 是一项有趣且简单的任务
-module(cat_fsm). -export([start/0, event/2]). start() -> spawn(fun() -> dont_give_crap() end). event(Pid, Event) -> Ref = make_ref(), % won't care for monitors here Pid ! {self(), Ref, Event}, receive {Ref, Msg} -> {ok, Msg} after 5000 -> {error, timeout} end. dont_give_crap() -> receive {Pid, Ref, _Msg} -> Pid ! {Ref, meh}; _ -> ok end, io:format("Switching to 'dont_give_crap' state~n"), dont_give_crap().
我们可以尝试该模块以查看猫真的从不在乎
1> c(cat_fsm). {ok,cat_fsm} 2> Cat = cat_fsm:start(). <0.67.0> 3> cat_fsm:event(Cat, pet). Switching to 'dont_give_crap' state {ok,meh} 4> cat_fsm:event(Cat, love). Switching to 'dont_give_crap' state {ok,meh} 5> cat_fsm:event(Cat, cherish). Switching to 'dont_give_crap' state {ok,meh}
对于 狗 FSM 可以做同样的事情,只不过可以使用更多状态
-module(dog_fsm). -export([start/0, squirrel/1, pet/1]). start() -> spawn(fun() -> bark() end). squirrel(Pid) -> Pid ! squirrel. pet(Pid) -> Pid ! pet. bark() -> io:format("Dog says: BARK! BARK!~n"), receive pet -> wag_tail(); _ -> io:format("Dog is confused~n"), bark() after 2000 -> bark() end. wag_tail() -> io:format("Dog wags its tail~n"), receive pet -> sit(); _ -> io:format("Dog is confused~n"), wag_tail() after 30000 -> bark() end. sit() -> io:format("Dog is sitting. Gooooood boy!~n"), receive squirrel -> bark(); _ -> io:format("Dog is confused~n"), sit() end.
将每个状态和转换与上面图表中的内容匹配应该相对简单。以下是 FSM 的使用情况
6> c(dog_fsm). {ok,dog_fsm} 7> Pid = dog_fsm:start(). Dog says: BARK! BARK! <0.46.0> Dog says: BARK! BARK! Dog says: BARK! BARK! Dog says: BARK! BARK! 8> dog_fsm:pet(Pid). pet Dog wags its tail 9> dog_fsm:pet(Pid). Dog is sitting. Gooooood boy! pet 10> dog_fsm:pet(Pid). Dog is confused pet Dog is sitting. Gooooood boy! 11> dog_fsm:squirrel(Pid). Dog says: BARK! BARK! squirrel Dog says: BARK! BARK! 12> dog_fsm:pet(Pid). Dog wags its tail pet 13> %% wait 30 seconds Dog says: BARK! BARK! Dog says: BARK! BARK! Dog says: BARK! BARK! 13> dog_fsm:pet(Pid). Dog wags its tail pet 14> dog_fsm:pet(Pid). Dog is sitting. Gooooood boy! pet
如果你想,你可以遵循模式(我通常这样做,它有助于确保没有错误)。
这确实是作为 Erlang 进程实现的 FSM 的核心。有些事情本可以做得不同:我们可以将状态传递到状态函数的参数中,这与我们在服务器的主循环中所做的事情类似。我们还可以添加 init
和 terminate
函数,处理代码更新等。
狗和猫 FSM 之间的另一个区别是,猫的事件是同步的,而狗的事件是异步的。在真正的 FSM 中,两者都可以混合使用,但我出于纯粹的惰性选择了最简单的表示。示例中没有显示其他形式的事件:可以在任何状态下发生的全局事件。
这种事件的一个例子可能是当狗闻到食物时。一旦触发了 闻到食物
事件,无论狗处于什么状态,它都会去寻找食物来源。
现在,我们不会在我们的“纸上谈兵” FSM 中花太多时间来实现所有这些。相反,我们将直接转到 gen_fsm
行为。
通用有限状态机
gen_fsm
行为与 gen_server
有点相似,因为它是一个专门版本。最大的区别在于,我们不是处理调用和投递,而是处理同步和异步事件。就像我们的狗和猫示例一样,每个状态都由一个函数表示。同样,我们将介绍模块要实现才能工作的回调。
init
这与用于通用服务器的 init/1 相同,除了接受的返回值是 {ok, StateName, Data}
、{ok, StateName, Data, Timeout}
、{ok, StateName, Data, hibernate}
和 {stop, Reason}
。stop
元组与 gen_server
的工作方式相同,hibernate
和 Timeout 保持相同的语义。
这里的新内容是 StateName 变量。 StateName 是一个原子,表示要调用的下一个回调函数。
StateName
函数 StateName/2 和 StateName/3 是占位符名称,由您决定它们是什么。假设 init/1
函数返回元组 {ok, sitting, dog}
。这意味着有限状态机将处于 sitting
状态。这与我们在 gen_server
中看到的州类型不同;它更像是先前狗 FSM 的 sit
、bark
和 wag_tail
状态的等价物。这些状态决定了您处理给定事件的上下文。
这方面的例子是有人用电话给你打电话。如果你处于“星期六早上睡觉”的状态,你的反应可能是对着电话大喊大叫。如果你处于“等待工作面试”的状态,你很可能会拿起电话并礼貌地接听。另一方面,如果你处于“死亡”状态,那么我很惊讶你能读懂这段文字。
回到我们的 FSM。init/1
函数说我们应该处于 sitting
状态。每当 gen_fsm
进程接收到事件时,都会调用函数 sitting/2
或 sitting/3
。sitting/2
函数用于异步事件,而 sitting/3
用于同步事件。
sitting/2
(或通常是 StateName/2
)的参数是 Event,即作为事件发送的实际消息,以及 StateData,即跨调用传递的数据。sitting/2
然后可以返回元组 {next_state, NextStateName, NewStateData}
、{next_state, NextStateName, NewStateData, Timeout}
、{next_state, NextStateName, NewStateData, hibernate}
和 {stop, Reason, NewStateData}
。
sitting/3
的参数类似,除了在 Event 和 StateData 之间有一个 From 变量。From 变量的使用方式与 gen_server
的使用方式完全相同,包括 gen_fsm:reply/2。StateName/3
函数可以返回以下元组
{reply, Reply, NextStateName, NewStateData} {reply, Reply, NextStateName, NewStateData, Timeout} {reply, Reply, NextStateName, NewStateData, hibernate} {next_state, NextStateName, NewStateData} {next_state, NextStateName, NewStateData, Timeout} {next_state, NextStateName, NewStateData, hibernate} {stop, Reason, Reply, NewStateData} {stop, Reason, NewStateData}
请注意,您可以拥有任意数量的这些函数,只要它们被导出。作为 NextStateName 返回的原子将在元组中确定是否调用该函数。
handle_event
在上一节中,我提到了全局事件,这些事件会触发特定的反应,无论我们处于什么状态(狗闻到食物会放下正在做的事情,转而寻找食物)。对于这些应该在每个状态下以相同方式处理的事件,handle_event/3 回调是您想要的。该函数采用类似于 StateName/2
的参数,除了它在它们之间接受一个 StateName 变量,告诉您接收事件时状态是什么。它返回与 StateName/2
相同的值。
handle_sync_event
handle_sync_event/4 回调对于 StateName/3
就像 handle_event/2
对于 StateName/2
一样。它处理同步全局事件,采用相同的参数并返回与 StateName/3
相同类型的元组。
现在可能是解释我们如何知道事件是全局事件还是旨在发送到特定状态的事件的好时机。为了确定这一点,我们可以查看用于将事件发送到 FSM 的函数。发送到任何 StateName/2
函数的异步事件通过 send_event/2 发送,发送到 StateName/3
的同步事件通过 sync_send_event/2-3 发送。
全局事件的两个等效函数是 send_all_state_event/2 和 sync_send_all_state_event/2-3(名字很长)。
code_change
这与 gen_server
的工作方式完全相同,只是在调用时它会采用额外的状态参数,例如 code_change(OldVersion, StateName, Data, Extra)
,并返回 {ok, NextStateName, NewStateData}
形式的元组。
terminate
同样,这应该有点像我们在通用服务器中所拥有的。 terminate/3 应该与 init/1
相反。
交易系统规范
是时候将所有这些付诸实践了。许多关于有限状态机的 Erlang 教程使用包含电话交换机和类似事物的示例。我猜大多数程序员很少需要处理电话交换机以实现状态机。因此,我们将查看一个更适合许多开发人员的示例:我们将设计和实现一个用于某些虚构且不存在的视频游戏的物品交易系统。
我选择的设计有点挑战性。我们不会使用玩家通过其路由物品和确认的经纪人(坦率地说,这将更容易),而是要实现一个服务器,让两个玩家可以直接互相通话(这将具有可分布的优势)。
由于实现很棘手,我将花相当长的时间来描述它,要面对的各种问题以及解决这些问题的办法。
首先,我们应该定义玩家在交易时可以执行的操作。第一个是要求建立交易。另一个用户也应该能够接受该交易。但是,我们不会赋予他们拒绝交易的权利,因为我们希望保持简单。一旦整个过程完成,添加此功能将很容易。
交易建立后,用户应该能够互相协商。这意味着他们应该能够提出报价,然后如果需要,他们可以撤回报价。当双方都对报价满意时,他们可以各自宣布自己已准备好最终确定交易。然后,数据应该保存在双方的某个位置。在任何时候,任何一方也应该可以取消整个交易。一些 平民 可能只提供被认为不值得的物品给对方(对方可能非常忙),因此应该可以取消,这将是对他们当之无愧的回应。
简而言之,以下操作应该是可以执行的
- 要求交易
- 接受交易
- 出价物品
- 撤销报价
- 宣布自己已准备好
- 残忍地取消交易
现在,当执行每个这些操作时,另一个玩家的 FSM 应该意识到这一点。这是有道理的,因为当吉姆告诉他的 FSM 将物品发送给卡尔时,卡尔的 FSM 必须意识到这一点。这意味着两个玩家都可以与自己的 FSM 交谈,而 FSM 将与对方的 FSM 交谈。这给了我们一些类似于以下内容的东西
当我们有两个相同的进程相互通信时,首先要注意的是我们必须尽可能避免同步调用。这样做的原因是,如果吉姆的 FSM 向卡尔的 FSM 发送消息,然后等待其回复,同时卡尔的 FSM 向吉姆的 FSM 发送消息并等待其自己的特定回复,两者最终都会等待对方而永远不回复。这实际上冻结了两个 FSM。我们陷入了僵局。
一个解决办法是等待超时,然后继续,但这样一来,两个进程的邮箱中就会有未处理的消息,协议就会乱套。这无疑是一个麻烦,所以我们想要避免它。
最简单的办法是避免所有同步消息,完全采用异步方式。请注意,吉姆仍然可以对自己的 FSM 进行同步调用;这里没有风险,因为 FSM 不需要调用吉姆,所以他们之间不会发生僵局。
当两个 FSM 相互通信时,整个交换可能看起来有点像这样
两个 FSM 都处于空闲状态。当你要求 Jim 交易时,Jim 必须先接受才能继续。然后你们双方都可以提供物品或撤回物品。当你们都宣布自己已准备好时,交易就可以进行。这是一个简化的版本,涵盖了所有可能发生的事情,在接下来的段落中,我们将更详细地介绍所有可能的情况。
接下来是困难的部分:定义状态图以及状态转换是如何发生的。通常,这需要大量的思考,因为你必须考虑所有可能出错的小细节。即使多次审查,也可能出现一些问题。因此,我将简单地列出我决定在这里实现的方案,然后进行解释。
起初,两个有限状态机都从 idle
状态开始。此时,我们可以做的一件事是要求另一个玩家与我们进行谈判。
我们进入 idle_wait
模式,以便在我们的 FSM 转发请求后等待最终的回复。一旦另一个 FSM 发送回复,我们的 FSM 就可以切换到 negotiate
状态。
另一个玩家也应该在 negotiate
状态。显然,如果我们可以邀请对方,对方也可以邀请我们。如果一切顺利,最终应该看起来像这样。
因此,这实际上是前两个状态图的组合,但顺序相反。请注意,在这种情况下,我们期望玩家接受报价。如果碰巧我们同时要求对方与我们交易,会发生什么情况呢?
发生的情况是,两个客户端都要求各自的 FSM 与对方进行谈判。一旦发送了 ask negotiate 消息,两个 FSM 就会切换到 idle_wait
状态。然后,他们将能够处理谈判请求。如果我们回顾之前状态图,我们会看到,这种情况是唯一会在 idle_wait
状态下收到 ask negotiate 消息的时候。因此,我们知道,在 idle_wait
状态下收到这些消息意味着我们遇到了竞态条件,并且可以假设两个用户都希望彼此交流。我们可以将他们都移到 negotiate
状态。万岁!
现在我们正在谈判。根据我之前列出的操作列表,我们必须支持用户提供物品,然后撤回报价。
这只是将我们客户端的消息转发到另一个 FSM。两个有限状态机都需要保留一个由任何一方提供的物品列表,以便在收到此类消息时更新该列表。我们在此之后将保持在 negotiate
状态;也许另一个玩家也想要提供物品。
在这里,我们的 FSM 基本上以类似的方式行动。这是正常的。一旦我们厌倦了提供东西,并认为自己足够慷慨,我们就必须说我们已准备好正式进行交易。由于我们必须同步两个玩家,因此我们将不得不使用一个中间状态,就像我们在 idle
和 idle_wait
中所做的那样。
我们在做的就是,一旦我们的玩家准备好,我们的 FSM 就询问 Jim 的 FSM 是否已准备好。在等待其回复期间,我们自己的 FSM 会进入其 wait
状态。我们收到的回复将取决于 Jim 的 FSM 状态:如果它处于 wait
状态,它将告诉我们它已准备好。否则,它将告诉我们它尚未准备好。这正是我们的 FSM 在 negotiate
状态下被 Jim 询问我们是否已准备好时自动回复 Jim 的内容。
我们的有限状态机将保持在 negotiate
模式,直到我们的玩家说他已准备好。假设他做到了,我们现在处于 wait
状态。但是,Jim 还没有到达那里。这意味着,当我们宣布自己已准备好时,我们会问 Jim 是否也已准备好,而他的 FSM 会回复“还没有”。
他还没有准备好,但我们已经准备好了。我们只能继续等待。在等待 Jim 的同时,他仍然在进行谈判,他可能会尝试向我们发送更多物品,或者取消他之前的报价。
当然,我们希望避免 Jim 删除他所有的物品,然后点击“我已准备好!”,从而在交易过程中坑我们。只要他更改了提供的物品,我们就会回到 negotiate
状态,以便我们可以修改自己的报价,或者检查当前报价,然后决定我们已准备好。重复以上步骤。
在某个时候,Jim 也将准备好完成交易。当这种情况发生时,他的有限状态机将询问我们的 FSM 是否已准备好。
我们的 FSM 所做的就是回复我们确实已准备好。不过,我们仍然保持在等待状态,拒绝切换到 ready
状态。为什么呢?因为存在潜在的竞态条件!想象一下,如果在没有执行此必要步骤的情况下,以下事件序列发生了。
这有点复杂,所以我将解释一下。由于消息接收的方式,我们可能只能在声明自己已准备好之后,并且在 Jim 声明自己已准备好之后,才能处理物品报价。这意味着,一旦我们读取报价消息,我们就切换回 negotiate
状态。在此期间,Jim 会告诉我们他已准备好。如果他在那里立即更改状态并继续进入 ready
状态(如上图所示),他将被无限期地卡在等待状态,而我们不知道该怎么办。这种情况也可能反过来发生!哎呀。
解决此问题的一种方法是添加一层间接性(感谢 David Wheeler)。这就是为什么我们保持在 wait
模式并发送“ready!”(如我们之前状态图中所示)的原因。以下是我们如何处理该“ready!”消息,假设我们之前已经告诉我们的 FSM 我们已准备好,并且已经处于 ready
状态。
当我们从另一个 FSM 收到“ready!”时,我们也会发送“ready!”回去。这样做是为了确保我们不会遇到上面提到的“双重竞态条件”。这将在两个 FSM 之一中产生一个多余的“ready!”消息,但我们只需要在这种情况中忽略它即可。然后,我们发送一个“ack”消息(Jim 的 FSM 也将执行相同的操作),然后再进入 ready
状态。这个“ack”消息存在的原因是由于有关同步客户端的一些实现细节。为了正确起见,我已将其放在图中,但不会在后面解释它。现在先忘掉它。我们终于成功地同步了两个玩家。呼。
现在是 ready
状态。这个状态有点特殊。两个玩家都已准备好,基本上已经将有限状态机所需的所有控制权都交给了有限状态机。这使我们能够实现一个 两阶段提交 的简化版本,以确保在正式进行交易时一切顺利。
我们上面描述的版本将非常简单。编写一个真正正确的两阶段提交将需要比我们理解有限状态机所需的代码多得多。
最后,我们只需要允许随时取消交易。这意味着,无论我们处于何种状态,我们都将监听来自双方的“cancel”消息,并退出交易。在离开之前,也应该礼貌地让对方知道我们已经离开了。
好了!这是一次性吸收的大量信息。如果需要一段时间才能完全理解,请不要担心。它需要一群人来检查我的协议,看看它是否正确,即使这样,我们也忽略了一些竞态条件,然后我在几天后在编写本文时查看代码时发现了这些竞态条件。多次阅读是正常的,尤其是你不习惯异步协议的情况下。如果是这种情况,我完全鼓励你尝试设计自己的协议。然后问问自己“如果两个人快速执行相同的操作会发生什么?如果他们快速链接两个其他事件会发生什么?当我更改状态时,我如何处理我没有处理的消息?”你会发现复杂性增长很快。你可能会找到一个类似于我的解决方案,或者可能是更好的解决方案(如果发现更好的解决方案,请告诉我!)。无论结果如何,这都是一件非常有趣的事情,我们的 FSM 仍然相对简单。
一旦你消化了所有内容(或者在消化之前,如果你是一个叛逆的读者),你就可以转到下一节,在那里我们将实现游戏系统。现在,如果你想休息一下,可以喝杯咖啡。
两个玩家之间的游戏交易
使用 OTP 的 gen_fsm
实现我们的协议的第一步是创建接口。我们的模块将有 3 个调用者:玩家、gen_fsm
行为和另一个玩家的 FSM。不过,我们只需要导出玩家函数和 gen_fsm
函数。这是因为另一个 FSM 也将在 trade_fsm 模块中运行,并且可以从内部访问它们。
-module(trade_fsm). -behaviour(gen_fsm). %% public API -export([start/1, start_link/1, trade/2, accept_trade/1, make_offer/2, retract_offer/2, ready/1, cancel/1]). %% gen_fsm callbacks -export([init/1, handle_event/3, handle_sync_event/4, handle_info/3, terminate/3, code_change/4, % custom state names idle/2, idle/3, idle_wait/2, idle_wait/3, negotiate/2, negotiate/3, wait/2, ready/2, ready/3]).
这就是我们的 API。你可以看到我计划让一些函数既同步又异步。这主要是因为我们希望我们的客户端在某些情况下同步调用我们,但另一个 FSM 可以异步调用。让客户端同步通过限制可以一个接一个发送的矛盾消息的数量,极大地简化了我们的逻辑。我们将会做到这一点。让我们首先根据上面定义的协议实现实际的公共 API。
%%% PUBLIC API start(Name) -> gen_fsm:start(?MODULE, [Name], []). start_link(Name) -> gen_fsm:start_link(?MODULE, [Name], []). %% ask for a begin session. Returns when/if the other accepts trade(OwnPid, OtherPid) -> gen_fsm:sync_send_event(OwnPid, {negotiate, OtherPid}, 30000). %% Accept someone's trade offer. accept_trade(OwnPid) -> gen_fsm:sync_send_event(OwnPid, accept_negotiate). %% Send an item on the table to be traded make_offer(OwnPid, Item) -> gen_fsm:send_event(OwnPid, {make_offer, Item}). %% Cancel trade offer retract_offer(OwnPid, Item) -> gen_fsm:send_event(OwnPid, {retract_offer, Item}). %% Mention that you're ready for a trade. When the other %% player also declares being ready, the trade is done ready(OwnPid) -> gen_fsm:sync_send_event(OwnPid, ready, infinity). %% Cancel the transaction. cancel(OwnPid) -> gen_fsm:sync_send_all_state_event(OwnPid, cancel).
这相当标准;所有这些“gen_fsm”函数都已在之前介绍过(除了 start/3-4 和 start_link/3-4,我认为你可以弄清楚)在本章中。
接下来,我们将实现 FSM 到 FSM 函数。第一个函数与交易设置有关,当我们第一次想要要求另一个用户加入我们的交易时。
%% Ask the other FSM's Pid for a trade session ask_negotiate(OtherPid, OwnPid) -> gen_fsm:send_event(OtherPid, {ask_negotiate, OwnPid}). %% Forward the client message accepting the transaction accept_negotiate(OtherPid, OwnPid) -> gen_fsm:send_event(OtherPid, {accept_negotiate, OwnPid}).
第一个函数询问另一个 pid 是否想要交易,第二个函数用于异步回复它。
然后,我们可以编写提供和取消报价的函数。根据我们上面的协议,它们应该是这样的。
%% forward a client's offer do_offer(OtherPid, Item) -> gen_fsm:send_event(OtherPid, {do_offer, Item}). %% forward a client's offer cancellation undo_offer(OtherPid, Item) -> gen_fsm:send_event(OtherPid, {undo_offer, Item}).
所以,现在我们已经完成了这些调用,我们需要关注其余部分。剩下的调用与是否准备好以及处理最终提交有关。同样,根据我们上面的协议,我们有三个调用:are_you_ready
,它可以回复 not_yet
或 ready!
。
%% Ask the other side if he's ready to trade. are_you_ready(OtherPid) -> gen_fsm:send_event(OtherPid, are_you_ready). %% Reply that the side is not ready to trade %% i.e. is not in 'wait' state. not_yet(OtherPid) -> gen_fsm:send_event(OtherPid, not_yet). %% Tells the other fsm that the user is currently waiting %% for the ready state. State should transition to 'ready' am_ready(OtherPid) -> gen_fsm:send_event(OtherPid, 'ready!').
剩下的唯一函数是在两个 FSM 在 ready
状态下进行提交时使用的函数。它们的确切用法将在后面更详细地描述,但现在,名称和之前状态图中的序列/状态图就足够了。尽管如此,你仍然可以将它们转录到你自己的 trade_fsm 版本中。
%% Acknowledge that the fsm is in a ready state. ack_trans(OtherPid) -> gen_fsm:send_event(OtherPid, ack). %% ask if ready to commit ask_commit(OtherPid) -> gen_fsm:sync_send_event(OtherPid, ask_commit). %% begin the synchronous commit do_commit(OtherPid) -> gen_fsm:sync_send_event(OtherPid, do_commit).
哦,还有一个礼貌函数,它允许我们警告另一个 FSM 我们取消了交易。
notify_cancel(OtherPid) -> gen_fsm:send_all_state_event(OtherPid, cancel).
现在我们可以进入真正有趣的部分:gen_fsm
回调。第一个回调是 init/1
。在我们的例子中,我们希望每个 FSM 在其传递给自己的数据中保存一个代表用户的名称(这样,我们的输出会更友好)。我们还想要在内存中保存什么?在我们的例子中,我们想要对方的 pid、我们提供的物品以及对方提供的物品。我们还将添加一个监控的引用(以便我们知道如果对方死亡则要中止),以及一个 from
字段,用于延迟回复。
-record(state, {name="", other, ownitems=[], otheritems=[], monitor, from}).
在 init/1
的情况下,我们现在只关心我们的名称。请注意,我们将从 idle
状态开始。
init(Name) -> {ok, idle, #state{name=Name}}.
接下来要考虑的回调将是状态本身。到目前为止,我已经描述了状态转换和可以执行的调用,但是我们需要一种方法来确保一切顺利。我们将首先编写一些实用函数。
%% Send players a notice. This could be messages to their clients %% but for our purposes, outputting to the shell is enough. notice(#state{name=N}, Str, Args) -> io:format("~s: "++Str++"~n", [N|Args]). %% Unexpected allows to log unexpected messages unexpected(Msg, State) -> io:format("~p received unknown event ~p while in state ~p~n", [self(), Msg, State]).
我们可以从空闲状态开始。为了符合惯例,我将首先介绍异步版本。如果查看 API 函数,你会发现这个版本只需要处理另一个玩家请求交易,而我们自己的玩家会使用同步调用。
idle({ask_negotiate, OtherPid}, S=#state{}) -> Ref = monitor(process, OtherPid), notice(S, "~p asked for a trade negotiation", [OtherPid]), {next_state, idle_wait, S#state{other=OtherPid, monitor=Ref}}; idle(Event, Data) -> unexpected(Event, idle), {next_state, idle, Data}.
设置一个监控器,以便我们可以处理另一个玩家死亡的情况,并将它的引用与另一个玩家的 pid 一起存储在 FSM 的数据中,然后进入 idle_wait
状态。注意,我们将报告所有意外事件并忽略它们,保持在当前状态。我们可能会遇到一些带外消息,这些消息可能是竞争条件的结果。通常忽略它们是安全的,但我们无法轻易摆脱它们。最好不要因为这些未知但可能发生的事件而导致整个 FSM 崩溃。
当我们自己的客户端要求 FSM 联系另一个玩家进行交易时,它将发送一个同步事件。将需要 idle/3
回调。
idle({negotiate, OtherPid}, From, S=#state{}) -> ask_negotiate(OtherPid, self()), notice(S, "asking user ~p for a trade", [OtherPid]), Ref = monitor(process, OtherPid), {next_state, idle_wait, S#state{other=OtherPid, monitor=Ref, from=From}}; idle(Event, _From, Data) -> unexpected(Event, idle), {next_state, idle, Data}.
我们的操作方式与异步版本类似,只是我们需要实际询问另一方是否愿意与我们进行协商。你会注意到我们没有回复客户端。这是因为我们没有有趣的内容要回复,并且希望客户端保持锁定状态,并等待交易被接受后才能执行任何操作。只有在另一方接受后,我们才会在 idle_wait
状态下发送回复。
当我们处于 idle_wait
状态时,我们需要处理对方接受协商和对方要求协商的情况(正如协议中所述的竞争条件的结果)。
idle_wait({ask_negotiate, OtherPid}, S=#state{other=OtherPid}) -> gen_fsm:reply(S#state.from, ok), notice(S, "starting negotiation", []), {next_state, negotiate, S}; %% The other side has accepted our offer. Move to negotiate state idle_wait({accept_negotiate, OtherPid}, S=#state{other=OtherPid}) -> gen_fsm:reply(S#state.from, ok), notice(S, "starting negotiation", []), {next_state, negotiate, S}; idle_wait(Event, Data) -> unexpected(Event, idle_wait), {next_state, idle_wait, Data}.
这为我们提供了进入 negotiate
状态的两种转换,但请记住,我们必须使用 gen_fsm:reply/2
回复我们的客户端,告诉它可以开始提供物品。还有一种情况是我们的 FSM 客户端接受了另一方建议的交易。
idle_wait(accept_negotiate, _From, S=#state{other=OtherPid}) -> accept_negotiate(OtherPid, self()), notice(S, "accepting negotiation", []), {reply, ok, negotiate, S}; idle_wait(Event, _From, Data) -> unexpected(Event, idle_wait), {next_state, idle_wait, Data}.
同样,这将继续进入 negotiate
状态。在这里,我们必须处理来自客户端和另一个 FSM 的异步添加和删除物品的查询。然而,我们还没有决定如何存储物品。因为我有点懒,并且假设用户不会交易太多物品,所以现在简单列表就足够了。然而,我们可能会在以后改变主意,因此将物品操作封装在它们自己的函数中是一个好主意。在文件底部添加以下函数,使用 notice/3
和 unexpected/2
。
%% adds an item to an item list add(Item, Items) -> [Item | Items]. %% remove an item from an item list remove(Item, Items) -> Items -- [Item].
它们很简单,但它们的作用是将操作(添加和删除物品)与其实现(使用列表)隔离开来。我们可以轻松地迁移到属性列表、数组或任何其他数据结构,而不会影响其他代码。
使用这两个函数,我们可以实现提供和删除物品。
negotiate({make_offer, Item}, S=#state{ownitems=OwnItems}) -> do_offer(S#state.other, Item), notice(S, "offering ~p", [Item]), {next_state, negotiate, S#state{ownitems=add(Item, OwnItems)}}; %% Own side retracting an item offer negotiate({retract_offer, Item}, S=#state{ownitems=OwnItems}) -> undo_offer(S#state.other, Item), notice(S, "cancelling offer on ~p", [Item]), {next_state, negotiate, S#state{ownitems=remove(Item, OwnItems)}}; %% other side offering an item negotiate({do_offer, Item}, S=#state{otheritems=OtherItems}) -> notice(S, "other player offering ~p", [Item]), {next_state, negotiate, S#state{otheritems=add(Item, OtherItems)}}; %% other side retracting an item offer negotiate({undo_offer, Item}, S=#state{otheritems=OtherItems}) -> notice(S, "Other player cancelling offer on ~p", [Item]), {next_state, negotiate, S#state{otheritems=remove(Item, OtherItems)}};
这是在双方使用异步消息时的另一个问题。一组消息的形式是“make”和“retract”,而另一组消息的形式是“do”和“undo”。这完全是任意的,只用于区分玩家到 FSM 的通信和 FSM 到 FSM 的通信。注意,对于来自我们玩家的消息,我们必须告诉另一方我们正在进行的更改。
另一个责任是处理我们在协议中提到的 are_you_ready
消息。这是我们在 negotiate
状态中处理的最后一个异步事件。
negotiate(are_you_ready, S=#state{other=OtherPid}) -> io:format("Other user ready to trade.~n"), notice(S, "Other user ready to transfer goods:~n" "You get ~p, The other side gets ~p", [S#state.otheritems, S#state.ownitems]), not_yet(OtherPid), {next_state, negotiate, S}; negotiate(Event, Data) -> unexpected(Event, negotiate), {next_state, negotiate, Data}.
正如协议中所述,无论何时我们不在 wait
状态下并收到此消息,我们都必须回复 not_yet
。我们还将交易详情输出给用户,以便他们做出决定。
当用户做出决定并准备好时,将发送 ready
事件。此事件应该是同步的,因为我们不希望用户在声称自己准备好时继续通过添加物品来修改自己的报价。
negotiate(ready, From, S = #state{other=OtherPid}) -> are_you_ready(OtherPid), notice(S, "asking if ready, waiting", []), {next_state, wait, S#state{from=From}}; negotiate(Event, _From, S) -> unexpected(Event, negotiate), {next_state, negotiate, S}.
此时,应该进行到 wait
状态的转换。注意,仅仅等待对方并没有意义。我们保存了 From 变量,以便在需要告诉客户端时,可以与 gen_fsm:reply/2
一起使用。
wait
状态是一个有趣的家伙。新的物品可能会被提供和撤回,因为另一个用户可能还没有准备好。因此,自动回滚到协商状态是有意义的。如果我们获得了很好的物品,然后另一个用户又将其移除并宣布自己已准备好,从而窃取了我们的战利品,那将是一件糟糕的事情。返回到协商状态是一个好的决定。
wait({do_offer, Item}, S=#state{otheritems=OtherItems}) -> gen_fsm:reply(S#state.from, offer_changed), notice(S, "other side offering ~p", [Item]), {next_state, negotiate, S#state{otheritems=add(Item, OtherItems)}}; wait({undo_offer, Item}, S=#state{otheritems=OtherItems}) -> gen_fsm:reply(S#state.from, offer_changed), notice(S, "Other side cancelling offer of ~p", [Item]), {next_state, negotiate, S#state{otheritems=remove(Item, OtherItems)}};
现在有了有意义的东西,我们用存储在 S#state.from 中的坐标回复玩家。 我们需要关注的下一组消息是那些与同步两个 FSM 相关的消息,以便它们可以进入 ready
状态并确认交易。对于这个消息,我们应该真正关注之前定义的协议。
我们可能遇到的三条消息是 are_you_ready
(因为另一个用户刚刚宣布自己已准备好)、not_yet
(因为我们询问另一个用户是否已准备好,但他们还没有准备好)和 ready!
(因为我们询问另一个用户是否已准备好,并且他们已经准备好)。
我们将从 are_you_ready
开始。请记住,在协议中,我们说过那里可能存在竞争条件。我们唯一能做的事情是使用 am_ready/1
发送 ready!
消息,并稍后处理其他问题。
wait(are_you_ready, S=#state{}) -> am_ready(S#state.other), notice(S, "asked if ready, and I am. Waiting for same reply", []), {next_state, wait, S};
我们将会再次卡在等待状态,因此不值得回复我们的客户端。类似地,当另一方对我们的邀请发送 not_yet
时,我们也不会回复客户端。
wait(not_yet, S = #state{}) -> notice(S, "Other not ready yet", []), {next_state, wait, S};
另一方面,如果另一方已准备好,我们会向另一个 FSM 发送额外的 ready!
消息,回复我们自己的用户,然后进入 ready
状态。
wait('ready!', S=#state{}) -> am_ready(S#state.other), ack_trans(S#state.other), gen_fsm:reply(S#state.from, ok), notice(S, "other side is ready. Moving to ready state", []), {next_state, ready, S}; %% DOn't care about these! wait(Event, Data) -> unexpected(Event, wait), {next_state, wait, Data}.
你可能已经注意到我使用了 ack_trans/1
。事实上,两个 FSM 都应该使用它。为什么呢?要理解这一点,我们必须开始了解 ready!
状态中发生了什么。
处于 ready 状态时,两个玩家的操作都变得毫无用处(除了取消)。我们不会关心新的物品提供。这给了我们一些自由。基本上,两个 FSM 可以自由地相互交流,而无需担心外部世界。这使我们能够实现对两阶段提交的改造。为了在任何玩家行动之前开始提交,我们需要一个事件来触发 FSM 的操作。来自 ack_trans/1
的 ack
事件用于此目的。一旦我们进入 ready 状态,该消息就会被处理并执行操作;事务可以开始。
然而,两阶段提交需要同步通信。这意味着我们不能让两个 FSM 同时开始事务,因为它们最终会陷入死锁。秘密在于找到一种方法来决定一个有限状态机应该启动提交,而另一个有限状态机将坐下来等待第一个有限状态机的命令。
事实证明,设计 Erlang 的工程师和计算机科学家非常聪明(好吧,我们已经知道了这一点)。任何进程的 pid 可以相互比较并排序。这可以在任何时候完成,无论进程是何时生成的,无论它是否还活着,或者它是否来自另一个 VM(当我们进入分布式 Erlang 时,我们将看到更多关于这方面的信息)。
知道两个 pid 可以比较,并且其中一个将大于另一个,我们可以编写一个函数 priority/2
,它将接收两个 pid 并告诉一个进程它是否被选中。
priority(OwnPid, OtherPid) when OwnPid > OtherPid -> true; priority(OwnPid, OtherPid) when OwnPid < OtherPid -> false.
通过调用该函数,我们可以让一个进程启动提交,而另一个进程则遵循命令。
以下是将其包含在 ready
状态中,接收 ack
消息后得到的结果。
ready(ack, S=#state{}) -> case priority(self(), S#state.other) of true -> try notice(S, "asking for commit", []), ready_commit = ask_commit(S#state.other), notice(S, "ordering commit", []), ok = do_commit(S#state.other), notice(S, "committing...", []), commit(S), {stop, normal, S} catch Class:Reason -> %% abort! Either ready_commit or do_commit failed notice(S, "commit failed", []), {stop, {Class, Reason}, S} end; false -> {next_state, ready, S} end; ready(Event, Data) -> unexpected(Event, ready), {next_state, ready, Data}.
这个大型的 try ... catch
表达式是领先的 FSM 决定提交工作方式的方式。ask_commit/1
和 do_commit/1
都是同步的。这使得领先的 FSM 可以自由地调用它们。你可以看到另一个 FSM 只是去等待。然后它将从领先进程接收命令。第一条消息应该是 ask_commit
。这只是为了确保两个 FSM 都还在那里;没有发生错误,它们都致力于完成任务。
ready(ask_commit, _From, S) -> notice(S, "replying to ask_commit", []), {reply, ready_commit, ready, S};
一旦收到此消息,领先进程将要求使用 do_commit
确认事务。此时,我们必须提交我们的数据。
ready(do_commit, _From, S) -> notice(S, "committing...", []), commit(S), {stop, normal, ok, S}; ready(Event, _From, Data) -> unexpected(Event, ready), {next_state, ready, Data}.
完成后,我们就离开了。领先的 FSM 将收到 ok
作为回复,并将知道之后要提交自己的内容。这解释了为什么我们需要大型 try ... catch
:如果回复的 FSM 死亡,或者它的玩家取消了事务,同步调用将在超时后崩溃。在这种情况下,提交应该被中止。
只是让你知道,我定义的提交函数如下:
commit(S = #state{}) -> io:format("Transaction completed for ~s. " "Items sent are:~n~p,~n received are:~n~p.~n" "This operation should have some atomic save " "in a database.~n", [S#state.name, S#state.ownitems, S#state.otheritems]).
太简单了吧?通常不可能只用两个参与者进行真正的安全提交——通常需要第三方来判断两个玩家是否都做了正确的事情。如果你要编写一个真正的提交函数,它应该代表两个玩家联系该第三方,然后为他们安全地写入数据库或回滚整个交换。我们不会深入研究这些细节,目前的 commit/1
函数足以满足本书的需要。
我们还没有完成。我们还没有介绍两种类型的事件:玩家取消交易和另一个玩家的有限状态机崩溃。前者可以通过使用回调 handle_event/3
和 handle_sync_event/4
来处理。无论何时另一个用户取消,我们都会收到一个异步通知。
%% The other player has sent this cancel event %% stop whatever we're doing and shut down! handle_event(cancel, _StateName, S=#state{}) -> notice(S, "received cancel event", []), {stop, other_cancelled, S}; handle_event(Event, StateName, Data) -> unexpected(Event, StateName), {next_state, StateName, Data}.
当我们这样做时,我们一定不要忘记在退出之前告诉另一个用户。
%% This cancel event comes from the client. We must warn the other %% player that we have a quitter! handle_sync_event(cancel, _From, _StateName, S = #state{}) -> notify_cancel(S#state.other), notice(S, "cancelling trade, sending cancel event", []), {stop, cancelled, ok, S}; %% Note: DO NOT reply to unexpected calls. Let the call-maker crash! handle_sync_event(Event, _From, StateName, Data) -> unexpected(Event, StateName), {next_state, StateName, Data}.
瞧!最后一个需要处理的事件是另一个 FSM 崩溃的情况。幸运的是,我们在 idle
状态下设置了监控器。我们可以根据此情况进行匹配并做出相应反应。
handle_info({'DOWN', Ref, process, Pid, Reason}, _, S=#state{other=Pid, monitor=Ref}) -> notice(S, "Other side dead", []), {stop, {other_down, Reason}, S}; handle_info(Info, StateName, Data) -> unexpected(Info, StateName), {next_state, StateName, Data}.
注意,即使 cancel
或 DOWN
事件发生在我们处于提交状态时,一切都应该是安全的,没有人会窃取他们的物品。
注意:我们使用 io:format/2
来处理大多数消息,让 FSM 与它们自己的客户端进行通信。在实际应用中,我们可能需要比这更灵活的东西。一种方法是让客户端发送一个 Pid,该 Pid 将接收发送给它的通知。该进程可以链接到 GUI 或任何其他系统,以便让玩家了解事件。选择 io:format/2
解决方案是因为它简单:我们希望关注 FSM 和异步协议,而不是其他内容。
只剩下两个回调需要介绍!它们是 code_change/4
和 terminate/3
。目前,我们没有针对 code_change/4
执行任何操作,只将其导出,以便 FSM 的下一个版本可以在重新加载时调用它。我们的终止函数也非常短,因为我们在这个示例中没有处理真正的资源。
code_change(_OldVsn, StateName, Data, _Extra) -> {ok, StateName, Data}. %% Transaction completed. terminate(normal, ready, S=#state{}) -> notice(S, "FSM leaving.", []); terminate(_Reason, _StateName, _StateData) -> ok.
呼。
现在我们可以尝试一下了。嗯,尝试它有点烦人,因为我们需要两个进程相互通信。为了解决这个问题,我在文件 trade_calls.erl 中编写了测试,它可以运行 3 种不同的场景。第一个是 main_ab/0
。它将运行一个标准交易并输出所有内容。第二个是 main_cd/0
,它将在交易进行到一半时取消交易。最后一个是 main_ef/0
,它与 main_ab/0
非常相似,只是它包含不同的竞争条件。第一个和第三个测试应该成功,而第二个测试应该失败(并产生大量错误消息,但这就是它的运行方式)。如果你想试试,可以试试。
这可是不小的挑战
如果你觉得本章比其他章节更难,我必须提醒你,这是完全正常的。我只是发疯了,决定把通用的有限状态机行为弄得复杂一些。如果你感到困惑,问问自己这些问题:你能理解不同事件是如何根据你的进程所处的状态来处理的吗?你理解如何从一个状态转换到另一个状态吗?你知道什么时候使用 send_event/2
和 sync_send_event/2-3
,而不是 send_all_state_event/2
和 sync_send_all_state_event/3
吗?如果你对这些问题的回答是肯定的,那么你就理解了 gen_fsm
是什么。
其他关于异步协议、延迟回复、携带 From 变量、优先处理同步调用的进程、混杂的两阶段提交等等 并不是必须理解的。它们主要用于展示可以做什么,并突出显示编写真正并发软件的难度,即使是在像 Erlang 这样的语言中也是如此。Erlang 不会让你免于计划或思考,也不会帮你解决问题。它只会为你提供工具。
话虽如此,如果你理解了关于这些点的所有内容,你可以为自己感到自豪(尤其是在你以前从未编写过并发软件的情况下)。你现在开始真正地并发思考了。
适合现实世界吗?
在一个真正的游戏中,还有很多事情会使交易变得更加复杂。物品可以在角色身上穿戴,并在角色被敌人攻击时损坏。也许物品可以在交易过程中被移进移出物品栏。玩家是否在同一个服务器上?如果不是,你如何同步对不同数据库的提交?
我们的交易系统在脱离任何游戏的现实情况下是合理的。在你尝试将它融入一个游戏之前(如果你敢的话),确保所有事情都顺利进行。测试它,再测试它,然后再次测试它。你可能会发现测试并发和并行代码是一件非常痛苦的事情。你会失去头发、朋友,以及你的理智的一部分。即使这样,你也要知道你的系统始终像它最薄弱的环节一样强大,因此有可能非常脆弱。
不要喝太多酷乐Aid
虽然这种交易系统的模型看起来很合理,但微妙的并发错误和竞争条件往往会在它们被编写很久之后,甚至在它们运行多年后,才会出现。虽然我的代码通常是防弹的(是的,没错),但你有时不得不面对刀剑和刀子。当心休眠的错误。
幸运的是,我们可以把所有这些疯狂抛在脑后。接下来,我们将了解 OTP 如何在 gen_event
行为的帮助下,处理各种事件,如警报和日志。