百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术文章 > 正文

Elixir实战:9 隔离错误影响 (1) 监督树

myzbx 2025-01-21 20:01 17 浏览

本章涵盖

  • 理解监督树
  • 动态启动工人
  • “让它崩溃”

在第 8 章中,您了解了基于监视器概念的并发系统中错误处理的基本理论。这个想法是有一个进程,其唯一的工作是监督其他进程,并在它们崩溃时重新启动它们。这为您提供了一种处理系统中各种意外错误的方法。无论工作进程中发生了什么错误,您都可以确信监视器会检测到错误并重新启动工作进程。

除了提供基本的错误检测和恢复功能,监督者在隔离错误影响方面也发挥着重要作用。通过将个别工人直接置于监督者之下,可以将错误的影响限制在单个工人身上。这有一个重要的好处:它使您的系统对客户的可用性更高。无论您多么努力地避免,意外错误总会发生。隔离这些错误的影响可以让系统的其他部分在您从错误中恢复时继续运行并提供服务。

例如,本书示例中的待办事项系统中的数据库错误不应阻止缓存的工作。在您尝试从数据库部分出现的问题中恢复时,您应该继续提供现有的缓存数据,从而至少提供部分服务。更进一步,单个数据库工作者中的错误不应影响其他数据库操作。最终,如果您能够将错误的影响限制在系统的一小部分,您的系统就可以始终提供大部分服务。

隔离错误并最小化其负面影响是本章的主题。主要思想是让每个工作者在一个监督者的管理下运行,这使得可以单独重启每个工作者。您将在下一节中看到这一点,在该节中您将开始构建一个细粒度的监督树。

9.1 监督树

在本节中,我们将讨论如何减少错误对整个系统的影响。基本工具是流程、链接和监督者,整体方法相对简单。您必须始终考虑如果由于错误导致某个流程崩溃,系统的其余部分会发生什么,并且当错误的影响过大(当错误影响到太多流程时)时,您应该采取纠正措施。

9.1.1 分离松散依赖的部分

让我们看看错误是如何在待办事项系统中传播的。过程之间的链接如图 9.1 所示。

图 9.1 待办系统中的流程链接

如图所示,整个结构是相连的。无论哪个进程崩溃,退出信号都会传播到其链接的进程。最终,待办缓存进程也会崩溃,这将被 Todo.System 注意到, Todo.System 将会重新启动缓存进程。

这是一种正确的错误处理方法,因为你重启系统,不会留下任何悬挂的进程。但这种恢复方法过于粗糙。无论错误发生在哪里,整个系统都会重启。在数据库错误的情况下,整个待办事项缓存将终止。同样,一个待办事项服务器进程中的错误将导致所有数据库工作进程停止。

这种粗粒度的错误恢复是由于您从其他工作进程中启动工作进程。例如,数据库服务器是从待办缓存中启动的。为了减少错误影响,您需要从主管进程启动各个工作进程。这种方案使主管能够单独监督和重启每个工作进程。

让我们看看如何做到这一点。首先,您将移动数据库服务器,以便它直接从主管启动。这将使您能够将数据库错误与缓存中发生的错误隔离开来。

将数据库服务器置于监督之下是相当简单的。您必须从 Todo.Cache.init/1 中删除对 Todo.Database.start_link 的调用。然后,在调用 Supervisor.start_link/2 时,您必须添加另一个子规范。

清单 9.1 监督数据库服务器 (supervise_database/lib/todo/system.ex)

defmodule Todo.System do
  def start_link do
    Supervisor.start_link(
      [
        Todo.Database,     
        Todo.Cache
      ],
      strategy: :one_for_one
    )
  end
end

包括规格列表中的数据库

还有一个小改动需要进行。就像你对 Todo.Cache 所做的那样,你需要调整 Todo.Database.start_link 以接受一个参数并忽略它。这将使得可以依赖于通过 use GenServer 获得的自动生成的 Todo.Database.child_spec/1 。

清单 9.2 调整 start_link (supervise_database/lib/todo/database.ex)

defmodule Todo.Database do
  ...
 
  def start_link(_) do
    ...
  end
 
  ...
end

这些更改确保缓存和数据库是分开的,如图 9.2 所示。在主管下运行数据库和缓存进程使得可以单独重启每个工作进程。数据库工作进程中的错误将导致整个数据库结构崩溃,但缓存将保持不受影响。这意味着所有从缓存中读取的客户端在数据库部分重启时仍然能够获取结果。

让我们验证一下。进入 supervise_database 文件夹,启动 shell ( iex -S mix )。然后,启动系统:

iex(1)> Todo.System.start_link()
 
Starting database server.
Starting database worker.
Starting database worker.
Starting database worker.
Starting to-do cache.

现在,杀死数据库服务器:

iex(2)> Process.exit(Process.whereis(Todo.Database), :kill)
 
