檢視原始碼 監督動態子代程

我們現在已成功定義我們的監督程式,它會在我們的應用程式生命週期中自動啟動(和停止)。

不過,請記住,我們的 KV.Registryhandle_cast/2 回呼中透過 start_link 連結,並透過 monitor 監控 bucket 程序。

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

連結是雙向的,這表示 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
  Agent.stop(bucket, :shutdown)
  assert KV.Registry.lookup(registry, "shopping") == :error
end

這個測試類似於「在結束時移除 bucket」,只不過我們更嚴苛,傳送 :shutdown 作為結束原因,而不是 :normal。如果程序以 :normal 以外的原因終止,所有連結的程序都會收到 EXIT 訊號,導致連結的程序也會終止,除非它正在捕捉結束。

由於 bucket 已終止,註冊表也已停止,當我們嘗試 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)

我們將透過定義一個新的監督程式來解決這個問題,它會產生並監督所有 bucket。與我們先前定義的監督程式相反,子代程並非事先已知,而是動態啟動的。對於這些情況,我們使用針對此類使用案例最佳化的監督程式,稱為 DynamicSupervisorDynamicSupervisor 在初始化期間並不需要子代程清單;相反地,每個子代程都是透過 DynamicSupervisor.start_child/2 手動啟動的。

bucket 監督程式

由於 DynamicSupervisor 在初始化期間未定義任何子項,因此 DynamicSupervisor 也允許我們略過使用一般 start_link 函數和 init 回呼定義一個完全獨立模組的工作。反之,我們可以直接在監督樹狀結構中定義 DynamicSupervisor,方法是給予它一個名稱和一個策略。

開啟 lib/kv/supervisor.ex,並新增動態監督程式為子項,如下所示

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

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

請記住,處理程序的名稱可以是任何原子。到目前為止,我們已使用與定義其實作的模組相同的名稱來命名處理程序。例如,由 KV.Registry 定義的處理程序被給予 KV.Registry 的處理程序名稱。這只是一個慣例:如果稍後您的系統中出現錯誤訊息,指出「名為 KV.Registry 的處理程序因原因而崩潰」,我們便確切知道該在哪裡進行調查。

在這個情況下,沒有模組,因此我們選擇了名稱 KV.BucketSupervisor。它可以是任何其他名稱。我們也選擇了 :one_for_one 策略,這目前是動態監督程式唯一可用的策略。

執行 iex -S mix,以便我們可以試試我們的動態監督程式

iex> {:ok, bucket} = DynamicSupervisor.start_child(KV.BucketSupervisor, KV.Bucket)
{:ok, #PID<0.72.0>}
iex> KV.Bucket.put(bucket, "eggs", 3)
:ok
iex> KV.Bucket.get(bucket, "eggs")
3

DynamicSupervisor.start_child/2 預期監督程式的名稱和要啟動的子項的子項規格。

最後一個步驟是變更註冊表以使用動態監督程式

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

這足以讓我們的測試通過,但我們的應用程式中存在資源外洩。當一個儲存區終止時,監督程式將在它的位置啟動一個新的儲存區。畢竟,這就是監督程式的角色!

然而,當監督程式重新啟動新的儲存區時,註冊表並不知道它。因此,我們將在監督程式中有一個空的儲存區,而沒有人可以存取!為了解決這個問題,我們希望表示儲存區實際上是暫時的。如果它們崩潰,無論原因為何,都不應該重新啟動它們。

我們可以透過在 KV.Bucket 中將 restart: :temporary 選項傳遞給 use Agent 來做到這一點

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_one,且 KV.RegistryKV.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.Registry 終止時 KV.BucketSupervisor 也終止。這需要將儲存區監督者放在註冊表之後,這會違反我們在上面兩段落中建立的順序限制。

因此,我們最後的選項是全力以赴,選擇 :one_for_all 策略:當任何一個子項發生錯誤時,監督者會終止並重新啟動所有子程序。這對我們的應用程式來說是一個完全合理的做法,因為沒有儲存區監督者,註冊表無法運作,而沒有註冊表,儲存區監督者應該終止。讓我們重新實作 KV.Supervisor 中的 init/1 來編碼這些屬性

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

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

在我們進入下一章節之前,還有兩個主題。

測試中的共用狀態

到目前為止,我們已經為每個測試啟動一個註冊表,以確保它們是孤立的

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

由於我們已變更我們的登錄檔以使用 KV.BucketSupervisor,我們的測試現在依賴此共用監督程式,即使每個測試都有自己的登錄檔。問題是:我們應該嗎?

這取決於情況。只要我們僅依賴此狀態的非共用分割區,依賴共用狀態是沒問題的。儘管多個登錄檔可能會在共用儲存區監督程式上啟動儲存區,但這些儲存區和登錄檔彼此隔離。如果我們使用 DynamicSupervisor.count_children(KV.BucketSupervisor) 等函式,則我們只會遇到並行問題,該函式會計算所有登錄檔中的所有儲存區,當測試並行執行時可能會產生不同的結果。

由於到目前為止我們僅依賴儲存區監督程式的非共用分割區,因此我們不必擔心我們的測試套件中的並行問題。如果它曾經變成問題,我們可以為每個測試啟動一個監督程式,並將它作為參數傳遞給登錄檔 start_link 函式。

觀察者

現在我們已經定義了我們的監督樹,這是介紹 Erlang 附帶的觀察者工具的絕佳機會。使用 iex -S mix 啟動您的應用程式並輸入

iex> :observer.start()

缺少相依性

在使用 iex -S mix 在具有 iex 的專案內執行時,observer 將無法作為相依性使用。為此,您需要在之前呼叫下列函式

iex> Mix.ensure_application!(:wx)
iex> Mix.ensure_application!(:runtime_tools)
iex> Mix.ensure_application!(:observer)
iex> :observer.start()

如果上述任何呼叫失敗,以下是可能發生的事情:某些套件管理員預設安裝精簡的 Erlang,而沒有 GUI 支援的 WX 繫結。在某些套件管理員中,您可能可以使用更完整的套件取代無頭 Erlang(在 Debian/Ubuntu/Arch 上尋找名稱為 erlangerlang-nox 的套件)。在其他管理員中,您可能需要安裝單獨的 erlang-wx(或類似名稱)套件。

有討論在未來版本中改善此體驗。

應該會彈出一個 GUI,其中包含有關我們系統的各種資訊,從一般統計資料到負載圖表,以及所有正在執行的程序和應用程式的清單。

在應用程式標籤中,您將看到目前在您的系統中執行的所有應用程式及其監督樹。您可以選擇 kv 應用程式以進一步探索它

Observer GUI screenshot

不僅如此,當你在終端機上建立新的儲存區時,你應該會看到在 Observer 中顯示的監控樹狀結構中產生新的程序

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

我們會讓你進一步探索 Observer 提供的內容。請注意,你可以對監控樹狀結構中的任何程序按兩下以取得更多資訊,也可以對程序按右鍵以傳送「終止訊號」,這是模擬故障並查看你的監控程式是否如預期般反應的完美方式。

最後,像 Observer 這樣的工具是你始終想在監控樹狀結構中啟動程序的原因之一,即使它們是暫時的,以確保它們始終可存取且可內省。

現在我們的儲存區已適當地連結並受到監控,讓我們看看如何加快速度。