现在,我们已经成功定义了我们的监督器,它将作为应用程序生命周期的一部分自动启动(和停止)。
但请记住,我们的 KV.Registry 在 handle_cast/2 回调中同时链接(通过 start_link)和监控(通过 monitor)存储容器进程:
链接是双向的,这意味着存储容器崩溃会导致注册表崩溃。虽然我们现在有了监督进程,可以保证注册表恢复正常运行,但注册表崩溃仍然意味着我们会丢失将存储容器名称与其各自进程相关联的所有数据。
换句话说,我们希望即使存储容器崩溃,注册表也能继续运行。让我们编写一个新的注册表测试:
该测试类似于“退出时删除存储容器”,只是我们发送 :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)
我们将通过定义一个新的监管者来解决这个问题,该监管者将生成并监督所有存储容器。与我们之前定义的监管者相反,子节点并不是预先知道的,而是动态启动的。对于这些情况,我们使用一个针对此类用例优化的监管者,称为 DynamicSupervisor。DynamicSupervisor 在初始化期间不需要子节点列表;而是通过 DynamicSupervisor.start_child/2 手动启动每个子节点。
存储容器监管者
由于 DynamicSupervisor 在初始化期间不定义任何子项,因此 DynamicSupervisor 还允许我们跳过使用通常的 start_link 函数和 init 回调定义整个单独模块的工作。相反,我们可以直接在监控树中定义 DynamicSupervisor,为其指定名称和策略。
打开 lib/kv/supervisor.ex 并将动态监控器添加为子项,如下所示:
请记住,进程的名称可以是任何原子。到目前为止,我们已经将进程命名为与定义其实现的模块相同的名称。例如,由 KV.Registry 定义的进程被赋予了 KV.Registry 的进程名称。这只是一个惯例:如果稍后您的系统中出现错误,提示“名为 KV.Registry 的进程因原因而崩溃”,我们确切地知道要调查的位置。
在这种情况下,没有模块,所以我们选择了 KV.BucketSupervisor 这个名字。它可能是任何其他名字。我们还选择了 :one_for_one 策略,这是目前动态监管者唯一可用的策略。
运行 iex -S mix,这样我们就可以尝试我们的动态监管者:
DynamicSupervisor.start_child/2 需要监管者的名称和要启动的子项的子规范。
最后一步是更改注册表以使用动态监管者:
这足以让我们的测试通过,但是我们的应用程序中存在资源泄漏。当一个 bucket 终止时,supervisor 将在其位置启动一个新的 bucket。毕竟,这是 supervisor 的角色!
但是,当 supervisor 重新启动新的 bucket 时,注册表并不知道这一点。因此,supervisor 中将有一个空的 bucket,没有人可以访问!为了解决这个问题,我们想说 bucket 实际上是临时的。如果它们崩溃,无论出于何种原因,都不应重新启动它们。
我们可以通过在 KV.Bucket 中传递 restart: :temporary 选项来使用 Agent 来实现这一点:
我们还将一个测试添加到 test/kv/bucket_test.exs,以确保 bucket 是临时的:
我们的测试使用 Supervisor.child_spec/2 函数从模块中检索子规范,然后断言其重新启动值为 :temporary。此时,您可能想知道如果 supervisor 永远不会重新启动其子节点,为什么要使用它。有时,监督器提供的不仅仅是重启,它们还负责保证正确启动和关闭,特别是在监督树崩溃的情况下。
监督树
当我们将 KV.BucketSupervisor 添加为 KV.Supervisor 的子进程时,我们开始拥有监管其他监管者的监管者,形成所谓的“监管树”。
每次向监管者添加新子进程时,评估监管者策略是否正确以及子进程的顺序非常重要。在本例中,我们使用 :one_for_one,并且 KV.Registry 在 KV.BucketSupervisor 之前启动。
立即出现的一个缺陷是排序问题。由于 KV.Registry 调用 KV.BucketSupervisor,因此 KV.BucketSupervisor 必须在 KV.Registry 之前启动。否则,注册表可能会在启动之前尝试访问 bucket 监管者。
第二个缺陷与监管策略有关。如果 KV.Registry 死亡,则将 KV.Bucket 名称与 bucket 进程联系起来的所有信息都会丢失。因此 KV.BucketSupervisor 和所有子进程也必须终止 - 否则我们将拥有孤儿进程。
鉴于这一观察,我们应该考虑转向另一种监管策略。另外两个候选者是 :one_for_all 和 :rest_for_one。使用 :rest_for_one 策略的监管者将杀死并重新启动在崩溃的子进程之后启动的子进程。在这种情况下,我们希望 KV.BucketSupervisor 在 KV.Registry 终止时终止。这将要求将 bucket 监管者置于注册表之后,这违反了我们在上面两段中建立的排序约束。
因此,我们的最后一个选择是全力以赴并选择 :one_for_all 策略:只要其中任何一个子进程死亡,监管者就会杀死并重新启动其所有子进程。对于我们的应用程序来说,这是一种完全合理的方法,因为注册表在没有 bucket 监管者的情况下无法工作,而 bucket 监管者应该在没有注册表的情况下终止。让我们在 KV.Supervisor 中重新实现 init/1 来编码这些属性:
在进入下一章之前,还有两个主题。
测试中的共享状态
到目前为止,我们已为每个测试启动一个注册表,以确保它们是隔离的:
由于我们已将注册表更改为使用 KV.BucketSupervisor,因此我们的测试现在依赖于此共享监管者,即使每个测试都有自己的注册表。问题是:我们应该吗?
视情况而定。只要我们仅依赖此状态的非共享分区,就可以依赖共享状态。尽管多个注册表可能会在共享存储容器监管者上启动存储容器,但这些存储容器和注册表彼此隔离。如果我们使用 DynamicSupervisor.count_children(KV.BucketSupervisor) 之类的函数来计算所有注册表中的所有存储容器,我们只会遇到并发问题,当测试同时运行时可能会产生不同的结果。
由于到目前为止我们只依赖存储容器监管者的非共享分区,因此我们不必担心测试套件中的并发问题。如果出现问题,我们可以为每个测试启动一个监管者,并将其作为参数传递给注册表 start_link 函数。
观察者
现在我们已经定义了监督树,这是介绍 Erlang 附带的 Observer 工具的绝佳机会。使用 iex -S mix 启动您的应用程序并输入以下内容:
缺少依赖项
当使用 iex -S mix 在项目中运行 iex 时,观察者将无法作为依赖项使用。为此,您需要先调用以下函数:
如果上述任何调用失败,则可能发生以下情况:某些包管理器默认安装最小化的 Erlang,不带 WX 绑定以提供 GUI 支持。在某些包管理器中,您可能能够用更完整的包替换无头 Erlang(在 Debian/Ubuntu/Arch 上查找名为 erlang vs erlang-nox 的包)。在其他管理器中,您可能需要安装单独的 erlang-wx(或类似名称)包。
有对话可以在未来的版本中改善这种体验。
应会弹出一个 GUI,其中包含有关我们系统的各种信息,从一般统计数据到负载图表,以及所有正在运行的进程和应用程序的列表。
在“应用程序”选项卡中,您将看到系统中当前正在运行的所有应用程序及其监督树。您可以选择 kv 应用程序以进一步探索它:
不仅如此,当您在终端上创建新存储容器时,您应该会看到 Observer 中显示的监督树中生成的新进程:
我们将让您进一步探索 Observer 提供的功能。请注意,您可以双击监督树中的任何进程以检索有关它的更多信息,也可以右键单击进程以发送“终止信号”,这是一种模拟故障并查看您的监督者是否按预期做出反应的完美方式。
归根结底,像 Observer 这样的工具是您希望始终在监督树内启动进程的原因之一,即使它们是临时的,也可以确保它们始终可访问和可自省。
现在我们的存储容器已正确链接和监督,让我们看看如何加快速度。