Deconstructing Elixir's GenServers

Jeff Kreeftmeijer

Jeff Kreeftmeijer on

Deconstructing Elixir's GenServers

Instead of using object instances like in object oriented languages, Elixir uses GenServers (generic servers) to store state in separate processes. GenServers can keep state and run code asynchronously, which is useful for keeping data available without passing it from function to function.

But how does this work? In this episode of Elixir Alchemy, we'll deconstruct Elixir's GenServer module to see how it functions. We'll learn how a GenServer communicates with other processes, and how it keeps track of its state by implementing part of its functionality ourselves.

A key-value store

Before we can dive under the hood of the GenServer module, we'll take it for a spin to build a key-value store that can store and retrieve values by key.

1defmodule KeyValue do
2  # 1
3  def init(state \\ %{}) do
4    {:ok, state}
5  end
7  # 2
8  def handle_cast({:put, key, value}, state) do
9    {:noreply, Map.put(state, key, value)}
10  end
12  # 3
13  def handle_call({:get, key}, _from, state) do
14    {:reply, Map.fetch!(state, key), state}
15  end

This example has three callback functions to initialize the store, put new values in the store, and query values by key.

  1. The init/1 function takes a state or defaults to an empty map. The GenServer API requires the state to be returned in an ok-tuple.

  2. The handle_cast/2 callback is used to cast an asynchronous message to our server, without waiting for a response. This server responds to a cast with a message matching {:put, key, value} and puts a new key in map that holds the state. It returns a :noreply-tuple to indicate the function doesn't return a response, with the updated state.

  3. To make synchronous calls, the handle_call/3 callback is used. This example responds to call with a message matching {:get, key}, which finds the key in the state and returns it in a :reply-tuple along with the state.

To try our key-value store, we use the start/2, cast/3 and call/2 functions on GenServer. Internally, these use the internal callbacks defined in the key-value store.

