पाठ 14 / 25

ETS, Agents and Shared State

Store shared state efficiently with ETS and simple state with Agents.

Where state lives

Elixir has several places for state. A GenServer serialises access and is ideal for coordinated updates. An Agent is a simplified wrapper around state with Agent.get and Agent.update, handy for small, simple state. ETS (Erlang Term Storage) is an in-memory key-value store built into the VM, with constant-time lookups from any process, without going through a single server process, which makes it ideal for caches, counters and lookup tables read by many processes. Tables are created with :ets.new(name, options), using types :set, :ordered_set, :bag or :duplicate_bag, access :public, :protected (the default; only the owner writes) or :private, and read_concurrency: true for read-heavy tables. An ETS table belongs to the process that created it and is deleted when that process dies, so create tables in a long-lived, supervised process. :ets.update_counter provides atomic increments. For persistent or distributed storage, use a database, :persistent_term for rarely-changing global config, or libraries such as Cachex and Nebulex.

A read-heavy cache in ETS

A GenServer owns the table; readers go straight to ETS.

defmodule Shop.PriceCache do
  use GenServer
  @table :price_cache

  def start_link(_opts), do: GenServer.start_link(__MODULE__, nil, name: __MODULE__)

  # readers do not go through the GenServer: direct, concurrent lookups
  def get(sku) do
    case :ets.lookup(@table, sku) do
      [{^sku, price, expires_at}] ->
        if System.monotonic_time(:second) < expires_at, do: {:ok, price}, else: :miss

      [] ->
        :miss
    end
  end

  def put(sku, price, ttl_seconds \\ 300) do
    expires_at = System.monotonic_time(:second) + ttl_seconds
    :ets.insert(@table, {sku, price, expires_at})
    :ok
  end

  def hit(counter), do: :ets.update_counter(@table, {:stats, counter}, 1, {{:stats, counter}, 0})

  @impl true
  def init(nil) do
    :ets.new(@table, [:named_table, :set, :public, read_concurrency: true])
    {:ok, nil}                                  # the table lives as long as this process
  end
end

# Simple state with an Agent
{:ok, agent} = Agent.start_link(fn -> %{} end)
Agent.update(agent, &Map.put(&1, "pen", 120))
IO.inspect(Agent.get(agent, &Map.get(&1, "pen")))   # 120

A notice board versus a clerk

Asking a GenServer is like queueing to ask a clerk. ETS is a notice board in the corridor: anyone can read it at the same time without waiting, while one person is responsible for keeping it up to date.

त्वरित जाँच: Why is ETS often faster than a GenServer for read-heavy caches?

  • It stores data on disk
  • It compresses data
  • It is written in JavaScript
  • Many processes can read ETS concurrently without sending messages through a single process
Answer

Many processes can read ETS concurrently without sending messages through a single process — ETS lookups avoid the serialisation of a single server process.