Learning Elixir's GenServer - Part 2
In Part 1, we introduced GenServers by looking at a simple example that exposed a shared id map (string to integer). We'll expand on that example by having our in-memory id map periodically refreshed. This is the reason that we didn't use an Agent in the first place.
Our original implementation doesn't require much change. First, we'll add a schedule_refresh
function and call it in init
defmodule Goku.IdMap do
use GenServer
def start_link() do
GenServer.start_link(Goku.IdMap, nil, name: "idmap")
end
def init(nil) do
schedule_refresh()
{:ok, load()}
end
defp schedule_refresh() do
# 60 seconds
Process.send_after(self(), :refresh, 1000 * 60)
end
defp load() do
%{"goku" => 1, "gohan" => 2}
end
end
Process.send_after
sends the specified message, :refresh
here, to the specified process. We use self()
to get the current running process id. Remember, init
is invoked by GenServer.start_link
and running in our newly created GenServer process. So, the above is the GenServer sending a message to itself, 60 seconds from now.
Much like we used hande_call
to respond to GenServer.call
requests, we use handle_info
to handle messages sent in the above fasion:
def handle_info(:refresh, _currnet_state) do
schedule_refresh()
new_state = load()
{:noreply, new_state}
end
Our handle_info
matches the argument passed (:refresh
) and receives the current data/state of the GenServer process (which we won't use, here). All we have to do is schedule the next refresh (using the same schedule_refresh
), reload the data and return that as our new state.
That's really all we need to make this work. However, there's an opportunity to improve this and further expose the concurrency model we talked about in the previous part. Specifically, our handle_info
code is being processed by our module's GenServer process. So while this code is executing, calls to get
will block until handle_info
is done and the process can fulfill pending call
's. Remember, get
uses GenServer.call
which is synchronous and blocks until it receives a reply. If load
is slow, you'll see noticeable latency spikes every 60 seconds. What can we do?
What if we spawned a process specifically to load the new state? That would get us half way there, but that process wouldn't be able to update our GenServer's state (nothing can touch the data, for reading or writing, except the GenServer process itself). But is that really a problem? We've already seen that we can easily communicate with a GenServer. So, consider:
def handle_info(:refresh, state) do
schedule_refresh()
spawn fn -> GenServer.cast(@name, {:refreshed, load()}) end
# doesn't wait for the above to finish
# so doesn't block the GenServer process from servicing other requests
{:noreply, state}
end
Our handle_info
still schedules a refresh 60 seconds from now, but instead of loading the new state directly, it spawns a process to do the loading. That new process will pass the newly loaded state back to the GenServer via the GenServer.cast
function, which is the asynchronous equivalent to call
(we could use call
, but since we don't care for a reply, cast
is more appropriate). Also, note that handle_info
now uses the state
parameter as part of the return value. That's because by the time we return from this function we're still using the same old state.
Finally, we have to handle the cast
call. This is done through handle_cast
:
def handle_cast({:refreshed, new_state}, _old_state) do
{:noreply, new_state}
end
Hopefully you guessed that the above function would be this simple. It fits the GenServer patterns we've seen so far. In the end, we've minimized the complexity of the code running in our single process; much like we'd minimize the surface area of a mutex. But, there's zero risk that we've introduced a concurrency bug by making things too granular.
Put differently, we spawn a new process to do the expensive loading, freeing our GenServer process to keep servicing requests. Once that loading is done, we pass the data back to the GenServer. At this point, the GenServer updates its state.
Recapping, there are 4 functions that run in a GenServer process: init
, handle_call
, handle_cast
and handle_info
. init
is the only that's required (to set the initial state), but you'll have at least one of the other ones (else, your GenServer can do nothing) and will often have a mix of them (we're using all three here). You'll also often have multiple version of the handle_*
functions (pattern matching the first argument).
And that's how GenServer's are used and a bit of how they work. At the risk of sounding like a broken record, I think there are two important things to understand. First, at first glance, it can seem more daunting than it is. Second, the strict ownership of data by the GenServer process is different than many other concurrent programming paradigms. It's easier to reason about, and thus harder to screw up, because there are no moving parts (more accurately, moving data) and never any shared access.