Starting database server.
Starting database worker.
Starting database worker.
Starting database worker.

从输出中可以看出,只有与数据库相关的进程被重启。如果您终止待办事项缓存,情况也是如此。通过将这两个进程放在一个监控者下,您可以将错误的负面影响局限于局部。缓存错误不会对数据库部分产生影响,反之亦然。

回顾第 8 章关于进程隔离的讨论。由于每个部分在单独的进程中实现,数据库服务器和待办事项缓存是隔离的,互不影响。当然,这些进程通过监控器间接链接,但监控器正在捕获退出信号,从而防止进一步传播。这是 one_for_one 监控器的一个特性:它将错误的影响限制在单个工作进程上,并仅对该进程采取纠正措施(重启)。

在这个例子中,主管启动了两个子进程。重要的是要意识到子进程是同步启动的,按照指定的顺序。主管启动一个子进程,等待它完成,然后再开始下一个子进程。当工作者是一个 GenServer 时,只有在当前子进程的 init/1 回调函数完成后,下一个子进程才会启动。

您可能还记得第 7 章提到的 init/1 不应该长时间运行。这正是原因。如果 Todo.Database 需要,比如说,5 分钟才能启动,那么在这段时间内您将无法使用待办缓存。始终确保您的 init/1 函数运行迅速,并在需要更复杂的初始化时使用第 7 章中提到的技术(通过 handle_continue/2 回调进行后初始化继续)。

9.1.2 丰富的过程发现

尽管您现在有了一些基本的错误隔离,但仍有很多需要改进的地方。一个数据库工作者中的错误将导致整个数据库结构崩溃,并终止所有正在运行的数据库操作。理想情况下,您希望将数据库错误限制在单个工作者中。这意味着每个数据库工作者必须受到直接监督。

这种方法有一个问题。回想一下,在当前版本中,数据库服务器启动工作进程并将它们的 PID 保存在其内部列表中。但是,如果一个进程是从监视器启动的,你无法访问工作进程的 PID。这是监视器的一个特性。你不能长时间保留工作进程的 PID,因为该进程可能会被重启,而其后续进程将具有不同的 PID。

因此,您需要一种方法为受监督的进程赋予符号名称,并通过该名称访问每个进程。当一个进程重新启动时,后续进程将以相同的名称注册自己,这将使您能够在多次重启后仍然找到正确的进程。

您可以使用注册名称来实现此目的。问题在于名称只能是原子,而在这种情况下,您需要更复杂的东西,以便能够使用任意术语,例如 {:database_worker, 1} 、 {:database_worker, 2} 等等。您需要的是一个进程注册表,它维护一个键值映射,其中键是名称,值是 PID。进程注册表与标准本地注册的不同之处在于名称可以是任意复杂的。

每次创建一个进程时,它可以以一个名称注册到注册表中。如果一个进程被终止并重新启动,新进程将重新注册自己。拥有一个注册表将为您提供一个固定的点,您可以在此发现进程(它们的 PID)。这个想法在图 9.3 中进行了说明。

首先,工作进程在初始化期间注册自己。稍后,客户端进程将查询注册表以获取所需工作进程的 PID。然后,客户端可以向服务器进程发出请求。

Elixir 的标准库在 Registry 模块中包含了进程注册表的实现。该模块允许您将进程与一个或多个任意复杂的键关联,然后通过执行基于键的查找来找到该进程(其 PID)。

让我们看几个例子。进程注册表本身就是一个进程。您可以通过调用 Registry.start_link/1 来启动它:

iex(1)> Registry.start_link(name: :my_registry, keys: :unique)

单个参数是注册表选项的关键字列表。必需的选项是 :name 和 :keys 。

:name 选项是一个原子,它指定了注册表进程的名称。您将使用此名称与注册表进行交互。

:keys 选项可以是 :unique 或 :duplicate 。在唯一注册表中,名称是唯一的——任何键下只能注册一个进程。这在您想要为进程分配唯一角色时非常有用。例如,在您的系统中,只有一个进程可以与 {:database_worker, 1} 相关联。相比之下,在重复注册表中,多个进程可以具有相同的名称。重复注册表在单个发布者进程需要向动态数量的订阅者进程发送通知的场景中非常有用,这些订阅者进程会随着时间的推移而出现和消失。

一旦您启动了注册表,您可以在某个键下注册一个进程。让我们试试看。您将生成一个模拟的 {:database_worker, 1} 进程,该进程等待消息,然后将其打印到控制台:

iex(2)> spawn(fn ->
          Registry.register(:my_registry, {:database_worker, 1}, nil)     
 
          receive do
            msg -> IO.puts("got message #{inspect(msg)}")
          end
        end)

在注册表中注册该进程

关键部分发生在调用 Registry.register/3 时。在这里,您传递了注册表的名称 ( :my_registry )、生成的进程的期望名称 ( {:database_worker, 1} ) 和一个任意值。 Registry 将存储名称与提供的值和调用进程的 PID 之间的映射。

