简单的一对一管理

本章是Mix和OTP指南的一部分,它取决于本指南的前几章。有关更多信息,请阅读简介指南或查看边栏中的章节索引。

我们现在已经成功定义了自动启动(和停止)作为我们应用程序生命周期一部分的管理程序。

但请记住,我们KV.Registry既在回调中连接(通过start_link)和监视(通过monitor)存储区流程handle_cast/2

{:ok, pid} = KV.Bucket.start_link([])
ref = Process.monitor(pid)

链接是双向的,这意味着桶中的崩溃会导致注册表崩溃。虽然我们现在拥有管理员,可以确保注册表能够备份和运行,但注册表崩溃仍然意味着我们会丢失所有关联存储桶名称与其各自进程的数据。

换句话说,我们希望即使存储桶崩溃,注册表也能继续运行。我们来写一个新的注册表测试:

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
  Agent.stop(bucket, :shutdown)
  assert KV.Registry.lookup(registry, "shopping") == :error
end

该测试类似于“删除出口处的桶”,除了我们通过发送:shutdown作为退出原因而不是更加苛刻:normal。如果一个进程因为不同于一个原因而终止:normal,则所有链接进程都会收到一个EXIT信号,导致链接进程也会终止,除非它们正在陷阱出口。

由于存储桶终止,注册表随即消失,而我们的测试在尝试时失败GenServer.call/3

  1) test removes bucket on crash (KV.RegistryTest)
     test/kv/registry_test.exs:26
     ** (exit) exited in: GenServer.call(#PID<0.148.0>, {:lookup, "shopping"}, 5000)
         ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
     code: assert KV.Registry.lookup(registry, "shopping") == :error
     stacktrace:
       (elixir) lib/gen_server.ex:770: GenServer.call/3
       test/kv/registry_test.exs:33: (test)

我们将通过定义一个新的主管来解决这个问题,该主管将产生并监督所有桶。有一种叫做主管策略,:simple_one_for_one非常适合这种情况:它允许我们指定一个工作者模板,并根据这个模板监督很多孩子。通过这种策略,在管理员初始化期间不会启动任何员工。相反,工作人员通过该Supervisor.start_child/2功能手动启动。

水桶主管

让我们来定义KV.BucketSupervisorlib/kv/bucket_supervisor.ex,如下所示:

defmodule KV.BucketSupervisor do
  use Supervisor

  # A simple module attribute that stores the supervisor name
  @name KV.BucketSupervisor

  def start_link(_opts) do
    Supervisor.start_link(__MODULE__, :ok, name: @name)
  end

  def start_bucket do
    Supervisor.start_child(@name, [])
  end

  def init(:ok) do
    Supervisor.init([KV.Bucket], strategy: :simple_one_for_one)
  end
end

与第一个相比,这位主管有两个变化。

首先,我们决定给主管一个当地的名字KV.BucketSupervisor。虽然我们可以将opts收到的信息传递start_link/1给主管,但为了简单起见,我们选择了对名称进行硬编码。注意这种方法有缺点。例如,您将无法KV.BucketSupervisor在测试期间启动多个实例,因为它们会与名称发生冲突。在这种情况下,我们只允许所有注册管理机构同时使用同一个桶管理员,这不会成为问题,因为一个管理员的简单管理员的孩子不会互相干扰。

我们还定义了一个start_bucket/0函数,它将启动一个桶作为我们的主管命名的孩子KV.BucketSupervisorstart_bucket/0是我们要调用的函数,而不是KV.Bucket.start_link/1直接在注册表中调用。

运行iex -S mix以便我们可以给我们的新主管一个尝试:

iex> {:ok, _} = KV.BucketSupervisor.start_link([])
{:ok, #PID<0.70.0>}
iex> {:ok, bucket} = KV.BucketSupervisor.start_bucket
{:ok, #PID<0.72.0>}
iex> KV.Bucket.put(bucket, "eggs", 3)
:ok
iex> KV.Bucket.get(bucket, "eggs")
3

我们几乎准备好在我们的应用程序中为一位主管使用简单的一个。第一步是更改注册表以调用start_bucket

  def handle_cast({:create, name}, {names, refs}) do
    if Map.has_key?(names, name) do
      {:noreply, {names, refs}}
    else
      {:ok, pid} = KV.BucketSupervisor.start_bucket()
      ref = Process.monitor(pid)
      refs = Map.put(refs, ref, name)
      names = Map.put(names, name, pid)
      {:noreply, {names, refs}}
    end
  end

第二步是确保KV.BucketSupervisor在我们的应用程序启动时启动。我们可以通过打开lib/kv/supervisor.ex并更改init/1为以下内容来完成此操作:

  def init(:ok) do
    children = [
      {KV.Registry, name: KV.Registry},
      KV.BucketSupervisor
    ]

    Supervisor.init(children, strategy: :one_for_one)
  end

这足以让我们的测试通过,但是我们的应用程序存在资源泄漏。当一个桶终止时,主管将在其位置启动一个新桶。毕竟,这是主管的角色!

但是,当主管重新启动新的存储桶时,注册表不知道它。因此,我们将在主管人员中设置一个无人能够访问的空桶。为了解决这个问题,我们想说桶实际上是暂时的。如果他们崩溃,不管其原因,他们不应该重新启动。

我们可以通过将执行此restart: :temporary选项use AgentKV.Bucket

defmodule KV.Bucket do
  use Agent, restart: :temporary

我们还要添加一个测试来test/kv/bucket_test.exs保证这个桶是暂时的:

  test "are temporary workers" do
    assert Supervisor.child_spec(KV.Bucket, []).restart == :temporary
  end

我们的测试使用该Supervisor.child_spec/2函数从模块中检索子规格,然后声明其重启值为:temporary。此时,您可能想知道为什么要使用主管,如果它从不重新启动子项。监管人员提供的不止是重新启动,他们还负责保证正确的启动和关闭,特别是在监督树中发生崩溃的情况下。

监管树

当我们KV.BucketSupervisor作为孩子加入时KV.Supervisor,我们开始有监督者监督其他监督者,形成所谓的“监督树”。

每当你给一位主管添加一个新的孩子时,评估主管策略是否正确以及子进程的顺序非常重要。在这种情况下,我们正在使用:one_for_oneKV.Registry之前开始KV.BucketSupervisor

一个立即出现的缺陷就是订购问题。由于KV.Registry调用KV.BucketSupervisor,那么KV.BucketSupervisor必须在之前启动KV.Registry。否则,可能会发生注册表试图在启动之前到达存储桶监督器的情况。

第二个缺陷与监管战略有关。如果KV.Registry死亡,将KV.Bucket名称链接到存储桶进程的所有信息都将丢失。因此KV.BucketSupervisor,所有的孩子都必须终止 - 否则我们会有孤儿过程。

鉴于这种观察,我们应该考虑转向另一个监督战略。另外两名候选人是:one_for_all:rest_for_one。一名使用该管理器的主管:rest_for_one将杀死并重新启动坠毁的孩子之后启动的子进程。在这种情况下,我们希望KV.BucketSupervisor在终止时KV.Registry终止。这需要将桶管理员放在注册表之后。这违反了我们在上面建立的两段的顺序约束。

因此,我们最后的选择是全力以赴并选择:one_for_all策略:当其中任何一个子进程死亡时,主管将终止并重新启动其所有子进程。对于我们的应用程序来说,这是一个完全合理的方法,因为注册表不能在没有存储桶管理员的情况下工作,并且存储桶管理员应该在没有注册表的情况下终止。让我们重新实现init/1KV.Supervisor编码这些属性:

  def init(:ok) do
    children = [
      KV.BucketSupervisor,
      {KV.Registry, name: KV.Registry}
    ]

    Supervisor.init(children, strategy: :one_for_all)
  end

为了帮助开发人员记住如何与Supervisors及其便捷功能合作,Benjamin Tan Wei Hao创建了一个Supervisor备忘单

在我们转到下一章之前,还有两个主题。

测试中的共享状态

到目前为止,我们已经开始每个测试一个注册中心,以确保它们是孤立的:

setup do
  {:ok, registry} = start_supervised(KV.Registry)
  %{registry: registry}
end

由于我们现在已将注册表更改KV.BucketSupervisor为全球注册,因此我们的测试现在依赖于此共享管理器,即使每个测试都有其自己的注册表。问题是:我们应该吗?

这取决于。只要我们只依赖这个状态的非共享分区,就可以依靠共享状态。虽然多个注册管理机构可能会在共享桶管理员上启动存储桶,但这些存储桶和注册表彼此隔离。如果我们使用一个函数Supervisor.count_children(KV.Bucket.Supervisor)来计算所有注册表中的所有桶,那么我们只会遇到并发问题,当测试同时运行时可能会给出不同的结果。

由于我们迄今为止只依赖bucket supervisor的非共享分区,所以我们不需要担心测试套件中的并发问题。如果它成为问题,我们可以启动每个测试的主管并将其作为参数传递给注册表start_link功能。

观察

现在我们已经定义了我们的监督树,这是介绍Erlang附带的Observer工具的绝佳机会。启动您的应用程序iex -S mix并将其键入以下内容:

iex> :observer.start

应该弹出一个GUI,其中包含有关我们系统的各种信息,从常规统计到加载图表,以及所有正在运行的进程和应用程序的列表。

在“应用程序”选项卡中,您将看到系统中当前在其监控树旁边运行的所有应用程序。您可以选择kv应用程序以进一步探索:

二次

二次

不仅如此,当您在终端上创建新桶时,您应该看到Observer中显示的监督树中产生的新进程:

iex> KV.Registry.create KV.Registry, "shopping"
:ok

我们将留给您以进一步探讨Observer提供的内容。请注意,您可以双击监督树中的任何进程以检索有关该进程的更多信息,也可以右键单击进程以发送“kill signal”,这是模拟失败并查看您的主管是否按预期做出反应的最佳方式。

在这一天结束时,像Observer这样的工具是你希望始终在监督树内启动进程的原因之一,即使它们是临时的,以确保它们始终可以被访问和反思。

现在我们的桶已经正确联系和监督,让我们看看我们如何加快速度。