Understanding GenServers

Understanding GenServers

·

10 min read

In this post, we'll be demystifying GenServers. We'll discuss its anatomy and then later roll out our own version of GenServer from the ground up.

To fully grasp this article, you must at least know the basics of Elixir/Erlang processes.

GenServer in Action

If you already know how to use GenServers, feel free to skip this section and jump straight to Anatomy of a GenServer.

GenServer is a behavior that facilitates client-server interaction. It abstracts away all the nitty-gritty details when dealing with interprocess communications.

In this example, we'll build an item accumulator. It would be an Elixir process that accepts an item and stores it in a list. This is how it would look like if we were to use the process primitive:

iex(1)> pid = spawn(fn ->
...(1)>     receive do
...(1)>       {sender, acc, item} -> send(sender, [item | acc])
...(1)>     end
...(1)>   end)
#PID<0.229.0>
iex(2)> send pid, {self, [], "apple"}
{#PID<0.169.0>, [], "apple"}
iex(3)> flush
["apple"]

As you may have noticed, we would need to pass a couple of things around. We would also need to make sure that it's a long-living process (we'll discuss more about this later). It's easy to get lost within this code. This is why GenServer behavior exists.

Let's rewrite this using GenServer. To start, we need to define a module that implements the GenServer behaviour.

defmodule ItemAccumulator do
  use GenServer
end

First, it should be able to set an initial state, in our case, an initial accumulator. To do this, we need to implement the init/1 callback.

@impl true
def init(acc) do
  {:ok, acc}
end

Our ItemAccumulator will have two functionalities. We need to have a function that would store the item and another function to return the items. When storing an item, we don't need a response; hence, we can use GenServer.cast/2. On our GenServer module, we then need to handle the cast request using the handle_cast/2 callback.

@impl true
def handle_cast({:add, item}, acc) do
  {:noreply, [item | acc]}
end

On the other hand, when we ask for the list of items in the accumulator, we need to wait for a response from the server. Because of this, we need to use GenServer.call/3. On our GenServer module, we would need to implement the handle_call/3 callback.

Lastly, let's also show the accumulated items before the process exits.

@impl true
def terminate(_reason, acc) do
  IO.puts "Last state: #{inspect(acc)}"
end

This is how our GenServer module should look like:

defmodule ItemAccumulator do
  use GenServer

  @impl true
  def init(acc) do
    {:ok, acc}
  end

  @impl true
  def handle_call(:list_items, from, acc) do
    {:reply, acc, acc}
  end

  @impl true
  def handle_cast({:add, item}, acc) do
    {:noreply, [item | acc]}
  end

  @impl true
  def terminate(_reason, acc) do
    IO.puts "Last state: #{inspect(acc)}"
  end
end

Let us test this out.