此时,注册的进程可以被其他进程发现。请注意,在前面的代码片段中,您没有获取数据库工作进程的 PID。这是因为您不需要它。您可以通过调用 Registry.lookup/2 在注册表中查找它:

iex(3)> [{db_worker_pid, _value}] =
          Registry.lookup(
            :my_registry,
            {:database_worker, 1}
          )

Registry.lookup/2 以注册表的名称和键(进程名称)为参数,返回一个 {pid, value} 元组的列表。当注册表是唯一的时,这个列表可以是空的(没有进程在给定的键下注册),或者可以有一个元素。对于重复的注册表,这个列表可以有任意数量的条目。每个元组中的 pid 元素是注册进程的 PID,而 value 是提供给 Registry.register/3 的值。

现在您已经发现了模拟数据库工作者,您可以给它发送一条消息:

iex(4)> send(db_worker_pid, :some_message)
got message :some_message

Registry 的一个非常有用的属性是它链接到所有注册的进程。这使得注册表能够注意到这些进程的终止,并从其内部结构中移除相应的条目。

您可以立即验证这一点。数据库工作者模拟是一次性过程。它接收了一条消息,打印出来,然后停止。尝试再次发现它:

iex(5)> Registry.lookup(:my_registry, {:database_worker, 1})
[]

如您所见,在给定的键下未找到任何条目,因为数据库工作进程已终止。

注意值得一提的是, Registry 是用纯 Elixir 实现的。你可以把 Registry 想象成类似于 GenServer 的东西,它在其状态中保存名称到 PID 的映射。实际上,实施更为复杂,并依赖于 ETS 表特性,你将在第 10 章中了解。ETS 表允许 Registry 非常高效和可扩展。查找和写入速度非常快,在许多情况下,它们不会相互阻塞,这意味着对同一注册表的多个操作可以并行运行。

Registry 具有更多的功能和属性,我们在这里不讨论。您可以查看官方文档 https://hexdocs.pm/elixir/Registry.xhtml 以获取更多详细信息。但有一个非常重要的 OTP 进程特性您需要了解:通过元组。

9.1.3 通过元组

一个通过元组的机制允许您使用任意第三方注册表来注册符合 OTP 的进程,例如 GenServer 和监督者。请记住,您可以在启动 GenServer 时提供 :name 选项:

GenServer.start_link(callback_module, some_arg, name: some_name)

到目前为止,您仅将原子作为 :name 选项传递,这导致启动的进程在本地注册。但是 :name 选项也可以以 {:via, some_module, some_arg} 的形式提供。这样的元组也称为通过元组。

如果您提供一个通过元组作为名称选项, GenServer 将从 some_module 调用一个明确定义的函数来注册该进程。同样,您可以将一个通过元组作为第一个参数传递给 GenServer.cast 和 GenServer.call , GenServer 将使用 some_module 发现 PID。从这个意义上说, some_module 像一个自定义的第三方进程注册表,而通过元组是将这样的注册表与 GenServer 及类似的 OTP 抽象连接起来的方式。

via 元组的第三个元素 some_arg 是传递给 some_module 函数的一段数据。该数据的确切形状由注册模块定义。至少,这段数据必须包含进程应注册和查找的名称。

在 Registry 的情况下,第三个参数应该是一个对, {registry_name, process_key} ,因此整个通过元组的形状为 {:via, Registry, {registry_name, process_key}} 。

让我们看一个例子。我们将重温第 6 章中的老朋友: EchoServer 。这是一个简单的 GenServer ,通过返回请求负载来处理呼叫请求。现在,您将向回声服务器添加注册。当您启动服务器时,您将提供服务器 ID——一个唯一标识服务器的任意术语。当您想向服务器发送请求时,您将传递此 ID,而不是 PID。

这是完整的实现:

defmodule EchoServer do
  use GenServer
 
  def start_link(id) do
    GenServer.start_link(__MODULE__, nil, name: via_tuple(id))  
  end
 
  def init(_), do: {:ok, nil}
 
  def call(id, some_request) do
    GenServer.call(via_tuple(id), some_request)                 
  end
 
  defp via_tuple(id) do
    {:via, Registry, {:my_registry, {__MODULE__, id}}}          
  end
 
  def handle_call(some_request, _, state) do
    {:reply, some_request, state}
  end
end

通过元组注册服务器

通过元组发现服务器

通过元组符合注册表标准

在这里,您在 via_tuple/1 辅助函数中整合了 via 元组的形状。该进程的注册名称将是 {__MODULE__, id} ,或者在这种情况下是 {EchoServer, id} 。

试试看。启动 iex 会话,复制并粘贴模块定义,然后启动 :my_registry :

iex(1)> defmodule EchoServer do ... end
 