1iex(1)> {:ok, pid} = GenServer.start(KeyValue, %{})
2{:ok, #PID<0.113.0>}
3iex(2)> GenServer.cast(pid, {:put, :foo, "bar"})
5iex(3)> GenServer.call(pid, {:get, :foo})
6{:ok, "bar"}

We initialize a new store, which returns the pid (Process ID) in an ok-tuple. The pid is used to refer to our store process later when calling cast and call to set and retrieve a value.

Convenience functions

Since having to call to our server through GenServer's functions gets tedious, they're usually abstracted away by adding functions to the server.

1defmodule KeyValue do
2  use GenServer
4  def start do
5    GenServer.start(KeyValue, %{})
6  end
8  def put(server, key, value) do
9    GenServer.cast(server, {:put, key, value})
10  end
12  def get(server, key) do
13    GenServer.call(server, {:get, key})
14  end
16  # Callbacks
18  def init(state) do
19    {:ok, state}
20  end
22  def handle_cast({:put, key, value}, state) do
23    {:noreply, Map.put(state, key, value)}
24  end
26  def handle_call({:get, key}, _from, state) do
27    {:reply, Map.fetch!(state, key), state}
28  end

By adding the start/0, get/2 and put/3 functions, we don't have to worry about the server's internal implementation when calling its API.

1iex(2)> {:ok, pid} = KeyValue.start
2{:ok, #PID<0.113.0>}
3iex(3)> KeyValue.put(pid, :foo, "bar")
5iex(4)> KeyValue.get(pid, :foo)

GenServer internals

To understand how our key-value server works under the hood, we'll dive into the GenServer module. It relies on message passing and recursion for communication and keeping state.

Message passing between processes

As we discussed when we talked about processes in Elixir, processes communicate by sending messages to each other. Most message passing is abstracted away while working in Elixir, but you can send a message to another process by using the send/2 function.

1send(pid, :hello)

The function takes the recipient's pid and a message to send. Usually, the message is an atom or a tuple when multiple arguments need to be passed.

In the other process, the recipient receives the message in its mailbox. It can react to incoming messages using the receive function.

1receive do
2  :hello ->
3    IO.puts "Hello there!"

By using pattern matching, different actions can be taken for different messages. In GenServer, tuples with the :"$gen_call" and :"$gen_cast" keys are used to query and update the internal state, for example.

Message passing is the first ingredient needed to build a server. In a GenServer, messages are passed to a separate spawned process to update and query its state.

State through recursion

Besides accepting messages, the process spawned by a GenServer uses looping to keep track of its initial state.

1def loop(state) do
2  receive do
3    message -> loop(message)
4  end

In this example, a default state is passed to the loop/1 function, which calls the receive function to check for new messages in the mailbox. If there are none, it will block the process and wait for one to come in. When a message is received, the function calls itself with the received message as its new state. The state is updated and the process is back to waiting for messages to come in.

GenServers have a loop method internally, which reads incoming messages and calls the corresponding callback functions. After that, it calls itself again with the updated state.

Implementing our own GenServer

Now that we understand how a GenServer functions, we can partially implement our own version of the GenServer module to understand how message passing and recursive state works together to make our key-value store work.

In Elixir itself, the GenServer module is mostly a wrapper around Erlang's tried and tested gen_server. We'll implement a version in Elixir that is a subset of the original.

1defmodule MyGenServer do
2  # 1
3  def start(module, state) do
4    {:ok, state} = module.init(state)
5    {:ok, spawn(__MODULE__, :loop, [state, module])}
6  end
8  # 2
9  def loop(state, module) do
10    state =
11      receive do
12        {:"$gen_cast", from, message} ->
13          {:noreply, state} = module.handle_cast(message, state)
14          state
16        {:"$gen_call", {pid, reference} = from, message} ->
17          {:reply, reply, state} = module.handle_call(message, from, state)
18          send(pid, {reference, reply})
19          state
20      end
22    loop(state, module)
23  end
25  # ...
  1. The start/2 function starts the server process. It calls the init/1 function on the passed module to allow it to set initial state and then spawns a process that runs the loop/2 function with the state and module variables as its arguments.

  2. The loop/2 function accepts incoming messages.

  • When a :"$gen_cast"-message comes in, the handle_cast/2 function is called on the module passed to the start/2 function (which is KeyValue in our example). It expects a :noreply-tuple to be returned with the new state. The new state is then used to run the loop/2 function again to wait for more messages.
  • :"$gen_call"-messages call the handle_call/3 function. Here, a :reply-tuple is expected, with the reply and the new state. The reply is then sent back to the process that sent the message. Then, like when handing casts, the updated state is used to call the loop/2 function again.

The server part of our custom GenServer is done. We can now start our key-value store with our custom GenServer. Because our custom GenServer's API matches Elixir's, we can use the built-in version to communicate with a key value store started with our custom GenServer.

1iex> {:ok, pid} = MyGenServer.start(KeyValue, %{})
2{:ok, #PID<0.119.0>}
3iex> GenServer.cast(pid, {:put, :foo, "bar"})
5iex> GenServer.call(pid, {:get, :foo})

To understand the cast/2 and call/2 functions, we'll implement those on our GenServer as well.

1defmodule MyGenServer do
2  # ...
4  # 1
5  def cast(pid, message) do
6    send(pid, {:"$gen_cast", {self(), nil}, message})
7    :ok
8  end
10  # 2
11  def call(pid, message) do
12    send(pid, {:"$gen_call", {self(), nil}, message})
14    receive do
15      {_, response} -> response
16    end
17  end
  1. To cast a message to our server, we'll send a message in the cast-format to our server and return an ok-atom.
  2. Since a call requires a response, we'll wait for a message to be sent back using the receive function and return that.
1iex> {:ok, pid} = MyGenServer.start(KeyValue, %{})
2{:ok, #PID<0.119.0>}
3iex> MyGenServer.cast(pid, {:put, :foo, "bar"})
5iex> MyGenServer.call(pid, {:get, :foo})

Now, we can use our implementation to start our key-value store and communicate with it without needing Elixir's GenServer module.

1defmodule MyGenServer do
2  def start(module, state) do
3    {:ok, state} = module.init(state)
4    {:ok, spawn(__MODULE__, :loop, [state, module])}
5  end
7  def loop(state, module) do
8    state =
9      receive do
10        {:"$gen_cast", from, message} ->
11          {:noreply, state} = module.handle_cast(message, state)
12          state
14        {:"$gen_call", {pid, reference} = from, message} ->
15          {:reply, reply, state} = module.handle_call(message, from, state)
16          send(pid, {reference, reply})
17          state
18      end
20    loop(state, module)
21  end
23  def cast(pid, message) do
24    send(pid, {:"$gen_cast", {self(), nil}, message})
25    :ok
26  end
28  def call(pid, message) do
29    send(pid, {:"$gen_call", {self(), nil}, message})
31    receive do
32      {_, response} -> response
33    end
34  end

Using GenServers in Elixir

We can now switch the convenience funtions in our key-value store over to our own GenServer implementation to verify everything still works.

1defmodule KeyValue do
2  def start do
3    MyGenServer.start(KeyValue, %{})
4  end
6  def put(server, key, value) do
7    MyGenServer.cast(server, {:put, key, value})
8  end
10  def get(server, key) do
11    MyGenServer.call(server, {:get, key})
12  end
14  # Callbacks
16  def init(state) do
17    {:ok, state}
18  end
20  def handle_cast({:put, key, value}, state) do
21    {:noreply, Map.put(state, key, value)}
22  end
24  def handle_call({:get, key}, _from, state) do
25    {:reply, Map.fetch!(state, key), state}
26  end
1iex> {:ok, pid} = KeyValue.start
2{:ok, #PID<0.113.0>}
3iex> KeyValue.put(pid, :foo, "bar")
5iex> KeyValue.get(pid, :foo)

This concludes our dive into Elixir's GenServer internals. Our example implements parts of what Elixir's built-in GenServer can do. Although our implementation only covers part of what GenServer and gen_server do in Elixir and Erlang, it gives a peek into how a GenServer works internally.

Note: Our own GenServer implementation functions, but shouldn't be used for anything but educational purposes. Our version doesn't include any of the built-in version's fault tolerance, and can't handle message timeouts, for example. Instead, use Elixir's built-in GenServer. It's based on Erlang's gen_server, which has been used in production apps for decades and comes with everything you need.

Did we clear up some confusion about Elixir's GenServers? We'd love to know what you think of this article, so please don't hesitate to let us know. We'd also love to know if you have any Elixir subjects you'd like to know more about.

Share this article


AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps