Elixir Supervisor
Supervisor
是一个用于实现监督者的模块。
监督者是一个监督其他进程的进程,被监督的进程我们称为子进程。监督者用来构建一个进程层级结构,称为监督树。监督树提供了容错性,并封装了应用的启动和停止。
有两种方式启动监督者,一是通过 start_link/2
,二是定义一个监督者模块并实现所需的回调。以下大部分示例都是使用前者,但也有使用后者的。
示例
为了启动一个监督者,我们首先需要定义一个将被监督的子进程。作为例子,我们将定义一个 GenServer
,一个通用计数服务器。其他进程可以向这个进程发送消息来读取计数器并增加其值。
免责声明
在实际中我们不会使用 GenServer 来做计数器,相反,如果你需要一个计数器,你会将它作为输入和输出传递给需要它的函数。我们在这里选择计数器作为示例是因为它比较简单,因为我们只关注监督者如何工作。
defmodule Counter do
use GenServer
def start_link(arg) when is_integer(arg) do
GenServer.start_link(__MODULE__, arg, name: __MODULE__)
end
## Callbacks
@impl true
def init(counter) do
{:ok, counter}
end
@impl true
def handle_call(:get, _from, counter) do
{:reply, counter, counter}
end
def handle_call({:bump, value}, _from, counter) do
{:reply, counter, counter + value}
end
end
Counter
在 start_link
上接收的参数会被传递给 init/1
回调,作为计数器的初始值。我们的计数器处理两个操作(称为调用): :get
获取当前计数器的值, :bump
使计数器加上 value
并返回旧的计数器值。
现在我们可以启动一个监督者并监督我们的计数器进程了。第一步是定义一个子节点描述列表,用来控制每个子进程的行为。每个子节点描述是一个 map,如下所示:
children = [
# The Counter is a child started via Counter.start_link(0)
%{
id: Counter,
start: {Counter, :start_link, [0]}
}
]
# Now we start the supervisor with the children and a strategy
{:ok, pid} = Supervisor.start_link(children, strategy: :one_for_one)
# After started, we can query the supervisor for information
Supervisor.count_children(pid)
#=> %{active: 1, specs: 1, supervisors: 0, workers: 1}
注意,启动 GenServer 时,我们通过 name: __MODULE__
选项使用名称 Counter
注册进程。这让我们可以直接调用它并获取它的值:
GenServer.call(Counter, :get)
#=> 0
GenServer.call(Counter, {:bump, 3})
#=> 0
GenServer.call(Counter, :get)
#=> 3
然而,我们的计数器服务器有一个问题。如果我们用一个非数字值调用 :bump
,它就会崩溃:
GenServer.call(Counter, {:bump, "oops"})
** (exit) exited in: GenServer.call(Counter, {:bump, "oops"}, 5000)
幸运的是,由于服务器被监督者监督,监督者会自动启动一个新的服务,重置初始值为 0 :
GenServer.call(Counter, :get)
#=> 0
监督者支持不同的策略;在上述示例中,我们选择了 :one_for_one
。此外,每个监督者可以有多个工作进程或监督者作为子进程,每个都有自己的配置。
接下来我们将讨论如何指定子进程,如何启动和停止它们,不同的监督策略等。
子进程描述
子进程描述描述了监督者如何启动、关闭和重启子进程。
子进程描述是一个包含多达6个元素的 map。如下所示,前两个是必需的,其余的是可选的:
:id
- 用于在监督者内部唯一标识一个子进程描述的值,可以是任意类型,默认是模块名。此项是必须的。对于监督者,如果:id
的值冲突了,它会拒绝初始化并要求指定ID。动态监督者则不是这样。:start
- 一个包含模块-函数-参数的元组用来启动子进程,此项是必须的。:restart
- 一个原子值,用来定义已终止的进程是否应该重启。它是可选的,默认是:permanent
。:shutdown
- 一个整数或原子,用来定义子进程应该如何终止。它也是可选的,:worker
类型的默认值是5_000
,:supervisor
类型的默认值是:infinity
。:type
- 指定子进程是:worker
还是:supervisor
。它是可选的,默认为:worker
。:modules
- 一个用于代码热更新机制的模块列表,用来确定哪些进程在使用指定的模块。它通常设置为GenServer
,Supervisor
等的回调模块。它会根据:start
的值自动设置,并且在实践中也极少修改。:significant
- 一个布尔值,表示监督者是否应用随着该子进程的退出而退出,也就是表示该子进程是否是”重要“的。只有:transient
和:temporary
子进程可以设置为”重要“。该配置是也是可选的,默认是false
。
接下来让我们看看 :shutdown
和 :restart
选项的作用。
:shutdown的可选值
:shutdown
选项支持以下值:
:brutal_kill
- 通过调用Process.exit(child, :kill)
,子进程被无条件立即终止。- 任意大于等于0的整数 - 监督者发出
Process.exit(child, :shutdown)
信号后等待子进程退出的毫秒数。如果子进程没有捕获退出信号,:shutdown
信号会立即终止子进程。如果子进程捕获了退出信号,它可以在给定时间内停止。如果给定时间内没有停止,子进程会被监督者通过Process.exit(child, :kill)
无条件立即终止。 :infinity
- 它与整数值类似,但是会无限期等待子进程退出。如果子进程是监督者,推荐使用:infinity
给监督者及其子进程足够的时间关闭。它也可以用于工作进程,但是我们不推荐这样做,需要及其小心。否则,子进程将永远不会停止,还会阻塞你的应用的关闭。
:restart的可选值
:restart
选项控制监督者区分进程是否正常退出。如果正常终止,监督者不会重启子进程。如果异常退出,监督者会重启子进程。
:restart
选项支持以下值:
:permanent
- 子进程总是被重启。:temporary
- 不管是什么监督策略,子进程都永远不会被重启。:transient
- 子进程只有在异常退出时会被重启,即:normal
,:shutdown
或{:shutdown, term}
之外的退出原因。
对于退出原因及其影响的更完整解释,请参见“退出原因和重启”部分。
child_spec/1
函数
当启动一个监督者时,我们会传递一个子进程描述列表。这些描述是 map,它告诉监督者应该如何启动、停止和重启每个子进程:
%{
id: Counter,
start: {Counter, :start_link, [0]}
}
上面的 map 定义了一个子进程,其 :id
是 Counter
,通过调用 Counter.start_link(0)
启动。
然而,使用 map 为每个子进程定义子进程描述可能会很容易出错,因为我们可能会更改 Counter
的实现却忘记更新其子进程描述。因此 Elixir 允许你传递一个包含模块名和 start_link
的参数的元组:
children = [
{Counter, 0}
]
然后监督者会调用 Counter.child_spec(0)
来获取子进程描述。现在 Counter
模块会自己负责构建自己的子进程描述,例如,我们可以编写以下函数:
def child_spec(arg) do
%{
id: Counter,
start: {Counter, :start_link, [arg]}
}
end
然后监督者会调用 Counter.start_link(arg)
来启动子进程。这个流程如下表所示。调用者是派生监督者进程的进程。然后监督者继续调用你的代码(模块)来生成其子进程:
sequenceDiagram
participant C as Caller (Process)
participant S as Supervisor (Process)
participant M as Module (Code)
note right of C: child is a {module, arg} specification
C->>+S: Supervisor.start_link([child])
S-->>+M: module.child_spec(arg)
M-->>-S: %{id: term, start: {module, :start_link, [arg]}}
S-->>+M: module.start_link(arg)
M->>M: Spawns child process (child_pid)
M-->>-S: {:ok, child_pid} | :ignore | {:error, reason}
S->>-C: {:ok, supervisor_pid} | {:error, reason}
幸运的是, use GenServer
已经定义了一个 Counter.child_spec/1
,所以你不需要自己编写上面的函数。如果你想自定义自动生成的 child_spec/1
函数,可以直接向 use GenServer
传递选项:
use GenServer, restart: :transient
最后,其实你也可以简单地只传递 Counter
模块作为子进程描述:
children = [
Counter
]
当只给出模块名时,它等同于 {Counter, []}
,当然这在我们的例子中是不行的,因此我们总是显示传递计数器初始值。
使用 {Counter, 0}
,我们将子进程描述封装在了 Counter
模块中。现在我们可以与其他开发人员分享我们的 Counter
实现,他们可以直接将其添加到他们的监督树中,而不必担心计数器的底层细节。
总的来说,子进程描述可以是以下形式之一:
- 一个代表子进程描述的 map
- 一个包含模块和启动参数的二元组 - 例如
{Counter, 0}
。此时,Count.child_spec(0)
会被调用以获取子进程描述。 - 一个模块 - 如
Counter
。此时,Counter.child_spec([])
会被调用,虽然在我们的例子中行不通,但是在其他情况下还是很有用的,特别是当你想向子进程传递一些选项时。
如果需要将 {module, arg}
元组或模块转换为子进程描述,或修改子进程描述,可以使用 Supervisor.child_spec/2
函数。例如,用不同的 :id
运行计数器,并设置 :shutdown
值为10秒(10_000毫秒):
children = [
Supervisor.child_spec({Counter, 0}, id: MyCounter, shutdown: 10_000)
]
监督者策略和选项
到目前为止,我们已经启动了一个监督者,并传递给它一个子进程元组,还有一个称为 :one_for_one
的策略:
children = [
{Counter, 0}
]
Supervisor.start_link(children, strategy: :one_for_one)
start_link/2
的第一个参数是如上所述的子进程描述列表。
第二个参数是一个关键字列表:
:strategy
- 监督策略选项。它可以是:one_for_one
、:rest_for_one
或:one_for_all
。必填项。max_restarts
- 在规定时间内的最大重启次数,默认为3。max_seconds
- 指定上面的”规定时间“。默认为5。:auto_shutdown
- 自动停止(指监督者)选项。可以是:never
,:any_significant
或all_significant
。可选项。:name
- 注册监督者进程的名字。可选项。
策略
监督者支持不同的监督策略(:strategy
选项):
:one_for_one
- 如果一个子进程停止,只有它会被重启。:one_for_all
- 如果一个进程终止,所有其他子进程否会被终止,然后所有子进程全部被重启。:rest_for_one
- 如果一个子进程终止,在它后面启动的进程会被终止,然后一起被重启。
以上说的进程终止指的是异常终止,是否重启由 :restart
选项决定。
要高效地监督动态启动的子进程,参见 DynamicSupervisor
。
自动停止
监督者可以在被标记为 :significant
的子进程退出时自动关闭自己。
监督者支持不同的自动关闭选项(:auto_shutdown
选项):
:never
- 默认值,禁用自动关闭。:any_significant
- 任意”重要“进程退出,监督者都会关闭其子进程和自己。:all_significant
- 当所有”重要“进程退出时,监督者才会关闭其子进程和自己。
只有 :transient
和 :temporary
子进程可以被标记为”重要”,配置才会生效。标记为”重要“的 :transient
子进程必须正常退出,自动关闭才会触发,而 :temporary
子进程则可以以任何原因退出。
名称注册
监督者受到与 GenServer
相同的名称注册规则的约束。
基于模块的监督者
在前面的例子中,监督者都是通过 start_link/2
来启动的。但是,监督者也可以通过显式定义一个监督模块来创建:
defmodule MyApp.Supervisor do
# Automatically defines child_spec/1
use Supervisor
def start_link(init_arg) do
Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
@impl true
def init(_init_arg) do
children = [
{Counter, 0}
]
Supervisor.init(children, strategy: :one_for_one)
end
end
这两种方法的区别在于,基于模块的监督者让你可以更直接地控制监督者的初始化。不同于调用 Supervisor.start_link/2
的隐式初始化,我们必须在 init/1
回调中调用 Supervisor.init/2
来显式初始化子进程。 Supervisor.init/2
接受与 start_link/2
相同的 :strategy
、 :max_restarts
和 :max_seconds
选项。
当你使用
use Supervisor
时,Supervisor
模块会设置@behaviour Supervisor
并定义一个child_spec/1
函数,因此你的模块也可以用作监督树的子进程。
use Supervisor
也定义了一个 child_spec/1
函数,让 MyApp.Supervisor
可以作为另一个监督者或者顶级监督树的子进程:
children = [
MyApp.Supervisor
]
Supervisor.start_link(children, strategy: :one_for_one)
一般我们只在顶级监督树中使用没有回调模块的监督者,顶级监督者通常在 Application.start/2
回调中。我们建议在应用中的任何其他地方使用基于模块的监督者,以便它们可以作为监督树中的另一个监督者的子进程运行。由 Supervisor
自动生成的 child_spec/1
可以通过以下选项自定义:
:id
- 子进程描述标识,默认为当前模块名。:restart
- 监督者是否应该重启,默认是:permanent
。
在 use Supervisor
前面的 @doc
注释会添加到生成的 child_spec/1
函数上。
启动和关闭
当监督者启动时,它会遍历所有子进程描述,然后按顺序启动每个子进程。子进程通过调用子进程描述中 :start
键指定的函数启动,默认是 start_link/1
。
然后每个子进程的 start_link/1
(或自定义函数)被调用。 start_link/1
函数必须返回 {:ok, pid}
,其中 pid
是新进程的进程标识符,它会与监督者链接。子进程通常通过执行 init/1
回调来开始它的工作。一般来说, init
回调是我们初始化和配置子进程的地方。
关闭过程与启动顺序相反。
当监督者关闭时,它会以子进程书写的相反顺序终止所有子进程。关闭就是通过 Process.exit(child_pid, :shutdown)
向子进程发送退出信号,并等待一段时间,等子进程退出。默认等待时间为 5000 毫秒。如果子进程在此期间没有停止,监督者将用 :kill
立即终止子进程。等待关闭时间可以在子进程描述中配置,详见下一章节。
如果子进程没有捕获退出信号,它将在接收到第一个退出信号时立即关闭。如果子进程捕获了退出信号,那么 terminate
回调将会被调用,子进程必须在合理的时间内停止,否则它将被监督者立即终止。
换言之,如果一个进程在应用或监督树关闭时需要自我清理,那么这个进程必须捕获退出信号,并在它的子进程描述中指定合适的 :shutdown
值,确保它在合理的时间内终止。
退出原因和重启
监督者根据子进程的 :restart
配置来重启子进程。例如,当 :restart
设置为 :transient
时,如果子进程以 :normal
、 :shutdown
或 {:shutdown, term}
原因退出,则不会被重启。
这些退出原因也影响日志记录。默认情况下,像 GenServers
这样的行为在退出原因是 :normal
、 :shutdown
或 {:shutdown, term}
时不会发送错误日志。
那么,人们可能会问:我应该选择哪种退出原因呢?有三种选项:
:normal
- 这种情况下,退出不会被记录,:restart
为:transient
模式的话不会被重启,链接的进程也不会退出。:shutdown
或{:shutdown, term}
- 这种情况退出也不会被记录,:restart
为:transient
模式也不会被重启,链接的进程会以相同的原因退出,除非它们捕获了退出信号。- 其他任意值 - 在这种情况下,退出会被记录,
:restart
为:transient
模式会被重启,链接的进程会以相同的原因退出,除非它们捕获了退出信号。
一般来说,如果你是因为预期的原因退出,你可以使用 :shutdown
或 {:shutdown, term}
。
注意,达到最大重启次数的监督者将以 :shutdown
原因退出。在这种情况下,监督者只有在它的子进程描述的 :restart
选项被设置为 :permnent
(默认值)时才会被重启。