iex(2)> Registry.start_link(name: :my_registry, keys: :unique)

现在,您可以启动并与多个回声服务器进行交互,而无需跟踪它们的 PID:

iex(3)> EchoServer.start_link("server one")
iex(4)> EchoServer.start_link("server two")
 
iex(5)> EchoServer.call("server one", :some_request)
:some_request
 
iex(6)> EchoServer.call("server two", :another_request)
:another_request

请注意,这里的 ID 是字符串,并且还要记住,整个注册的密钥实际上是 {EchoServer, some_id} ,这证明您正在使用任意复杂的术语来注册进程并发现它们。

9.1.4 注册数据库工作者

现在您已经学习了 Registry 的基础知识,您可以实现数据库工作者的注册和发现。首先,您需要创建 Todo.ProcessRegistry 模块。

清单 9.3 待办事项进程注册表 (pool_supervision/lib/todo/process_registry.ex)

defmodule Todo.ProcessRegistry do
  def start_link do
    Registry.start_link(keys: :unique, name: __MODULE__)
  end
 
  def via_tuple(key) do
    {:via, Registry, {__MODULE__, key}}
  end
 
  def child_spec(_) do                       
    Supervisor.child_spec(                   
      Registry,                              
      id: __MODULE__,                        
      start: {__MODULE__, :start_link, []}   
    )                                        
  end
end

儿童规格

接口功能很简单。 start_link 函数只是转发到 Registry 模块以启动一个唯一的注册表。 via_tuple/1 函数可以被其他模块使用,例如 Todo.DatabaseWorker ,以创建适当的元组,从而将进程注册到此注册表。

因为注册是一个过程,所以应该进行监督。因此,您在模块中包含了 child_spec/1 。在这里,您使用 Supervisor.child_spec/2 来调整 Registry 模块的默认规范。这个调用本质上表明您将使用 Registry 提供的任何子规范,同时更改 :id 和 :start 字段。通过这样做,您不需要了解 Registry 实现的内部细节,例如注册过程是一个工作者还是一个监督者。

有了这个,你可以立即将注册表置于 Todo.System 监督之下。

清单 9.4 监督注册 (pool_supervision/lib/todo/system.ex)

defmodule Todo.System do
  def start_link do
    Supervisor.start_link(
      [
        Todo.ProcessRegistry,    
        Todo.Database,
        Todo.Cache
      ],
      strategy: :one_for_one
    )
  end
end

启动进程注册表

请记住,进程是同步启动的,按照您指定的顺序。因此,子规范列表中的顺序很重要,并不是随意选择的。子项必须始终在其依赖项之后指定。在这种情况下,您必须首先启动注册表,因为数据库工作者将依赖于它。

在 Todo.ProcessRegistry 到位后,您可以开始调整数据库工作者。相关更改在以下列表中呈现。

列表 9.5 注册工作者 (pool_supervision/lib/todo/database_worker.ex)

defmodule Todo.DatabaseWorker do
  use GenServer
 
  def start_link({db_folder, worker_id}) do
    GenServer.start_link(
      __MODULE__,
      db_folder,
      name: via_tuple(worker_id)                               
    )
  end
 
  def store(worker_id, key, data) do
    GenServer.cast(via_tuple(worker_id), {:store, key, data})  
  end
 
  def get(worker_id, key) do
    GenServer.call(via_tuple(worker_id), {:get, key})          
  end
 
  defp via_tuple(worker_id) do
    Todo.ProcessRegistry.via_tuple({__MODULE__, worker_id})
  end
 
  ...
end

注册

发现

此代码引入了 worker_id 的概念,它是范围 1..pool_size 内的一个整数。 start_link 函数现在将此参数与 db_ folder 一起使用。然而,请注意,该函数将两个参数作为一个单一的 {db_folder, worker_id} 元组。原因再次符合自动生成的 child_spec/1 ,它转发到 start_link/1 。要在主管下管理一个工作者,您现在可以使用 {Todo.DatabaseWorker, {db_folder, worker_id}} 子规范。

当调用 GenServer.start_link 时,您提供 via 元组作为名称选项。元组的确切形状被包装在内部 via_tuple/1 函数中,该函数接受工作者 ID 并返回相应的 via 元组。此函数仅委托给 Todo.ProcessRegistry ,将所需的名称以 {__MODULE__, worker_id} 的形式传递给它。因此,工作者使用键 {Todo.DatabaseWorker, worker_id} 注册。这样的名称消除了与可能在同一注册表中注册的其他类型进程的冲突。

同样,您可以使用 via_tuple/1 辅助工具在调用 GenServer.call 和 GenServer.cast 时发现进程。请注意, store/3 和 get/2 函数现在将工作者 ID 作为第一个参数。这意味着它们的客户端不再需要跟踪 PID。

9.1.5 监督数据库工作人员

