Learning Elixir's GenServer - Part 2

23 Mar 2017

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")

  def init(nil) do
    {:ok, load()}

  defp schedule_refresh() do
    # 60 seconds
    Process.send_after(self(), :refresh, 1000 * 60)

  defp load() do
    %{"goku" => 1, "gohan" => 2}

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
  new_state = load()
  {:noreply, new_state}

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
  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}

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}

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.

blog comments powered by Disqus