elixir官方教程Mix與OTP(五) ETS

#ETS緩存

  1. ETS作緩存
  2. 競態條件

每當咱們須要查找一個桶,咱們就要發送一個信息給註冊表.這時咱們的註冊表會被多個進程併發訪問,就會遇到瓶頸.服務器

本章咱們會學習ETS(Erlang條件存儲)以及如何使用它做爲緩存機制.併發

警告!不要貿然地使用ETS作緩存!查看日誌並分析你的應用表現,肯定那一部分是瓶頸,這樣你就能夠知道是否應該使用緩存,以及緩存什麼.本章僅僅是一個如何使用ETS作緩存的例子.異步

#ETS作緩存async

ETS容許咱們存儲任何Elixir條件到一個內存中的表格.操做ETS表格須要經過Erlang的:ets模塊:函數

iex> table = :ets.new(:buckets_registry, [:set, :protected])
8207
iex> :ets.insert(table, {"foo", self})
true
iex> :ets.lookup(table, "foo")
[{"foo", #PID<0.41.0>}]

建立一個ETS表格時,須要兩個參數:表格名以及一些選項.經過這些選項,咱們傳送了表格類型和它的訪問規則.咱們已經選擇了:set類型,它意味着鍵不能夠被複制.咱們也將表格訪問設置成了:protected,意味着只有建立了這個表格的進程能夠寫入它,但全部進程均可以從表中讀取.這些都是默認值,因此咱們將在以後跳過它們.學習

ETS表格能夠被命名,容許咱們經過名稱來訪問:測試

iex> :ets.new(:buckets_registry, [:named_table])
:buckets_registry
iex> :ets.insert(:buckets_registry, {"foo", self})
true
iex> :ets.lookup(:buckets_registry, "foo")
[{"foo", #PID<0.41.0>}]

讓咱們修改KV.Registry來使用ETS表格.因爲咱們的註冊表須要一個名字做爲參數,咱們能夠用相同的名字命名ETS表格.ETS名與進程名存儲在不一樣的位置,因此它們不會衝突.優化

打開lib/kv/registry.ex,讓咱們來改變它的實現.咱們已經爲源代碼的修改添加了註釋:atom

defmodule KV.Registry do
  use GenServer

  ## Client API

  @doc """
  Starts the registry with the given `name`.
  """
  def start_link(name) do
    # 1. 傳送名字給GenServer的init
    GenServer.start_link(__MODULE__, name, name: name)
  end

  @doc """
  Looks up the bucket pid for `name` stored in `server`.

  Returns `{:ok, pid}` if the bucket exists, `:error` otherwise.
  """
  def lookup(server, name) when is_atom(server) do
    # 2. 查找將直接在ETS中運行,不須要訪問服務器
    case :ets.lookup(server, name) do
      [{^name, pid}] -> {:ok, pid}
      [] -> :error
    end
  end

  @doc """
  Ensures there is a bucket associated to the given `name` in `server`.
  """
  def create(server, name) do
    GenServer.cast(server, {:create, name})
  end

  @doc """
  Stops the registry.
  """
  def stop(server) do
    GenServer.stop(server)
  end

  ## Server callbacks

  def init(table) do
    # 3. 咱們已經用ETS表格取代了名稱映射
    names = :ets.new(table, [:named_table, read_concurrency: true])
    refs  = %{}
    {:ok, {names, refs}}
  end

  # 4. 以前用於查找的handle_call回調已經刪除

  def handle_cast({:create, name}, {names, refs}) do
    # 5. 對ETS表格進行讀寫,而非對映射
    case lookup(names, name) do
      {:ok, _pid} ->
        {:noreply, {names, refs}}
      :error ->
        {:ok, pid} = KV.Bucket.Supervisor.start_bucket
        ref = Process.monitor(pid)
        refs = Map.put(refs, ref, name)
        :ets.insert(names, {name, pid})
        {:noreply, {names, refs}}
    end
  end

  def handle_info({:DOWN, ref, :process, _pid, _reason}, {names, refs}) do
    {name, refs} = Map.pop(refs, ref)
    names = Map.delete(names, name)
    {:noreply, {names, refs}}
  end

  def handle_info(_msg, state) do
    {:noreply, state}
  end
end

注意到在修改以前,KV.Reigstry.lookup/2會將請求發送給服務器,但如今會直接從ETS表格中讀取,改表格會被全部進程分享.這就是咱們實現的緩存機制背後的主要原理.

爲了使緩存機制運行,ETS表格須要有讓對其的訪問:protected(默認),這樣全部客戶端均可以從中讀取,而只有KV.Registry進程能寫入.咱們也在建立表格時設置了read_concurrency: true,爲普通的併發讀取操做的腳本作了表格優化.

上訴修改破壞了咱們的測試,由於以前爲全部操做使用的是註冊表進程的pid,而如今註冊表查找要求的是ETS表格名.然而,因爲註冊表進程和ETS表格有着相同的名字,就很好解決這個問題.將test/kv/registry_test.exs中的設置函數修改成:

setup context do
  {:ok, _} = KV.Registry.start_link(context.test)
  {:ok, registry: context.test}
end

咱們修改了setup,仍然有一些測試失敗了.你可能會注意到每次測試的結果不一致.例如,"生成桶"的測試:

test "spawns buckets", %{registry: registry} do
  assert KV.Registry.lookup(registry, "shopping") == :error

  KV.Registry.create(registry, "shopping")
  assert {:ok, bucket} = KV.Registry.lookup(registry, "shopping")

  KV.Bucket.put(bucket, "milk", 1)
  assert KV.Bucket.get(bucket, "milk") == 1
end

可能會在這裏失敗:

{:ok, bucket} = KV.Registry.lookup(registry, "shopping")

爲何這條會失敗,咱們明明已經在上一條代碼中建立了桶?

緣由就是,出於教學目的,咱們犯了兩個錯誤:

\1. 咱們貿然作了優化(經過添加這個緩存層) \2. 咱們使用了cast/2(應該使用call/2)

#競態條件?

使用Elixir開發並不能使你的代碼魔獸競態條件影響.然而,Elixir中默認的不共用任何事物的簡單概念使得咱們更容易發現產生競態條件的緣由.

在發出操做和從ETS表格觀察到改變之間會有延遲,致使了咱們測試的失敗.下面是咱們但願看到的:

\1. 咱們調用KV.Rgistry.create(registry, "shopping") \2. 註冊表建立了桶並更新了緩存表格 \3. 咱們使用KV.Registry.lookup(registry, "shopping")從表格中獲取信息 \4. 返回{:ok, bucket}

然而,因爲KV.Registry.create/2是一個投擲操做,這條命令將會在咱們真正寫入表格以前返回!換句話或,實際發生的是:

\1. 咱們調用KV.Rgistry.create(registry, "shopping") \2. 咱們使用KV.Registry.lookup(ets, "shopping")從表格中獲取信息 \3. 返回:error \4. 註冊表建立了桶並更新了緩存表格

爲了修正這個錯誤,咱們須要使得KV.Registry.create/2變爲異步的,經過使用call/2代替cast/2.這就保證了客戶端只會在對錶格的改動發生事後繼續.讓咱們修改函數,以及它的回調:

def create(server, name) do
  GenServer.call(server, {:create, name})
end

def handle_call({:create, name}, _from, {names, refs}) do
  case lookup(names, name) do
    {:ok, pid} ->
      {:reply, pid, {names, refs}}
    :error ->
      {:ok, pid} = KV.Bucket.Supervisor.start_bucket
      ref = Process.monitor(pid)
      refs = Map.put(refs, ref, name)
      :ets.insert(names, {name, pid})
      {:reply, pid, {names, refs}}
  end
end

咱們簡單地將回調從handle_cast/2改成了handle_call/3,並回覆被建立了的桶的pid.一般來講,Elixir開發者更喜歡使用call/2而不是cast/2,由於它也提供了背壓(你會被擋住直到獲得回覆).在沒必要要時使用cast/2也能夠被看作是貿然的優化.

讓咱們再次運行測試.這一次,咱們會傳送--trace選項:

$ mix test --trace

--trace選項在你的測試死鎖或遇到競態條件時頗有用,由於它會異步運行全部測試(async: true無效)並展現每一個測試的詳細信息.這一次咱們應該會獲得一兩個斷斷續續的失敗:

1) test removes buckets on exit (KV.RegistryTest)
   test/kv/registry_test.exs:19
   Assertion with == failed
   code: KV.Registry.lookup(registry, "shopping") == :error
   lhs:  {:ok, #PID<0.109.0>}
   rhs:  :error
   stacktrace:
     test/kv/registry_test.exs:23

根據錯誤信息,咱們指望桶再也不存在,但它仍在那兒!這個問題與咱們剛纔解決的正相反:以前在命令建立桶與更新表格之間存在延遲,如今是在桶進程死亡與它在表中的記錄被刪除之間存在延遲.

不走運的是這一次咱們不能簡單地將handle_info/2這個負責清潔ETS表格的操做,改變爲一個異步操做.相反咱們須要找到一個方法來保證註冊表已經處理了:DOWN通知的發送,當桶崩潰時.

簡單的方法是發送一個異步請求給註冊表:由於信息會被按順序處理,若是註冊表回覆了一個在Agent.stop調用以後的發送的請求,就意味着:DOWN消息已經被處理了.讓咱們建立一個"bogus"桶,它是一個異步請求,在每一個測試中排在Agent.stop以後:

test "removes buckets on exit", %{registry: registry} do
  KV.Registry.create(registry, "shopping")
  {:ok, bucket} = KV.Registry.lookup(registry, "shopping")
  Agent.stop(bucket)

  # Do a call to ensure the registry processed the DOWN message
  _ = KV.Registry.create(registry, "bogus")
  assert KV.Registry.lookup(registry, "shopping") == :error
end

test "removes bucket on crash", %{registry: registry} do
  KV.Registry.create(registry, "shopping")
  {:ok, bucket} = KV.Registry.lookup(registry, "shopping")

  # Kill the bucket and wait for the notification
  Process.exit(bucket, :shutdown)

  # Wait until the bucket is dead
  ref = Process.monitor(bucket)
  assert_receive {:DOWN, ^ref, _, _, _}

  # Do a call to ensure the registry processed the DOWN message
  _ = KV.Registry.create(registry, "bogus")
  assert KV.Registry.lookup(registry, "shopping") == :error
end

咱們的測試如今能夠(一直)經過了!

該總結一下咱們的優化章節了.咱們使用了ETS做爲緩存機制,一人寫萬人讀.更重要的是,一但數據能夠被同步讀取,就要小心競態條件.

下一章咱們將討論外部和內部的依賴,以及Mix如何幫助咱們管理巨型代碼庫.

相關文章
相關標籤/搜索