现在,您可以创建一个新的监督者来管理工人池。为什么要引入一个单独的监督者?理论上,将工人放在 Todo.System 之下是可以的。但请记住,在上一章中提到,如果重启发生得太频繁,监督者会在某个时刻放弃并终止所有子进程。如果您在同一个监督者下保持太多子进程,您可能会更早达到最大重启强度——在这种情况下,所有进程都会被重启。换句话说,单个进程中的问题可能会轻易波及到系统的大部分。

在这种情况下,我做出了一个任意的决定,将系统的一个独立部分(数据库)置于一个单独的监督者之下。这种方法可能会将重启失败的影响限制在数据库操作上。如果重启一个数据库工作进程失败,监督者将终止,这意味着父监督者将尝试重启整个数据库服务,而不影响系统中的其他进程。

无论哪种方式,这些变化的结果是您不再需要数据库 GenServer 。该服务器的目的是启动一组工作进程并管理工作 ID 到 PID 的映射。随着这些新变化,工作进程由主管启动;映射已经由注册表处理。因此,数据库 GenServer 是多余的。

您可以保留 Todo.Database 模块。它现在将实现数据库工作进程的监督者,并保留与之前相同的接口功能。因此,您完全不需要更改客户端 Todo.Server 模块的代码,并且可以将 Todo.Database 保留在 Todo.System 子项的列表中。

接下来,您将把数据库转换为监督者。

清单 9.6 监督工人 (pool_supervision/lib/todo/database.ex)

defmodule Todo.Database do
  @pool_size 3
  @db_folder "./persist"
 
  def start_link do
    File.mkdir_p!(@db_folder)
 
    children = Enum.map(1..@pool_size, &worker_spec/1)
    Supervisor.start_link(children, strategy: :one_for_one)
  end
 
  defp worker_spec(worker_id) do
    default_worker_spec = {Todo.DatabaseWorker, {@db_folder, worker_id}}
    Supervisor.child_spec(default_worker_spec, id: worker_id)
  end
 
  ...
end

您首先创建一个包含三个子规范的列表,每个子规范描述一个数据库工作者。然后,您将此列表传递给 Supervisor.start_link/2 。

每个工作者的规范是在 worker_spec/1 中创建的。您从数据库工作者的默认规范 {Todo.DatabaseWorker, {@db_folder, worker_id}} 开始。然后,您使用 Supervisor.child_spec/2 设置工作者的唯一 ID。

没有这个,你将会有多个孩子拥有相同的 ID。回想第 8 章,默认的 child_spec/1 通过 use GenServer 生成,提供了 :id 字段中的模块名称。因此,如果你使用那个默认规格并尝试启动两个数据库工作者,它们都会获得相同的 ID Todo.DatabaseWorker 。然后, Supervisor 模块会对此表示抱怨并引发错误。

您还需要实现 Todo.Database.child_spec/1 。您刚刚将数据库转换为一个监督者,因此该模块不再包含 use GenServer ,这意味着 child_spec/1 不会自动生成。代码如下所示。

清单 9.7 数据库操作 (pool_supervision/lib/todo/database.ex)

defmodule Todo.Database do
  ...
 
  def child_spec(_) do
    %{
      id: __MODULE__,
      start: {__MODULE__, :start_link, []},
      type: :supervisor
    }
  end
 
  ...
end

该规范包含字段 :type ,该字段之前未提及。此字段可用于指示已启动进程的类型。有效值为 :supervisor (如果子进程是监督进程)或 :worker (对于任何其他类型的进程)。如果省略此字段,则使用默认值 :worker 。

因此,列表 9.7 中的 child_spec/1 指定 Todo.Database 是一个监督者,并且可以通过调用 Todo.Database.start_link/0 来启动它。

这是一个很好的例子,说明 child_spec/1 如何帮助您将实现细节保留在驱动过程的模块中。您只是将数据库变成了一个监督者,并且您更改了其 start_link 函数的元数(现在它不接受任何参数),但 Todo.System 模块中不需要进行任何更改。

接下来,您需要调整 store/2 和 get/1 函数。

清单 9.8 数据库操作 (pool_supervision/lib/todo/database.ex)

defmodule Todo.Database do
  ...
 
  def store(key, data) do
    key
    |> choose_worker()
    |> Todo.DatabaseWorker.store(key, data)
  end
 
  def get(key) do
    key
    |> choose_worker()
    |> Todo.DatabaseWorker.get(key)
  end
 
  defp choose_worker(key) do
    :erlang.phash2(key, @pool_size) + 1
  end
 
  ...
end

与之前版本的唯一区别在于 choose_worker/1 函数。之前,该函数会向数据库服务器发出调用。现在,它只是在范围 1..@pool_size 内选择工作者 ID。然后,这个 ID 会被传递给 Todo.DatabaseWorker 函数,这些函数将执行注册查找并将请求转发给相应的数据库工作者。

