5-监督者和应用程序
到目前为止,我们的程序已经实现了注册表(registry)来对成百上千的bucket进程进行监视。
你是不是觉得这个还不错?没有软件是bug-free的,挂掉那是必定会发生滴。
当有东西挂了,我们的第一反应是:“快拯救这些错误”。但是,像在《入门》中学到的那样,
不同于其它多数语言,Elixir不太做“防御性编程”。
相反,我们说“要挂快点挂”,或是“就让它挂”。
如果有bug要让我们的注册表进程挂掉,啥也别怕,因为我们即将实现用监督者来启动新的注册表进程副本。
本章我们将学习监督者(supervisor),还会讲到些有关应用程序的知识。
一个不够,我们要创建两个监督者,用它们监督我们的进程。
5.1-第一个监督者
创建一个监督者跟创建通用服务器差不多。我们将定义一个名为KV.Supervisor
的模块,
使用Supervisor行为。
代码文件lib/kv/supervisor.ex
内容如下:
defmodule KV.Supervisor do
use Supervisor
def start_link do
Supervisor.start_link(__MODULE__, :ok)
end
def init(:ok) do
children = [
worker(KV.Registry, [KV.Registry])
]
supervise(children, strategy: :one_for_one)
end
end
我们的监督者目前只有一个孩子:注册表进程。一个形式如
worker(KV.Registry, [KV.Registry])
的worker,在调用:
KV.Registry.start_link(KV.Registry)
时将启动一个进程。
我们传给start_link
的参数是进程的名称。给监督机制下得进程命名是常见的做法,
这样别的进程就可以通过名称访问它们,而不需要知道它们的进程ID。
这很有用,因为当被监督的某进程挂掉被重启后,它的进程ID可能会改变。但是用名称就不一样了。
我们可以保证一个挂掉新启的进程,还会用同样的名称注册进来。而不用显式地先获取之前的进程ID。
另外,通常会用定义的模块名称作为进程的名字,在将来对系统进行debug时非常直观。
最后,我们调用了supervisor/2
,给它传递了一个孩子列表以及策略::one_for_one
。
监督者的策略指明了当一个孩子进程挂了会发生什么。:one_for_one
意思是如果一个孩子进程挂了,
只有一个“复制品”会启动来替代它。我们现在要的就是这个策略,
因为我们只有一个孩子。Supervisor
支持许多不同的策略,我们在本章中将会陆续讨论。
因为KV.Registry.start_link/1
现在期待一个参数,需要修改我们的实现来接受这一个参数。
打开文件lib/kv/registry.ex
,覆盖原来的start_link/0
定义:
@doc """
Starts the registry with the given `name`.
"""
def start_link(name) do
GenServer.start_link(__MODULE__, :ok, name: name)
end
我们还要修改测试,在注册表进程启动时给个名字。在文件test/kv/registry_test.exs
中覆盖
原setup
函数代码:
setup context do
{:ok, registry} = KV.Registry.start_link(context.test)
{:ok, registry: registry}
end
类似test/3
,函数setup/2
也接受测试上下文(context)。不管我们给setup代码中添加了啥,
上下文中包含着几个关键变量:比如:case
,:test
,:file
和:line
。
上面代码中,我们用了context.test
作为捷径取得当前运行着的测试名称,生成一个注册表进程。
现在,随着测试通过,可以拉我们的监督者出去溜溜了。如果在工程中启动命令行对话iex -S mix
,
我们可以手动启动监督者:
iex> KV.Supervisor.start_link
{:ok, #PID<0.66.0>}
iex> KV.Registry.create(KV.Registry, "shopping")
:ok
iex> KV.Registry.lookup(KV.Registry, "shopping")
{:ok, #PID<0.70.0>}
当我们启动监督者,注册表worker会自动启动,允许我们创建bucket而不需要手动启动它们。
但是,在实战中我们很少手动启动应用程序的监督者。启动监督者是应用程序回调过程的一部分。
5.2-理解应用程序
起始我们已经把所有时间都花在这个应用程序上了。每次修改了一个文件,执行mix compile
,
我们都能看到Generated kv app
消息在编译信息中打印出来。
我们可以在_build/dev/lib/kv/ebin/kv.app
找到.app
文件。来看一下它的内容:
{application,kv,
[{registered,[]},
{description,"kv"},
{applications,[kernel,stdlib,elixir,logger]},
{vsn,"0.0.1"},
{modules,['Elixir.KV','Elixir.KV.Bucket',
'Elixir.KV.Registry','Elixir.KV.Supervisor']}]}.
该文件包含Erlang的语句(使用Erlang的语法写的)。但即使我们不熟悉Erlang,
也能很容易地猜到这个文件保存的是我们应用程序的定义。
它包括应用程序的版本,定义的所有模块,还有它依赖的应用程序列表,
如Erlang的Kernel,elixir本身,logger(我们在mix.exs
里添加的)。
要是每次我们添加一个新的模块就要手动修改这个文件,是很讨厌的。
这也是为啥把它交给mix来自动维护的原因。
我们还可以通过修改mix.exs
工程文件中,函数application/0
的返回值,
来配置生成的.app
文件。我们将很快做第一次自定义配置。
5.2.1-启动应用程序
定义了.app
文件(里面是应用程序的定义),我们就可以将应用程序视作一个整体形式来启动和停止。
到目前为止我们还没有考虑过这个问题,这是因为:
- Mix为我们自动启动了应用程序
- 即使Mix没有自动启动我们的程序,该程序启动后也没做啥特别的事儿
总之,让我们看看Mix如何为我们启动应用程序。先在工程下启动命令行,然后试着执行:
iex> Application.start(:kv)
{:error, {:already_started, :kv}}
擦,已经启动了?Mix通常会启动文件mix.exs
中定义的整个应用程序结构。
遇到依赖的程序也会如此一并启动。
我们可以给mix一个选项,让它不要启动我们的应用程序。
执行命令:iex -S mix run --no-start
启动命令行,然后执行:
iex> Application.start(:kv)
:ok
我们可以停止:kv
程序和:logger
程序,后者是Elixir默认情况下自动启动的:
iex> Application.stop(:kv)
:ok
iex> Application.stop(:logger)
:ok
然后再次启动我们的程序:
iex> Application.start(:kv)
{:error, {:not_started, :logger}}
错误是由于:kv
所依赖的应用程序(这里是:logger
)没有启动导致的。
Mix一般会根据工程中的mix.exs
启动整个应用程序结构;
对其依赖的每个应用程序来说也是这样(如果它们还依赖于其它应用程序)。
但是这次我们用了--no-start
标志,因此我们需要手动 按顺序 启动所有应用程序,
或者像这样调用Application.ensure_all_started
:
iex> Application.ensure_all_started(:kv)
{:ok, [:logger, :kv]}
没什么激动人心的,这些只是演示了如何控制我们的应用程序。
当你运行
iex -S mix
,它相当于执行iex -S mix run
。
因此无论何时你启动iex会话,传递参数给mix run
,实际上是传递给run
命令。
你可以在命令行中执行mix help run
获取关于run
的更多信息。
5.2.2-应用程序的回调(callback)
因为我们几乎都在讲应用程序如何启动和停止,你能猜到肯定有办法能在启动的当儿做点有意义的事情。
没错,有的!
我们可以定义应用程序的回调函数。在应用程序启动时,该函数将被调用。
这个函数必须返回{:ok, pid}
,其中pid
是其内部监督者进程的标识符。
我们分两步来定义这个回调函数。首先,打开mix.exs
文件,修改def application
部分:
def application do
[applications: [:logger],
mod: {KV, []}]
end
选项:mod
指出了“应用程序回调函数的模块”,后面跟着该传递给它的参数。
这个回调函数的模块可以是任意模块,只要它实现了Application行为。
在这里,我们要让KV
作为它回调函数的模块。因此在文件lib/kv.ex
中做一些修改:
defmodule KV do
use Application
def start(_type, _args) do
KV.Supervisor.start_link
end
end
当我们声明use Application
,(类似声明了GenServer
、Supervisor
)
我们需要定义几个函数。这里我们只需定义start/2
函数。
如果我们想在应用程序停止时定义一个自定义的行为,我们也可以定义一个stop/1
函数。
现在我们再次用iex -S mix
启动我们的工程对话。
我们将看到一个名为KV.Registry
的进程已经在运行:
iex> KV.Registry.create(KV.Registry, "shopping")
:ok
iex> KV.Registry.lookup(KV.Registry, "shopping")
{:ok, #PID<0.88.0>}
好牛逼!
5.2.3-工程还是应用程序?
Mix是区分工程(projects)和应用程序(applications)的。
基于目前的mix.exs
,我们可以说,我们有一个Mix 工程,该工程定义了:kv
应用程序。
在后面章节我们会看到,有些工程一个应用程序也没定义。
当我们讲“工程”时,你应该想到Mix。Mix是管理工程的工具。
它知道如何去编译、测试你的工程,等等。它还知道如何编译和启动你的工程的相关应用程序。
当我们讲“应用程序”时,我们讨论的是OTP。应用程序是一个实体,它作为一个整体启动或者停止。
你可以在应用程序模块文档
阅读更多关于应用程序的知识。或者执行mix help compile.app
来学习def application
中支持的更多选项。
5.3 简单的一对一监督者
我们已经成功定义了我们的监督者,它作为我们应用程序生命周期的一部分自动启动(和停止)。
回顾一下,我们的KV.Registry
在handle_cast/2
回调中,链接并且监视bucket进程:
{:ok, pid} = KV.Bucket.start_link()
ref = Process.monitor(pid)
链接是双向的,意味着一个bucket进程挂了会导致注册表进程挂掉。
尽管现在我们有了监督者,它能保证一旦注册表进程挂了还可以重启。
但是注册表挂掉仍然意味着我们会丢失用来匹配bucket名称到其相应进程的数据。
换句话说,我们希望即使bucket进程挂了,注册表进程也能够保持运行。写成测试就是:
test "removes bucket on crash", %{registry: registry} do
KV.Registry.create(registry, "shopping")
{:ok, bucket} = KV.Registry.lookup(registry, "shopping")
# Stop the bucket with non-normal reason
Process.exit(bucket, :shutdown)
# Wait until the bucket is dead
ref = Process.monitor(bucket)
assert_receive {:DOWN, ^ref, _, _, _}
assert KV.Registry.lookup(registry, "shopping") == :error
end
这个测试很像之前的“退出时移除bucket”,
只是我们的做法更加残暴(用:shutdown
代替了:normal
)。
不像Agent.stop/1
,Process.exit/2
是一个异步的操作。
因此我们不能简单地在刚发了退出信号之后就执行查询KV.Registry.lookup/2
,
那个时候也许bucket进程还没有结束(也就不会造成系统问题)。
为了解决这个问题,我们仍然要在测试期间监视bucket进程,然后在确保其已经结束时再去查询注册表进程,
避免竞争状态。
因为bucket是链接注册表进程的,而注册表进程是链接着测试进程。让bucket挂掉会导致测试进程挂掉:
1) test removes bucket on crash (KV.RegistryTest)
test/kv/registry_test.exs:52
** (EXIT from #PID<0.94.0>) shutdown
一个可行的解决方法是提供KV.Bucket.start/0
,让它执行Agent.start/1
。
在注册表进程中使用这个方法启动bucket,从而避免它们之间的链接。
但是这不是个好办法,因为这样bucket进程就链接不到任何进程。
这意味着所有bucket进程即使在有人停止了:kv
程序也一直活着。
不光如此,它的进程会变得不可触及。而一个不可触及的进程是难以在运行时内省的。
我们将定义一个新的监督者来解决这个问题。这个新监督者会派生和监督所有的bucket。
有一个简单的一对一监督策略,叫做:simple_one_for_one
,对于此情况是非常适用的:
他允许指定一个工人模板,而后监督基于那个模板创建的多个孩子。
在这个策略下,工人进程不会在监督者初始化时启动。而是每次调用了start_child/2
函数后,
才会创建一个新的工人进程。
让我们在文件lib/kv/bucket/supervisor.ex
中定义KV.Bucket.Supervisor
:
defmodule KV.Bucket.Supervisor do
use Supervisor
# A simple module attribute that stores the supervisor name
@name KV.Bucket.Supervisor
def start_link() do
Supervisor.start_link(__MODULE__, :ok, name: @name)
end
def start_bucket do
Supervisor.start_child(@name, [])
end
def init(:ok) do
children = [
worker(KV.Bucket, [], restart: :temporary)
]
supervise(children, strategy: :simple_one_for_one)
end
end
比起我们第一个监督者,这个监督者有三点改变。
相较于之前接受所注册进程的名字作为参数,我们这里只简单地将其命名为KV.Bucket.Supervisor
(代码中用__MODULE__
),因为我们不需要派生这个进程的多个版本。
我们还定义了函数start_bucket/0
来启动每个bucket,
作为这个名为KV.Bucket.Supervisor
的监督者的孩子。
函数start_bucket/0
代替了注册表进程中直接调用的KV.Bucket.start_link
。
最后,在init/1
回调中,我们将工人进程标记为:temporary
。
意思是如果bucket进程即使挂了也不回重启。因为我们创建这个监督者,只是用来作为将bucket进程圈成组这么一种机制。
bucket进程的创建还应该通过注册表进程。
执行iex -S mix
来试用下这个新监督者:
iex> {:ok, _} = KV.Bucket.Supervisor.start_link
{:ok, #PID<0.70.0>}
iex> {:ok, bucket} = KV.Bucket.Supervisor.start_bucket()
{:ok, #PID<0.72.0>}
iex> KV.Bucket.put(bucket, "eggs", 3)
:ok
iex> KV.Bucket.get(bucket, "eggs")
3
修改注册表进程中启动bucket的部分,来与bucket的监督者协同工作:
def handle_cast({:create, name}, {names, refs}) do
if Map.has_key?(names, name) do
{:noreply, {names, refs}}
else
{:ok, pid} = KV.Bucket.Supervisor.start_bucket()
ref = Process.monitor(pid)
refs = Map.put(refs, ref, name)
names = Map.put(names, name, pid)
{:noreply, {names, refs}}
end
end
在做了这些修改之后,我们的测试还是会fail。因为bucket的监督者还没有启动。
但是我们将不会在每次测试启动时启动bucket的监督者,而是让其作为我们主监督者树的一部分自动启动。
5.4-监督树
为了在应用程序中使用bucket的监督者,我们要把它作为一个孩子加到KV.Supervisor
中去。
注意,我们已经开始用一个监督者去监督另一个监督者了—-正式的称呼是“监督树”。
打开lib/kv/supervisor.ex
,添加一个新的模块属性存储bucket监督者的名字,
并且修改init/1
:
def init(:ok) do
children = [
worker(KV.Registry, [KV.Registry]),
supervisor(KV.Bucket.Supervisor, [])
]
supervise(children, strategy: :one_for_one)
end
这里我们添加了一个监督者作为孩子(没有传递启动参数)。重新运行测试,测试将可以通过。
记住,声明各个孩子的顺序是很重要的。因为注册表进程依赖于bucket监督者,
所以bucket监督者需要在孩子列表中排得靠前一些。
因为我们已为监督者添加了多个孩子,现在就需要考虑使用:one_for_one
这个策略还是否正确。
一个显现的问题就是注册表进程和bucket监督者之间的关系。
如果注册表进程挂了,bucket监督者也必须挂。
因为一旦注册表进程挂了,所有关联bucket名字和其进程的信息也就丢失了。
此时若bucket的监督者还活着,它掌管的众多bucket将根本访问不到,变成垃圾。
我们可以考虑使用其他的策略,如:one_for_all
或:rest_for_one
。
策略:one_for_all
在任何时候,只要有一个孩子挂,它就会停止并且重启所有孩子进程。
这个貌似符合现在的需求,但是有些简单粗暴。因为如果bucket监督者进程挂了,
是没必要同时挂掉注册表进程的。因为注册表进程本身就监控这每个bucket进程的状态,
它会自己清理不需要的信息(挂掉的bucket)。因此,策略:rest_for_one
是比较合适的。
它会单独重启挂掉的孩子进程,而不影响其它的。因此我们做如下修改:
def init(:ok) do
children = [
worker(KV.Registry, [KV.Registry]),
supervisor(KV.Bucket.Supervisor, [])
]
supervise(children, strategy: :rest_for_one)
end
如果注册表进程挂了,那么它和bucket监督者都会被重启;
而如果只是bucket监督者进程挂了,那么只有它自己被重启。
还有其它几个策略或选项可以传递给worker/2
,supervisor/2
和supervise/2
函数,
所以可别忘记阅读监督者
及监督者.spec的文档。
5.5 观察者(Observer)
现在我们定义好了监督者树,这是介绍观察者工具(Observer tool)的最佳时机。
该工具和Erlang一同推出。使用iex -S mix
启动你的应用程序,输入:
iex> :observer.start
一个GUI窗口将弹出,里面包含了关于我们系统的各种信息:从总体统计信息到负载图表,
还有运行中的所有进程和应用程序。
在“应用程序”Tab页上,可以看到系统中运行的所有应用程序以及它们的监督者树信息。
可以选择kv
查看它的详细信息:
不但如此,如果你再命令行中创建新的bucket:
iex> KV.Registry.create KV.Registry, "shopping"
:ok
你可以在观察者工具中看到从监督者树种派生出了新的进程。
观察者工具就留给读者自行探索。你可以双击进程查看其详细信息,
还可以右击发送停止信号(模拟进程失败的完美方法)等等。
在每天辛苦工作快要结束的时候,一个像观察者这样的工具绝对是你还想着在监督者树里创建几条进程的主要原因之一。
即使创建的都是临时的,你也可以看看整个工程里各个进程还是不是可触及或是可内省的。
5.6 测试里共享的状态
目前为止,我们是在每个测试中启动一个注册表进程,以确保它们是独立的:
setup context do
{:ok, registry} = KV.Registry.start_link(context.test)
{:ok, registry: registry}
end
因为我们已经将注册表进程改成使用KV.Bucket.Supervisor
了,而它是在全局注册的,
因此现在我们的测试依赖于这个共享的、全局的监督者,即使每个测试仍使用自己的注册表进程。
那么问题来了:我们是否应该这么做?
It depends。只要仅依赖于某一状态的非共享部分,那么也还ok啦。比如,每次用一个名字注册进程,
都是注册在一个共享的注册表中。尽管如此,只要确保每个名字用于不同的测试,
比如在创建时使用上下文参数context.test
,就不会再测试间出现并行或者数据依赖的问题。
对我们的bucket监督者来说也是同样的道理。尽管多个注册表进程会在共享的bucket监督者上启动bucket,
但这些bucket和注册表进程之间是相互隔离的。我们唯一会遇到并发问题,是我们想要调用
函数Supervisor.count_children(KV.Bucket.Supervisor)
的时候。
它统计所有注册表进程下的所有bucket。当测试并行执行并调用它的时候,返回的结果可能不一样。
因此,目前由于我们的测试依赖于共享的监督者中的非共享部分,我们不用担心并发问题。
假如它成为问题了,我们可以给每个测试启动一个监督者,并将其作为参数传递给注册表进程的start_link
函数。
至此,我们的应用程序已经被监督者监督着,而且也已测试通过。之后我们要想办法提升一些性能。