Erlang极简学习笔记<10>——OTP篇

697 阅读17分钟
  • Erlang的巨大优势一部分来自于其并发和分布式特性,还有一部分来自其错误处理能力,OTP框架则是第三部分

  • 在一个服务器框架中,我们通常需要解决的问题有:进程(服务)命名、超时配置、调试信息、非期望消息处理、代码热加载、特殊错误的处理、公共回复代码、服务器关闭的处理、保证服务器和监督者的配合等。自己动手解决这些问题是一件有风险的事情,很幸运,Erlang/OTP已经在gen_server行为中解决了所有这些问题

  • OTP的gen_server行为会要求提供一些进程的初始化和结束、基于消息发送的同步和异步处理以及其他任务的处理函数

  • init/1函数负责初始化服务器的状态,并完成服务器需要的所有一次性任务。这个函数可以返回{ok, State}{ok, State, TimeOut}{ok, State, hibernate}{stop, Reason}以及ignore

  • 常规的{ok, State}返回值无需解释,只要记住State会直接传给进程的主循环,并作为进程的状态一直保存在那里就行了。

  • 当期望服务器在某个时间期限之前能收到一条消息时,可以使用TimeOut变量。如果到期没有收到任何消息,那么会给服务器发送一条特殊的消息(原子timeout),可以在handle_info/2中处理这条消息。很少会在产品代码使用这个选项,因为不能总是知道会收到哪条消息,而任意一条消息都会重置计时器。通常,更好的方法是使用erlang:start_timer/3之类的函数,可以获得更好的处理控制

  • 如果确实觉得进程在很长一段时间内不会有什么消息要处理,并且担心内存问题,那么可以在元组中使用hibernate原子。一般来讲,hibernate选项会缩减进程的状态,直到它收到一条消息,不过会多耗费些处理能力。如果在是否使用hibernate选项时存有疑惑,就说明不太需要它

  • 如果在初始化的过程中出现了错误,可以返回{stop, Reason}

  • 注意!当执行init/1函数时,创建服务器的进程会被阻塞。这是因为它在等待一条“就绪”消息,这条消息由gen_server模块自动发送以确认一切正常

  • handle_call/2函数用于处理同步消息。它有3个参数:RequestFrom以及State

  • gen_server中,有8种不同的返回值可供选择,这些返回值都是元组形式

    • {reply, Reply, NewState}
    • {reply, Reply, NewState, TimeOut}
    • {reply, Reply, NewState, hibernate}
    • {noreply, NewState}
    • {noreply, NewState, TimeOut}
    • {noreply, NewState, hibernate}
    • {stop, Reason, Reply, NewState}
    • {stop, Reason, NewState}
  • 这些返回值中,TimeOuthibernate的工作方式和init/1中的一样。Reply中的内容会被原封不动地发回给调用服务器的进程

  • 共有3种不同的noreply选项,当使用noreply时,服务器的通用部分会认为你将自己发送回应消息,可以调用gen_server:reply/2发送回应

  • 在绝大部分情况下,只需要使用reply元组。不过有些情况确实需要使用noreply,例如,希望由另一个进程来替你发送回应,或者想先发送一条确认消息(“嗨!我收到消息了!”),然后继续处理(处理完无需回应)。如果这是所需要的场景,那么只能使用gen_server:reply/2,否则,调用会超时然后崩溃

  • handle_cast函数用于异步消息的处理,它的参数是:MessageState。和handle_call/3类似,其中也可以进行任何处理。不过它只能返回noreply元组

    • {noreply, NewState}
    • {noreply, NewState, TimeOut}
    • {noreply, NewState, hibernate}
    • {stop, Reason, NewState}
  • handle_info函数用于处理和接口不相容的消息。它和handle_case/2非常类似,事实上,返回值也完全一样。它们之间的区别在于,这个回调函数只用来处理直接通过!操作符发送的消息,以及如init/1timeout、监控器通知或者EXIT信号之类的特殊消息

  • 当上面3种handle_something函数返回形如{stop, Reason, NewState}或者{stop, Reason, Reply, NewState}的元组时,会调用terminate/2函数。它有两个参数:ReasonState,分别对应stop元组中的同名字段

  • 当父进程(创建服务器的进程)死亡时,也会调用terminate/2函数,不过这只会发生在gen_server捕获了退出信号的时候

  • 如果在调用terminate/2时,原因不是normalshutdown或者{shutdown, Term},那么OTP框架会把这当成故障,并会把进程的状态、故障原因、最后收到的消息等记入日志。这让调试变得更加容易,可以帮助你快速定位问题

  • 这个函数和init/1正好相反,因此所有在init/1中做的动作都应该在terminate/2中有对应的取消动作

  • code_change函数用于代码升级,它的调用形式是code_change(PreviousVersion, State, Extra)。其中,变量PreviousVersion在升级时是版本数据项本身,在降级时是{down, Version}State变量中保存着服务器当前的所有状态数据,可以对其进行转换

  • 假如我们一开始使用一个有序字典来存储所有数据。一段时间之后,有序字典变得越来越慢,我们决定用常规字典把它替换掉。为了避免进程在接下来的调用中崩溃,可以在这个函数中安全地进行数据结构的转换。所要做的就是用{ok, NewState}返回新的状态

  • gen_server的调用与回调关系

    • gen_server:start/3-4 <-> YourModule:init/1
    • gen_server:start_link/3-4 <-> YourModule:init/1
    • gen_server:call/2-3 <-> YourModule:handle_call/3
    • gen_server:cast/2 <-> YourModule:handle_cast/2
  • 还有其他几个回调函数如handle_info/2terminate/2code_change/3,这些回调函数主要处理一些特殊情况

  • gen_fsm行为和gen_server有点类似,因为gen_fsmgen_server行为的一个专用版本。它们之间最大的区别在于,gen_fsm中不再处理call消息和cast消息,而是处理同步和异步事件

  • FSM中的init函数和通用服务器中使用的init/1完全一样,除了返回值多一些,可接受的返回值为:{ok, StateName, Data}{ok, StateName, Data, Timeout}{ok, StateName, Data, hibernate}以及{stop, Reason}stop元组的工作原理和gen_server中的完全一样,hibernateTimeout的语义也保持不变

  • StateName是一个新出现的变量。StateName是原子类型,表示下一个被调用的回调函数

  • 函数StateName/2StateName/3是占位名字,由你来决定它们的内容

  • 假设init/1函数返回元组{ok, sitting, dog},这意味着FSM会处于sitting状态

  • 在上面的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, hibernate}
    • {stop, Reason, NewStateData}
  • 函数sitting/3的参数与此类似,只是在EventStateData之间多了一个From参数。From参数和gen_fsm:reply/2的用法与gen_server中的完全一样

  • 函数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/3回调函数正好满足需要。这个函数的参数和StateName/2类似,不过它在中间多了一个参数StateName(handle_event(Event, StateName, Data)),这个参数表明了收到事件时所处的状态。它的返回值和函数StateName/2一样

  • 回调函数handle_sync_event/4StateName/3的关系与handle_event/2StateName/2的关系一样。这个函数处理同步全局事件,参数和所返回的元组种类都和StateName/3一样

  • 通过向FSM发送事件所使用的函数,我们可以知道一个事件是全局的还是针对某个特定状态的。被StateName/2函数处理的异步事件是用函数gen_fsm:send_event/2发送的,而被StateName/3函数处理的同步事件是用函数gen_fsm:sync_send_event/2-3发送的(第三个可选的参数是超时)

  • 两个对等的用来发送全局事件的函数为:gen_fsm:send_all_state_event/2gen_fsm:sync_send_all_state_event/2-3

  • FSM中code_change函数的工作方式和gen_server中的完全一样,只是多处一个额外的状态参数,如:code_change(OldVersion, StateName, Data, Extra),并且返回的元组格式为{ ok, NextStateName, NewStateData }

  • 同样的,FSM中terminate的行为和通用服务器中也类似,terminate(Reason, StateName, Data)函数做多额工作应该和init/1相反

  • gen_event行为与gen_server以及gen_fsm有很大的不同,它根本不需要实际启动一个进程。之所以不需要进程是因为它的工作方式是“接受一组回调函数”

  • 简单来讲,gen_event行为运行这个接受并调用回调函数的事件管理器进程,而你只需要提供包含这些回调函数的模块即可。这意味着你无需关心事件分派,只需按照事件管理器要求的格式放置回调函数即可。所有的事件管理就自然都有了,你只需提供应用特定的东西

  • initterminate函数与我们前面看到的gen_servergen_fsm行为中的类似。init/1函数接收列表参数,返回{ok, State}。在init/1中创建的东西,要在terminate/2函数中有对应释放操作

  • handle_event(Event, State)函数可以说是gen_event回调模块的核心函数。和gen_server中的handle_cast/2一样,handle_event/2函数也是异步的。不过,它的返回值有所不同:

    • {ok, NewState}
    • {ok, NewState, hibernate},让事件管理器进程进入休眠状态,直到收到下一个事件
    • remove_handler
    • {swap_handler, Args1, NewState, NewHandler, Args2}
  • 返回值{ok, NewState}元组的含义和gen_server:handle_cast/2函数中的一样。它只更新自己的状态,不做任何回应。

  • 返回{ok, NewState, hibernate}则会使整个事件管理器进入休眠状态。记住,事件处理器和其他管理器运行在同一个进程中

  • 返回remove_handler则会导致事件处理器从事件管理器中删除。当某个事件处理器知道自己已经完成工作并且无其他任务时,可以使用这个返回值

  • 最后一个返回值{swap_handler, Args1, NewState, NewHandler, Args2},它移除当前事件处理器并用一个新的替代它。事件管理器首先调用CurrentHandler:terminate(Args1, NewState)函数,并移除当前的事件处理器。接着调用NewHandler:init(Args2, ResultFromTerminate)函数,添加新的事件处理器。

  • 所有事件都是通过gen_event:notify/2函数触发的,和gen_server:cast/2一样,它也是异步的。还有另外一个函数gen_event:sync_notify/2,它是同步的。由于handle_event/2是异步的,这里的同步指的是,当所有事件处理器都收到这个事件并且处理完毕后,sync_notify函数才会返回。在那之前,事件管理器会一直阻塞调用进程,不予响应

  • handle_call函数和gen_serverhandle_call回调函数类似,不同之处在于,它可以返回{ok, Reply, NewState}{ok, Reply, NewState, hibernate}{remove_handler, Reply}以及{swap_handler, Reply, Args1, NewState, Handler2, Args2}。使用gen_event:call/3-4函数就可以发起该调用

  • handle_info回调和handle_event回调非常相似(有着同样的返回值和含义),唯一的不同在于,handle_info只处理带外消息,如退出信号或使用!操作符直接向事件管理器进程发送消息。它的使用场景和gen_server以及gen_fsm中的handle_info的使用场景类似

  • code_change函数的工作方式和gen_server中的一样,不过它仅仅针对单独的事件处理器。它的参数为:OldVsnStateExtra,分别表示版本号、当前事件处理器的状态、最后这个参数——Extra,目前可以不用关心。这个方法只要返回{ok, NewState}即可

  • 在所有OTP中,监督者是最容易使用和理解的一个,但也是最难设计好的一个。在Erlang中应该把所有东西都监督起来,此外,监督机制还可以让你以恰当的顺序终止应用

  • 当想终止一个应用时,只需去终止虚拟机中最顶层的那个监督者(调用init:stop/1函数就可以了)。然后这个监督者会接着要求它的子进程停止运行。如果某个子进程也是一个监督者,它会做同样的事情

  • 如果没有用树形结构来组织所有的进程,那么很难让VM以井然有序的方式终止。当然,有时也会出现进程因某种原因被卡住而不能正常终止的情况。此时,监督者可以强行杀死这个进程。

  • 监督者使用起来很简单,我们只需要提供一个回调函数:init/1。麻烦的地方在于这个函数的返回值非常复杂。下面是一个返回值的例子:

    {ok, {{one_for_all, 5, 60},
          [{fake_id,
            {fake_mod, start_link, [SomeArg]},
            permanent,
            5000,
            worker,
            [fake_mode]},
           {other_id,
            {event_manager_mod, start_link, []},
            transient,
            infinity,
            worker,
            dynamic}]}}
    
  • 重启策略RestartStrategy的值可以为:

    • one_for_one,当被监督的进程都是独立的、互不相关的,或者即便这些进程重启后丢失了自己的状态,也不会对其他进程产生影响时,可以使用one_for_one策略
    • one_for_all,当所有工作者进程都受同一个监督者监督,且这些工作者进程必须互相依赖才能正常工作时,就使用这个策略
    • rest_for_one,如果一个进程死了,那么所有在这个进程之后启动的进程(依赖于该进程)都将被重启,反之不然
    • simple_one_for_one,这个类型的监督者只监督一种子进程,当希望以动态的方式向监督者中增加子进程(当需要一个新的子进程时,向它发起请求,就能得到一个),而不是静态启动子进程时,可以使用这种策略
  • RestartStrategy元组中剩余的两个变量是MaxRestartMaxTime。他们的意思是,如果在MaxTime(以秒为单位)指定的时间内,重启次数超过了MaxRestart指定的数字,那么监督者会放弃重启并终止所有子进程,然后自杀,永远停止运行

  • 子进程规格说明可以描述成:{ChildId, StartFunc, Restart, Shutdown, Type, Modules}

  • ChildId只是监督者内部使用的一个名称

  • StartFunc是一个元组,用来指定子进程的启动方式,它采用了标准的{M, F, A}格式。注意!这里的启动函数是OTP兼容的,在执行时会和调用者进程链接在一起,这一点非常重要

  • Restart指定了监督者在某个特定的子进程死后的处理方式,它可以取如下3个值:

    • permanent: 不管发生什么,一个永久进程都要被重启
    • temporary: 指的是那种绝对不应该被重启的进程
    • transient: 介于上述两种进程之间。如果被正常终止了,就不会被重启。如果异常死亡,就会被重启
  • Shutdown当要求最顶层的监督者终止时,它会对每个子进程调用exit(ChildPid, shutdown)。如果这个子进程是一个工作者进程并且捕获了退出信号,那么就会调用自己的terminate函数;否则,进程死掉就行了。如果是一个监督者子进程收到了shutdown信号,它会用同样的方式将这个信号转发给它的子进程

  • 子进程规格说明中的Shutdown值用来指定终止的超时期限,它可以设置一个确定的终止超时,可以是多少毫秒,也可以是infinity。如果指定时间过去了,进程还没有死,那么进程会被exit(Pid, kill)强行杀死

  • 如果对子进程并不在意,不设定超时等待时间时,子进程死亡也没啥影响,那么可以将Shutdown设置成原子brutal_kill。使用brutal_kill会调用exit(Pid, kill)杀死子进程,此时,退出是即时的,子进程也无法捕获这个退出信号

  • Type字段可以让监督者知道子进程是一个监督者(supervisor)(实现了supervisor或者supervisor_bridge行为)还是一个工作者(worker)(任何其他OTP进程)

  • Modules是一个列表,其中只有一个元素:子进程行为使用的回调模块名。有一个例外情况:事先无法知道回调模块的标示符(如事件管理器中的事件处理器模块)。此时,Modules的值要设置成dynamic,这样,在使用其他高级特性(如发布)时,整个OTP系统才能知道去找谁

  • 动态监督使用one_for_onerest_for_one或者one_for_all策略把工作者进程加入监督者中时,除了该进程的pid和其他一些信息外,还会向监督者持有的一个列表中增加子进程规格说明。在以后重启子进程或者执行其他任务时,会使用这份子进程规格说明。基于这种工作方式,相应的接口定义如下:

    • start_child(SupervisorNameOrPid, ChildSpec) 向列表中增加一个子进程规格说明,并且用该规格说明启动一个子进程
    • terminate_child(SupervisorNameOrPid, ChildId) 终止或者强行杀死(brutal_kills)指定的子进程。子进程的规格说明仍然保留在监督者中
    • restart_child(SupervisorNameOrPid, ChildId) 使用子进程规格说明重启子进程
    • delete_child(SupervisorNameOrPid, ChildId 删除指定ChildId所对应的子进程规格说明
    • check_childspecs([ChildSpec]) 检查一个子进程规格说明是否有效。在调用start_child/2函数前,可以用这个函数去测试一下规格说明的有效性
    • count_children(SupervisorNameOrPid) 分类列举出该监督者下的所有子进程,包括活动进程个数、子进程规格说明个数、监督者类型的个数和工作者类型的个数
    • which_children(SupervisorNameorPid) 返回一个指定监督者下所有子进程信息的列表
  • 当子进程不多时,这些函数很适用于各种动态性要求(启动、终止等)。不过,由于内部使用的是列表,因此当需要快速访问大量子进程时,并不是很适用。此时所需要的是simple_one_for_one

  • 使用simple_one_for_one策略的监督者把所有子进程信息存放在一个字典中,这样可以快速查找,并且对监督者的所有子进程来说,只有一份子进程规格说明

  • 编写simple_one_for_one策略监督者的方式基本上和其他策略的监督者类似,只有一点不同:{M, F, A}元组中的参数列表A并不是全部参数,完整的参数是把supervisor:start_child(Sup, Args)调用中的Args追加到A之后的新列表

  • 这里的supervisor:start_child/2的含义改变了。与原来的supervisor:start_child(Sup, Spec)调用erlang:apply(M, F, A)不同,现在的supervisor:start_child(Sup, Args)调用的是erlang:apply(M, F, A++Args)

    init(jamband) ->
        {ok, {{simple_one_for_one, 3, 60},
              [{jam_musician,
               {musicians, start_link, []},
               temporary, 1000, worker, [musicians]}
              ]}}
    
  • 仅当明确知道要监督的子进程数量不多并且(或者)不需要频繁地操控子进程,或者对性能要求不高的情况下,可以动态地使用标准监督者。对于其他需要动态监督的情况,尽可能使用simple_one_for_one

原文链接