在这一点上,您可以测试系统的工作方式。启动所有内容:

iex(1)> Todo.System.start_link()
 
Starting database server.
Starting database worker.
Starting database worker.
Starting database worker.
Starting to-do cache.

现在,验证您是否可以正确重启单个工作者。为此,您需要获取工作者的 PID。因为您了解系统的内部结构,这可以通过在注册表中查找轻松完成。一旦您获得了 PID,就可以终止该工作者:

iex(2)> [{worker_pid, _}] =
          Registry.lookup(
            Todo.ProcessRegistry,
            {Todo.DatabaseWorker, 2}
          )
 
iex(3)> Process.exit(worker_pid, :kill)
Starting database worker.

工人如预期般重新启动,其余系统未受干扰。

值得重申的是,注册表如何支持系统中关于重启进程的正确行为。当一个工作进程被重启时,新进程会有一个不同的 PID。但由于注册表的存在,客户端代码对此并不关心。您在最后可能的时刻解析 PID,在向数据库工作进程发出请求之前进行注册表查找。因此,在大多数情况下,查找将成功,您将与正确的进程进行通信。

在某些情况下,数据库工作者的发现可能会返回无效值,例如如果数据库工作者在客户端进程找到其 PID 后但在请求发送之前崩溃。在这种情况下,客户端进程拥有一个过时的 PID,因此请求将失败。如果客户端想要找到刚刚崩溃的数据库工作者,也可能会出现类似的问题。重启和注册与客户端并发运行,因此客户端可能无法在注册表中找到工作者 PID。

这两种情况导致相同的结果:客户端进程——在这种情况下是一个待办事项服务器——将崩溃,错误将传播给最终用户。这是系统高度并发特性的结果。故障恢复在监控进程中并发执行,因此系统的某些部分在短时间内可能处于不一致状态。

9.1.6 组织监督树

让我们停下来思考一下你到目前为止所做的事情。过程之间的关系如图 9.4 所示。

这是一个简单的监督树的示例——一个由监督者和工作者组成的嵌套结构。该树描述了系统如何组织成服务的层次结构。在这个例子中,系统由三个服务组成:进程注册、数据库和缓存。

每个服务可以进一步细分为子服务。例如,数据库由多个工作者组成,缓存由多个待办服务器组成。即使注册表也进一步细分为多个进程,但那是 Registry 模块的实现细节,因此在图中未显示。

尽管监督者在容错和错误恢复的上下文中经常被提及,但定义正确的启动顺序是他们最重要的角色。监督树描述了系统是如何启动的以及如何关闭的。

更细粒度的树允许您关闭系统的任意部分,而不影响其他部分。在当前版本中,停止数据库服务就像请求其父节点( Todo.System )停止 Todo.Database 子节点一样简单,使用 Supervisor.terminate_child/2 函数。这将关闭数据库进程及其后代。

如果工作进程是系统中的小服务,您可以将监督者视为服务经理——相当于 systemd、Windows 服务管理器等的内置等价物。它们负责直接管理的服务的生命周期。如果任何关键服务停止,其父进程将尝试重新启动它。

查看监督树,您可以推断错误是如何在系统中处理和传播的。如果数据库工作进程崩溃,数据库监督者将重新启动它,而不影响系统的其他部分。如果这没有帮助,您将超过最大重启频率,数据库监督者将终止所有数据库工作进程,然后自身也会终止。

这将被系统监督者注意到,随后将启动一个新的数据库池,以期解决问题。所有这些重启能给你带来什么?通过重启整个工作组,你实际上终止了所有待处理的数据库操作,并重新开始。如果这没有帮助,你就无能为力了,因此你将错误向上传播(在这种情况下,终止所有内容)。这就是监督树中错误恢复的工作方式——你尝试在本地恢复错误,尽量影响尽可能少的进程。如果这不起作用,你就向上移动,尝试重启系统的更大部分。

OTP 合规流程

所有直接从监督者启动的进程都应符合 OTP 标准。要实现符合 OTP 标准的进程,仅仅生成或链接一个进程是不够的;您还必须以特定方式处理一些特定于 OTP 的消息。具体需要做什么的详细信息可以在 Erlang 文档中找到,网址为 https://www.erlang.org/doc/design_principles/spec_proc.xhtml#special-processes。

幸运的是,您通常不需要从头开始实现一个符合 OTP 的过程。相反,您可以使用各种更高级的抽象,例如 GenServer 、 Supervisor 和 Registry 。使用这些模块启动的过程将符合 OTP 标准。Elixir 还附带了 Task 和 Agent 模块,可以用来运行符合 OTP 标准的过程。您将在下一章中学习任务和代理。

由 spawn_link 启动的普通流程不符合 OTP 标准,因此不应直接从主管启动此类流程。您可以自由地从工人那里启动普通流程,例如 GenServer ,但通常最好在可能的情况下使用符合 OTP 标准的流程。

