Alex Martsinovich

Software Engineer
Home Posts Github LinkedIn

Concurrent Reads, Serialized Writes with GenServer and Registry

Of all the OTP abstractions, GenServer is the most iconic one. As a result, it is usually the first thing newcomers learn to use, and they inevitably run into some common pitfalls. Let's talk about one of them.

GenServers are commonly used whenever there is a need for a stateful process that will store some data and make it available to other processes. As an example, let's imagine we want to fetch currency conversion rates and make them available in our app. A naive approach could look something like this:

Mix.install([:req])

defmodule FXRates do
  use GenServer

  def start_link(arg, name \\ __MODULE__) do
    GenServer.start_link(__MODULE__, arg, name: name)
  end

  def get_rates(name_or_pid \\ __MODULE__) do
    GenServer.call(name_or_pid, :get_rates)
  end

  @impl GenServer
  def init(_init_arg) do
    {:ok, [], {:continue, :update_rates}}
  end

  @impl GenServer
  def handle_continue(:update_rates, _state) do
    %{status: 200, body: rates} = Req.get!(
      "https://api.frankfurter.dev/v1/latest"
    )

    Process.send_after(self(), :refresh_rates, to_timeout(minute: 1))
    {:noreply, rates}
  end

  @impl GenServer
  def handle_info(:refresh_rates, state) do
    {:noreply, state, {:continue, :update_rates}}
  end

  @impl GenServer
  def handle_call(:get_rates, _from, state) do
    {:reply, state, state}
  end
end

defmodule Main do
  def main do
    children = [
      FXRates
    ]

    {:ok, _} = Supervisor.start_link(children, strategy: :one_for_one)
  end
end

For convenience, let's put it into a single genserver.exs and play with it using iex -r genserver.exs. Indeed, we can start the supervisor and call FXRates.get_rates() to get the latest rates:

iex(1)> Main.main()
{:ok, #PID<0.249.0>}
iex(2)> FXRates.get_rates()
%{
  "amount" => 1.0,
  "base" => "EUR",
  ...
}

Now, if you submit this for code review to any experienced engineer, they will immediately point out that using GenServer.call for reading data is not good. GenServer is a natural serialization point and concurrent callers will have to form a line to get their FX rates. Not to mention that reads will block the GenServer and might mess with its refresh timer.

But how do we make reads concurrent? Well, we just need to put rates somewhere where they can be accessed concurrently. ETS will do. But the thing with raw ETS is that you need to manage the table's lifecycle. And it also comes with a certain style of developer experience that Erlang modules have. What if I told you there is a way to use ETS without using ETS?

┌─────────────┐
│┌───────────┐│
││    The    ││
││ GenServer ││
││  is [IN]  ││
││           ││
││ ●         ││
││           ││   ╭──────╮ ╭───────╮ ╭──────╮ ╭───────╮ ╭──────╮
││           ││   │ read │ │ write │ │ read │ │ write │ │ read │
││           ││   ╰──────╯ ╰───────╯ ╰──────╯ ╰───────╯ ╰──────╯
│└───────────┘│
└─────────────┘
The GenServer will see you soon

Meet Registry

Registry is a way to register processes. But that's not all! It also allows those registrered processes to store arbitrary data. So, why don't we register our genserver in Registry and put FXRates in it?

Mix.install([:req])

defmodule FXRates do
  use GenServer

  def start_link(_arg, name \\ __MODULE__) do
    via_tuple = {:via, Registry, {FXRatesRegistry, name, %{rates: []}}}
    GenServer.start_link(__MODULE__, name, name: via_tuple)
  end

  def get_rates(name \\ __MODULE__) do
    [{_pid, %{rates: rates}}] = Registry.lookup(FXRatesRegistry, name)
    rates
  end

  @impl GenServer
  def init(name) do
    {:ok, %{name: name, rates: []}, {:continue, :update_rates}}
  end

  @impl GenServer
  def handle_continue(:update_rates, state) do
    %{status: 200, body: rates} = Req.get!(
      "https://api.frankfurter.dev/v1/latest"
    )

    Registry.update_value(FXRatesRegistry, state.name, fn _current ->
      %{rates: rates}
    end)

    Process.send_after(self(), :refresh_rates, to_timeout(minute: 1))
    {:noreply, %{state | rates: rates}}
  end

  @impl GenServer
  def handle_info(:refresh_rates, state) do
    {:noreply, state, {:continue, :update_rates}}
  end
end

defmodule Main do
  def main do
    children = [
      {Registry, keys: :unique, name: FXRatesRegistry},
      FXRates
    ]

    {:ok, _} = Supervisor.start_link(children, strategy: :one_for_one)
  end
end

There are a couple of changes here. First of all, we use a via tuple to register the GenServer. We also save name in the state: it acts as a Registry key, and we need to know it in order to update the data associated with our server. Apart from that, get_rates is not a GenServer call anymore! Try it in the IEx to confirm it still works.

Ok, but where are our rates stored now? In ETS, of course! Registry uses ETS under the hood, so the Registry.lookup call is just an ETS read. We made our reads concurrent without having to manage ETS table lifecycle ourselves.

Conclusion

Registry is an amazing tool and is a joy to work with. I reach for it every time I want to use a GenServer for serializing writes while keeping data available for concurrent reads. Give it a try!