Alex MartsinovichSoftware Engineer |
||||
| Home | Posts | Github | ||
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 │ ││ ││ ╰──────╯ ╰───────╯ ╰──────╯ ╰───────╯ ╰──────╯ │└───────────┘│ └─────────────┘
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!