关闭进程

监督树的一个重要好处是能够在不留下悬挂进程的情况下停止整个系统。当你终止一个监督者时,它的所有直接子进程也会被终止。如果所有其他进程直接或间接地与这些子进程相连,它们最终也会被终止。因此,你可以通过终止顶层监督者进程来停止整个系统。

通常,监督者子树以受控方式终止。监督者进程会指示其子进程优雅地终止,从而给它们机会进行最终清理。如果其中一些子进程本身也是监督者,它们将以相同的方式关闭自己的树。优雅终止一个 GenServer 工作进程涉及调用 terminate/2 回调,但仅在工作进程捕获退出时。因此,如果您想从 GenServer 进程中进行一些清理,请确保从 init/1 回调设置退出捕获。

因为优雅终止涉及可能执行清理代码,因此可能会比预期花费更长的时间。子规范中的 :shutdown 选项让您控制监督者将等待子进程优雅终止的时间。如果子进程在此时间内未终止,它将被强制终止。您可以通过在 child_spec/1 中指定 shutdown: shutdown_strategy 并传递一个表示毫秒的整数来选择关闭时间。或者,您可以传递原子 :infinity ,指示监督者无限期等待子进程终止。最后,您可以传递原子 :brutal_kill ,告诉监督者立即以强制方式终止子进程。强制终止是通过向进程发送 :kill 退出信号来完成的,就像您对 Process.exit(pid, :kill) 所做的那样。 :shutdown 选项的默认值是工作进程的 5_000 或监督进程的 :infinity 。

避免进程重启

默认情况下,监视器会重新启动一个终止的进程,无论退出原因是什么。即使进程因 :normal 原因终止,它也会被重新启动。有时,您可能希望更改这种行为。

例如,考虑一个处理 HTTP 请求或 TCP 连接的进程。如果这样的进程失败,套接字将被关闭,重启该进程没有意义(远程方无论如何会断开连接)。您仍然希望将这样的进程放在监督树下,因为这使得可以终止整个监督子树,而不会留下悬挂的进程。在这种情况下,您可以通过在 child_spec/1 中提供 restart: :temporary 来设置一个临时工作者。临时工作者在终止时不会被重启。

另一个选项是临时工作者,仅在异常终止时重新启动。临时工作者可以用于可能正常终止的进程,作为标准系统工作流的一部分。一个典型的例子是您希望在系统启动时执行的一次性任务。您可以在监督树中启动相应的进程(通常由 Task 模块提供支持),然后将其配置为临时。临时工作者可以通过在 child_spec/1 中提供 restart: :transient 来指定。

重启策略

到目前为止,您只使用了 :one_for_one 重启策略。在此模式下,监视器通过启动一个新进程来处理进程终止,保持其他子进程不变。还有两种额外的重启策略:

  • :one_for_all —当一个孩子崩溃时,监督员终止所有其他孩子,然后重新启动所有孩子。
  • :rest_for_one —当一个子进程崩溃时,监视器终止崩溃子进程的所有较小兄弟进程。然后,监视器在被终止的进程的位置启动新的子进程。

这些策略在兄弟之间紧密耦合时非常有用,在这种情况下,某个子服务没有其兄弟服务就没有意义。一个例子是当一个进程在其自身状态中保持某个兄弟的 PID。在这种情况下,该进程与兄弟的一个实例紧密耦合。如果兄弟终止,依赖的进程也应该终止。

通过选择 :one_for_all 或 :rest_for_one ,您可以实现这一点。前者在所有方向上都有紧密依赖时很有用(每个兄弟姐妹都依赖其他兄弟姐妹)。后者适用于较小的兄弟姐妹依赖于较大的兄弟姐妹的情况。

例如,在待办事项系统中,如果注册进程终止,您可以使用 :rest_for_one 关闭数据库工作进程。没有注册表,这些进程无法发挥任何作用,因此关闭它们是正确的做法。然而,在这种情况下,您不需要这样做,因为 Registry 将每个注册的进程与注册进程链接在一起。因此,注册进程的终止会正确地传播到注册的进程。任何不捕获退出的进程将自动被关闭;捕获退出的进程将收到通知消息。

这结束了我们对细粒度监督的初步了解。您已经进行了几项更改,以最小化错误的影响,但仍有很大的改进空间。在下一部分中,您将继续扩展系统,学习如何动态启动工作者。

相关推荐

斐波那契时钟:据说智商太低的人看不懂,你敢来挑战吗?

如果你在一个公共场合,看到了桌上亮着的东西,你会以为它是什么?小夜灯?恭喜你,回答错误。它是Fibonacci钟,专为追求与众不同的“怪胎们”准备。它外表精美,甚至看不出这是一个钟,但它真的是个钟,只...

曾都区文峰学校二年级数学组开展钟表制作主题活动

