监督者与工作者(orientation)
理解监督者如何监督和管理进程的状态。
Elixir 并不特别担心代码崩溃的问题,而是确保整个应用程序持续运行。这听起来似乎矛盾,但实际上并非如此。设想一个典型的应用程序:如果某个未处理的错误引发了异常,应用程序就会停止运行,直到重启之前无法执行任何其他操作。如果是服务器同时处理多个请求,那么这些请求可能全部丢失。
问题在于,一个错误会导致整个应用程序崩溃。现在想象一下,我们的应用程序由成百上千个进程组成,每个进程只负责处理请求的一小部分。如果其中一个进程崩溃,其余进程仍然可以继续运行。我们可能会丢失该进程正在执行的工作,但我们可以设计应用来尽量减少这种风险。当这个进程被重新启动后,系统又能恢复到 100% 的运行状态。
在 Elixir 和 OTP 生态中,监督者(supervisors) 负责完成所有进程的监控和重启工作。
监督者
Elixir 中的监督者只有一个目的:管理一个或多个进程。稍后我们会讨论,这些进程可以是工作者(worker)或其他监督者。
从最简单的角度看,监督者就是一个使用 OTP 监督者行为的进程。它接收一个需要监控的进程列表,并被告知在进程崩溃时应如何处理,以及如何防止重启循环(即进程不断重启、崩溃、再重启、再崩溃……)。
为了实现这一点,监督者利用 Erlang 虚拟机提供的进程链接和进程监控功能。我们可以将监督者写成独立的模块,但 Elixir 的风格是将其内联编写。开始最简单的方式是使用 --sup 标志创建项目。我们现在就为序列服务器这样做:
1$ mix new --sup sequence
唯一明显的区别是出现了文件 lib/sequence/application。让我们看看它的内容(已移除部分注释):
1defmodule Sequence.Application do 2 @moduledoc false 3 use Application 4 def start(_type, _args) do 5 children = [ 6 # {Sequence.Worker, arg}, 7 ] 8 opts = [strategy: :one_for_one, name: Sequence.Supervisor] 9 Supervisor.start_link(children, opts) 10 end 11end
现在,我们的 start 函数为应用程序创建了一个监督者。我们只需要告诉它要监督哪些内容即可。我们将上一章中 Sequence.Server 模块的第二个版本复制到 lib/sequence 文件夹中,然后取消注释并修改 children 中的行,引用该模块:
1defmodule Sequence.Application do 2 @moduledoc false 3 4 use Application 5 6 def start(_type, _args) do 7 children = [ 8 { Sequence.Server, 123}, 9 ] 10 11 opts = [strategy: :one_for_one, name: Sequence.Supervisor] 12 Supervisor.start_link(children, opts) 13 end 14end
让我们看看接下来会发生什么:
1)当应用程序启动时,会调用 start 函数。
2)它创建一个子服务器模块的列表。在我们的例子中,只有一个,即 Sequence.Server。
3)除了模块名称外,我们还指定了启动时传递给服务器的参数。
4)我们调用 Supervisor.start_link,传入子进程规范列表和一组选项。这会创建一个监督者进程。
5)现在,监督者进程会为每个受管理的子进程调用 start_link 函数。在我们的例子中,就是 Sequence.Server 中的函数。这部分代码保持不变,它调用 GenServer.start_link 来创建一个 GenServer 进程。
现在,系统已经正常运行了。我们来测试一下:
1iex> Sequence.Server.increment_number 3 2:ok 3iex> Sequence.Server.next_number 4126
目前为止一切顺利。但监督者的真正意义在于,它应该负责管理我们的工作进程。如果它崩溃了,比如我们希望它能自动重启。我们来试试看:如果我们向 increment_number 传递一个非数字类型的值,进程在尝试将其加到当前数值时应该会崩溃。
1iex> Sequence.Server.increment_number "cat" 2:ok 3iex> 410:04:05.805 [error] GenServer Sequence.Server terminating 5** (ArithmeticError) bad argument in arithmetic expression 6 :erlang.+(123, "cat") 7 (sequence 0.1.0) lib/sequence/server.ex:31: Sequence.Server.handle_cast/2 8 (stdlib 3.15.2) gen_server.erl:695: :gen_server.try_dispatch/4 9 (stdlib 3.15.2) gen_server.erl:771: :gen_server.handle_msg/6 10 (stdlib 3.15.2) proc_lib.erl:226: :proc_lib.init_p_do_apply/3 11Last message: {:"$gen_cast", {:increment_number, "cat"}} 12State: 126
运行上述两个代码片段中的命令以执行以下代码:
No configuration found for ID: 82adc38124fbfcd45619d3cafb8f26dfc19c0ef8953ffc80727b8a7f7289327c
我们得到了一份详尽的错误报告,显示了异常信息以及来自进程的堆栈跟踪。我们还能看到触发问题的消息。
但当我们再次向服务器请求一个数字时,它却像什么事都没发生一样作出响应。监督者已经为我们自动重启了该进程。
这非常棒,但有一个问题:监督者重启我们的序列进程时,使用的是我们最初传入的参数,因此数字又从 123 开始了。重生后的进程没有记忆过去的生命,也不会保留崩溃前的状态。
在重启之间维持进程状态
有些服务器本质上是无状态的。例如,一个计算数字因数或返回当前时间的服务器,只需重启即可继续运行。
但我们的服务器不是无状态的。它需要记住当前的数值,以便生成递增的序列。
解决这个问题的所有方法都涉及将状态存储在进程之外。我们选择一种简单的方法:编写一个单独的进程来存储和检索数值。我们称它为 储藏室(stash)。序列服务器可以在终止时将当前数值存入储藏室,重启时再从中恢复数值。
现在,我们需要考虑生命周期问题。我们的序列服务器应该足够健壮,但我们已经发现它存在一个可能导致崩溃的缺陷。从可靠性角度看,它并不是调度队列中最可靠的进程。但储藏室进程必须更加稳健,至少要能比序列服务器存活更久。
要做到这一点,我们需要做两件事。第一,让储藏室尽可能简单。代码越少的可变部分,出错的可能性就越低。
第二,我们必须安排好结构,使储藏室与序列服务器的失败隔离。
我们现在先完成第一件事:创建一个极其简单的服务器,其唯一目的就是存储一个单一数值。
1defmodule Sequence.Stash do 2 use GenServer 3 4 @me __MODULE__ 5 6 def start_link(initial_number) do 7 GenServer.start_link(__MODULE__, initial_number, name: @me) 8 end 9 10 def get() do 11 GenServer.call(@me, { :get }) 12 end 13 14 def update(new_number) do 15 GenServer.cast(@me, { :update, new_number }) 16 end 17 18 # 服务器实现 19 20 def init(initial_number) do 21 { :ok, initial_number } 22 end 23 24 def handle_call({ :get }, _from, current_number ) do 25 { :reply, current_number, current_number } 26 end 27 28 def handle_cast({ :update, new_number }, _current_number) do 29 { :noreply, new_number } 30 end 31 32end
现在,我们要对它进行监督。它将与序列服务器一起运行:
这是我们第一次同时监督两个服务器。因此,我们必须面对一个问题:如果其中只有一个崩溃了,会发生什么?答案取决于我们选择的监督策略。
使用 :one_for_one 策略时,如果某个服务器崩溃,监督者只会重启它。这是默认策略。
使用 :one_for_all 策略时,如果某个服务器崩溃,其余所有服务器都会先被终止,然后全部重启。
使用 :rest_for_one 策略时,如果某个服务器崩溃,列表中位于它之后的所有服务器会被终止,然后崩溃的服务器及其后续被终止的服务器都会被重启。
目前我们只有两个服务器。储藏室服务器应该是永久存在的,而序列服务器可能会崩溃并需要重启。因此,我们不能使用 :one_for_all。
剩下两种选择,两者都能工作。如果使用 :one_for_one,序列服务器失败时会重启,而储藏室不受影响。如果使用 :rest_for_one,同样的效果也会发生,前提是序列服务器在子进程列表中排在储藏室之后。
该如何选择?我们选择 :rest_for_one,并不是因为它与 :one_for_one 有不同行为,而是因为它更能清晰表达我们的意图:“这个服务器依赖于列表中前面服务器的健康状态。”
现在,我们在监督器启动代码中添加储藏室,并更新监督策略:
1defmodule Sequence.Application do 2 @moduledoc false 3 4 use Application 5 6 def start(_type, _args) do 7 children = [ 8 { Sequence.Stash, 123}, 9 { Sequence.Server, nil}, 10 ] 11 12 opts = [strategy: :rest_for_one, name: Sequence.Supervisor] 13 Supervisor.start_link(children, opts) 14 end 15 16end
最后,我们需要修改序列服务器以使用这个储藏室。目前,我们让它在启动时从储藏室获取当前值作为初始状态,并在崩溃时将值存回储藏室。
设置初始状态意味着在序列服务器的 init 函数中从储藏室获取当前值。处理服务器退出则需要添加另一个回调函数 terminate。以下是完整代码:
1defmodule Sequence.Server do 2 use GenServer 3 4 ##### 5 # 外部 API 6 7 def start_link(_) do 8 GenServer.start_link(__MODULE__, nil, name: __MODULE__) 9 end 10 11 def next_number do 12 GenServer.call __MODULE__, :next_number 13 end 14 15 def increment_number(delta) do 16 GenServer.cast __MODULE__, {:increment_number, delta} 17 end 18 19 ##### 20 # GenServer 实现 21 22 def init(_) do 23 { :ok, Sequence.Stash.get() } 24 end 25 26 def handle_call(:next_number, _from, current_number) do 27 { :reply, current_number, current_number+1 } 28 end 29 30 def handle_cast({:increment_number, delta}, current_number) do 31 { :noreply, current_number + delta} 32 end 33 34 def terminate(_reason, current_number) do 35 Sequence.Stash.update(current_number) 36 end 37 38end
现在我们在 IEx 中测试这个改动:
1iex> Sequence.Server.next_number 2123 3iex> Sequence.Server.next_number 4124 5iex> Sequence.Server.next_number 6125 7iex> Sequence.Server.increment_number "cat" 8:ok 9iex> 1012:15:48.424 [error] GenServer Sequence.Server terminating 11** (ArithmeticError) bad argument in arithmetic expression 12 (sequence) lib/sequence/server.ex:39: Sequence.Server.handle_cast/2 13iex> Sequence.Server.next_number 14126 15iex> Sequence.Server.next_number 16127
太棒了!服务器代码虽然崩溃了,但被自动重启了。在这个过程中,状态被保存到了储藏室,并在重启时成功恢复。序列继续无缝运行。
简化储藏室
我们的储藏室模块唯一的职责就是存储一个值。当我们了解代理(agents)时,会发现它们非常适合这种用途,我们可以进一步简化代码。
No configuration found for ID: 12a092023382fd610a75223ab7f004898a3e67a762cf6cba5eab4a76384dfc19
Can we use the :one_for_all strategy for stash and sequence servers? If not, why? Give any reason.