iex(1)> GenServer.start_link(ItemAccumulator, [], name: ItemAccumulator)
{:ok, #PID<0.165.0>}

The start_link/3 function starts the GenServer process. Giving it a name would register the process and would make it easier for us to locate and use it.

iex(2)> GenServer.cast(ItemAccumulator, {:add, "Apples"})
:ok
iex(3)> GenServer.cast(ItemAccumulator, {:add, "Oranges"})
:ok
iex(4)> GenServer.call(ItemAccumulator, :list_items)
["Oranges", "Apples"]
iex(5)> GenServer.stop(ItemAccumulator)
Last state: ["Oranges", "Apples"]
:ok

Since we have a working GenServer, we can now stop here or make the interface more developer-friendly. We can do that by abstracting the implementation details away from the client. They don't always need to know the implementation details, in our case, that we're using GenServers.

defmodule ItemAccumulator do
  use GenServer

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

  def add(item) do
    GenServer.cast(__MODULE__, {:add, item})
  end

  def list_items(), do: GenServer.call(__MODULE__, :list_items)

  ...
  # callbacks
end

Let's try it out in the shell.

iex(1)> ItemAccumulator.start_link([])
{:ok, #PID<0.155.0>}
iex(2)> ItemAccumulator.add("Apples")
:ok
iex(3)> ItemAccumulator.add("Oranges")
:ok
iex(4)> ItemAccumulator.list()
["Oranges", "Apples"]

The interface now looks cleaner and just focuses on the functionalities instead of implementation details.

Anatomy of a GenServer

A GenServer is just a regular Elixir/Erlang process that is stuck in a loop. Don't believe me? Here's what the observer shows when you're running a GenServer process.

It's an Erlang process that waits for a message, acts on the message, then loops and waits for a message again.

Take a look at the snippet below.

defmodule HealthCheck do
  def loop() do
    receive do
      {pid, :ping} ->
        send(pid,:pong)
        loop()
      :exit -> 
        Process.exit(self(), :normal)
    end
  end
end

We define a module called HealthCheck that has a function that just responds to a ping message and then loops. Let's use this module to build our first dumb version of our generic server.

iex(1)> pid = spawn(&HealthCheck.loop/0)
#PID<0.155.0>

We now have a running generic server. To confirm that it's indeed alive, verify it using Process.alive?/1.

iex(2)> Process.alive?(pid)
true

Let's do a ping check and see if our server responds.

iex(3)> send(pid, {self(), :ping})
{#PID<0.151.0>, :ping}
iex(4)> Process.info(self(), :messages)
{:messages, [:pong]}

Now confirm if our generic server is still alive.

iex(5)> Process.alive?(pid)
true

We just built a long-running process. This is GenServer under the hood, with some quirks sprinkled on top of it.

Now let's build our own version of a GenServer!

BasicServer

Requirements

In this mini-project, we aim to replicate the following functionalities of a GenServer:

  • init - We should be able to set the initial state.

  • call - We should be able to make synchronous calls to the server and wait for a reply.

  • cast - We should be able to send fire-and-forget calls to the server.

  • stop - We should be able to stop the server.

GenServers have other functionalities like multicast, multicall, etc but we'll only focus on the functionalities enumerated above.

__using__ Macro

The __using__ macro allows us to inject code into another module. We need this so that we can set the behavior for the server module and implement default callbacks.

defmodule BasicServer do
  @callback init(state :: term()) :: {:ok, term()} | {:stop, term()}

  defmacro __using__(_opts) do
    quote do
      @behaviour BasicServer
    end
  end
end

Setting Initial State

Setting the initial state of a GenServer is done via the init/1 callback. A module that is using our BasicServer must have an init/1 callback defined; otherwise, we raise an exception.

...
defmacro __using__(_opts) do
  quote do
    @behaviour BasicServer

    def init(_state) do
      raise "you forgot to implement the init callback!"
    end

    defoverridable init: 1
  end
end
...

Starting the Server

Now that we're enforcing the presence of an init/1 callback, we can proceed with building our first function which is starting the BasicServer.

defmodule BasicServer do
  @callback init(state :: term()) :: {:ok, term()} | {:stop, term()}

  ...

  def start_link(callback_mod, init_state, opts \\ []) do
    pid =
      # links to the parent process
      spawn_link(fn ->
        {:ok, state} = apply(callback_mod, :init, [init_state])

        loop(callback_mod, state)
      end)

    # registers the process
    if opts[:name], do: Process.register(pid, opts[:name])

    {:ok, pid}
  end

  defp loop(callback, state) do
    # this receive-block will do the heavy-lifting later
    receive do
      _ -> loop(callback, state)
    end
  end

  ...
end

Synchronous Calls

In this section, we will be building the call part of GenServers. call is an asynchronous operation. It will not proceed with other actions until it receives a reply or it times out. How are we going to build this? call will send a message to our server process which is in a suspended state and just waiting for a message to arrive. Once our server process receives the message, it will call the callback module's handle_call/3 function, passing in the message and current state. The calling process would then enter a suspended state waiting for a reply.

Let's create the call/2 function.

...
def call(pid, message) do
  # we need to mark the message as a `:call` to pattern-match later
  # in the receive-loop
  send(pid, {:call, self(), message})

  # since `call` is a synchronous operation, we need to wait for
  # a response from the server
  receive do
    {:reply, reply} ->
      reply
  end
end
...

Now, we modify the receive-block inside the looping function to handle the call message and call the appropriate callback function.

...
defp loop(callback, state) do
  receive do
    {:call, caller_pid, message} ->
      # for this example, we'll only handle the
      # `{:reply, reply, state}` response.
      {:reply, reply, new_state} = apply(callback, :handle_call, [message, caller_pid, state])
      send(caller_pid, {:reply, reply})
      loop(callback, new_state)
  end
end
...

Asynchronous Casts

Let's deal with the fire-and-forget requests. This is very similar to the call function, except that we don't need to send a reply back to the calling process.

...
def cast(pid, message) do
  # we don't need the caller_pid anymore
  # since we don't need to send a message back
  send(pid, {:cast, message})
end
...

Handling the cast message is also simpler. We just need to call the callback function.

...
defp loop(callback, state) do
  receive do
    {:call, caller_pid, message} ->
      {:reply, reply, new_state} = apply(callback, :handle_call, [message, caller_pid, state])
      send(caller_pid, {:reply, reply})
      loop(callback, new_state)

    {:cast, message} ->
      {:noreply, new_state} = apply(callback, :handle_cast, [message, state])
      loop(callback, new_state)
  end
end
...

Hey Server, stop!

Finally, the last remaining function, stop. This function would just tell our server to exit. Well, it needs to do some cleanup first before exiting. Let's start by implementing the stop function.

...
def stop(server, reason) do
  send(server, {:stop, reason})

  :ok
end
...

Hmm... why do we need to send a message to the server? Remember, the server is in a suspended state waiting for a message. Sure, we can just kill the server process, but that's not a graceful exit. Once the server receives the exit message, it executes the terminate/2 callback and then escapes the loop; thus, terminating the server process.

...
defp loop(callback, state) do
  receive do
    {:call, caller_pid, message} ->
      {:reply, reply, new_state} = apply(callback, :handle_call, [message, caller_pid, state])
      send(caller_pid, {:reply, reply})
      loop(callback, new_state)

    {:cast, message} ->
      {:noreply, new_state} = apply(callback, :handle_cast, [message, state])
      loop(callback, new_state)

    {:stop, reason} ->
      apply(callback, :terminate, [reason, state])
  end
end
...

Testing Our Basic Server

Here's the full code of our GenServer clone, BasicServer.

defmodule BasicServer do
  @callback init(state :: term()) :: {:ok, term()} | {:stop, term()}
  @callback handle_call(msg :: term(), from :: pid(), state :: term()) ::
              {:reply, reply :: term(), state :: term()}
  @callback handle_cast(msg :: term(), state :: term()) :: {:noreply, state :: term()}
  @callback terminate(reason :: atom(), state :: term()) :: :ok

  defmacro __using__(_opts) do
    quote do
      @behaviour BasicServer

      def init(_state) do
        raise "you forgot to implement the init callback!"
      end

      defoverridable init: 1
    end
  end

  def start_link(callback_mod, init_state, opts \\ []) do
    pid =
      spawn_link(fn ->
        {:ok, state} = apply(callback_mod, :init, [init_state])

        loop(callback_mod, state)
      end)

    if opts[:name], do: Process.register(pid, opts[:name])

    {:ok, pid}
  end

  def call(pid, message) do
    send(pid, {:call, self(), message})

    receive do
      {:reply, reply} ->
        reply
    end
  end

  def cast(server, message) do
    send(server, {:cast, message})

    :ok
  end

  def stop(server, reason) do
    send(server, {:stop, reason})

    :ok
  end

  defp loop(callback, state) do
    receive do
      {:call, caller_pid, message} ->
        {:reply, reply, new_state} = apply(callback, :handle_call, [message, caller_pid, state])
        send(caller_pid, {:reply, reply})
        loop(callback, new_state)

      {:cast, message} ->
        {:noreply, new_state} = apply(callback, :handle_cast, [message, state])
        loop(callback, new_state)

      {:stop, reason} ->
        apply(callback, :terminate, [reason, state])
    end
  end
end

We'll reuse the ItemAcc we wrote before but instead of using GenServer, we use BasicServer.

defmodule ItemAcc do
  use BasicServer

  def start_link(state) do
    BasicServer.start_link(__MODULE__, state, name: __MODULE__)
  end

  def add(item) do
    BasicServer.cast(__MODULE__, {:add, item})
  end

  def list_items(), do: BasicServer.call(__MODULE__, :list_items)

  def stop(), do: BasicServer.stop(__MODULE__, :normal)

  @impl true
  def init(state), do: {:ok, state}

  @impl true
  def handle_call(:list_items, _from, acc) do
    {:reply, acc, acc}
  end

  @impl true
  def handle_cast({:add, item}, acc) do
    {:noreply, [item | acc]}
  end

  @impl true
  def terminate(_reason, state) do
    IO.puts "Terminating... Last state: #{inspect(state)}"
  end
end
iex(1)> ItemAcc.start_link([])
{:ok, #PID<0.155.0>}
iex(2)> ItemAcc.add("Apples")
:ok
iex(3)> ItemAcc.add("Oranges")
:ok
iex(4)> ItemAcc.list_items()
["Oranges", "Apples"]
iex(5)> ItemAcc.stop()
Terminating... Last state: ["Oranges", "Apples"]
:ok

We are now done implementing the core functionalities of a GenServer. I think it's important to highlight that you should not build your own GenServer. Elixir's GenServers are battle-tested already. You most likely don't need to reinvent the wheel. We only did it for educational purposes. If you need client-server communication, then just use the already-built GenServer.

If you enjoyed this insightful post and would like to receive more content like this, I warmly invite you to subscribe to my newsletter. Your support is truly appreciated! See you on the next one!