为了使学生更加直观地认识钟表、感知钟表,曾都区文峰学校二年级数学组开展了“小钟面大创意”钟表制作主题活动。孩子们大胆设计、精心制作,每一个作品都充满了创意与童趣。 滴答滴答滴答,小小时钟在说话。它都...

不能更酷!游戏机改造的报时钟表(游戏机能改成正常机吗)

当家里的游戏机逐渐过时的时候,你会把它怎么办?无论是卖掉还是封存珍藏都是不错的办法,不过Rurue0111有更酷的主意:将PlayStation改造成时钟。初代PS正脸有一个圆圆的硕大光驱盒盖,别...

计量小知识来了!古代没有钟表,夜晚是如何计算时间的?

古人把一昼夜分为十二个时辰,用十二地支名加上“时”字表示。即子时、丑时、寅时、卯时、辰时、巳时、午时、未时、申时、酉时、戌时、亥时。每一时刻相当于今天的两个小时。这十二时辰与现今计时法的关系是:子时-...

「爱」的挂钟(爱的时钟)

鸳鸯和天鹅算是鸟类中最会秀恩爱的了吧,以它们为元素做设计也应该是件很优雅的事。台湾的好事(haoshi)工作室成立于2009年。设计师坚持纯洁与和平的设计理念,将生活中抽象的好事物以艺术化的形式呈...

亚洲最大欧米茄时钟亮相北京王府井百货 钟面直径约7.9米

亚洲最大欧米茄(OMEGA)时钟正式亮相北京地标性建筑王府井百货(专题阅读)大楼。作为迄今为止亚洲最大的欧米茄时钟,甫一亮相即吸引到众多游客的目光。至此,位于北京最繁华地带的王府井大街将被欧米茄时钟...

初一数学难点“线段与角”,老师手把手教你画图,保证学会

期中考试已过去,学生们的学习进了一个新的阶段,每个科目的老师又开始兢兢业业开始了他们的讲授之路。现以初一数学为例,具体说说现阶段的数学学习什么?如何去高效学习?在现阶段,学生们开始学习了一些简单的几何...

科技感十足的悬浮时钟(悬浮 时钟)

说起来磁悬浮已经不是什么新鲜事儿了,自从有了磁悬浮列车,好像所有的东西都可以跟磁悬浮扯上关系。一个来自瑞典的设计团队FLYTE设计了一款叫做STORY的时钟,利用磁悬浮技术,让时钟的指针飞了起来~▽设...

一上数学 第七单元 认识钟表 逐字稿

(bluehouse456全文整理)同学们大家好,老师,这里准备了一些图片,你知道是什么吗?是钟表。钟表在我们的生活中经常遇到,你知道钟表有什么作用吗?是的,钟表可以帮助人们计时安排一天的工作和学习...

人教版数学一年级上册第七单元《认识钟表》知识要点:

一、认识钟面1.钟面结构钟面上有12个数字,按顺时针方向排列,代表1到12时。分针:又细又长的指针,转动较快,指示分钟。时针:又粗又短的指针,转动较慢,指示小时。指针转动方向为顺时针(从左到右)。2...

浔阳小学一年级数学组举行钟表制作活动

九江新闻网讯(伍巧红)12月23日,浔阳小学一年级数学组组织学生进行制作钟表的活动。孩子们利用双休日与父母共同参与了这项活动。这次钟面设计手工制作活动,属于数学“设计与应用”领域。钟表与学生的生活息息...

时钟指针夹角计算公式(时钟夹角万能公式)

关于时钟指针夹角问题,小学应该有一定的认识,一些特殊情况下的夹角,学生能顺利求出。在初一数学《角》的教学中,我们对夹角问题会有更深入的了解。设定钟面时间为a时b分,此时,时针与分针夹角是多少呢?有没有...

雨城区四小教育集团汉碑校区一年级数学组开展“创意钟面”制作比赛

四川新闻网雅安5月6日讯为丰富学生的课余生活,培养创新思维和动手操作能力,复习巩固新学的《钟面的认识》,使学生进一步掌握“整时、几时半、大约几时”等知识点。近日,雨城区四小教育集团汉碑校区一年级数学...

一大波奇奇怪怪的钟表来袭,你见过几个?

别再问时间都去哪了,其实时间都去了设计师的脑洞里……No.1音乐时钟DINN意大利设计师AlessandroZambelli为钟表品牌Diamantini&Domeniconi设...

瑞士国铁钟表Mondaine:分秒不差的精准,穿越七十年的经典

在瑞士,时间不仅是数字,更是一种艺术。当你踏入瑞士任何一个火车站,目光一定会被站台上那简洁优雅的挂钟吸引——纯白的钟面、清晰的黑色刻度,再配上一根醒目的红色圆头秒针,仿佛在无声地诉说着瑞士人对精准与美...