⌨️ Understanding Elixir’s GenServer APIs

Introduction

In this post, we dive right into learning how processes in Elixir work, what is GenServer, and the patterns that govern each GenServer application.

The content below assumes you have reasonable knowledge around basic Elixir language features.

What is OTP and GenServer

To perform concurrent tasks, the BEAM virtual machine used by Erlang/Elixir uses lightweight and isolated processes.

Using this design affords the following advantages:

  1. Applications can serve one request without blocking another one.
  2. When one process crashes, the others can keep running.

To take advantage of them, most people use OTP (Open Telecommunications Platform) — a general-purpose framework to build concurrent systems.

OTP defines what a structure of application and provides a database alongside useful tools for creating processes, recover from errors, logging, etc.

GenServer is the server behaviour piece provided by OTP.

GenServer API Design

To demonstrate the standard structure of a worker built with GenServer, we’ll be progressively make one that keeps track of a simple shopping list.

Want to see the final code source? Click Me

Here is what a basic structure of a GenServer worker will look like

defmodule GenServerExample.ShoppingList do
	use GenServer
	# Client Functions

	# Server Callbacks

	# Helper Functions
end

Client Functions — you can treat these functions as the “public” access points for this worker. In this post, we will build the following features:add, remove, print

Server Callbacks — these functions represent the “server logic” that will make the stateful calls and keep track of a worker’s given state (if any)

Helper Functions — these series of functions will provide any convenient features to help your client and server functions stay concise.

Initialising the Worker

To start, let’s add an initialiser to our worker:

defmodule GenServerExample.ShoppingList do
	use GenServer
	# Client Functions
	def start_link(opts \\ []) do
		GenServer.start_link(__MODULE__, :ok, opts)
	end
	# Server Callbacks
	def init(:ok) do
		%{:ok, %{}}
	end
	# Helper Functions
end

GenServer.start_link/3 defines a set of behaviours that has to happen when your worker is initialised.

The first argument takes in the module where the init/1 callback is define, second argument passes arguments to the callback init/1, and the last argument provides a set of options to register the process with the Erlang VM. The options are out of the scope of this article, so we will just leave them empty for now.

Because GenServer enforces a lot of structure in the way we interact with the APIs, their callbacks also have expected return values. You can refer to the table below for them:

genserver callbacks

This table is important when it comes to building workers with GenServer. I find myself referring to them all the time. As we progress further down this post, I will explain the utility of each callback inside this table.

Handling Asynchronous Calls with GenServer

Once we are done writing an initialiser for our worker, it’s time to give it some functionality. In this section, we will look at how we can add and remove items from our shopping list.

Our goal is to produce a set of APIs that will look something like this:

iex(2)> GenServerExample.ShoppingList.add(pid, "Eggs")
:ok
iex(3)> GenServerExample.ShoppingList.remove(pid, "Eggs")
:ok

Add the following functions inside the “Client Functions” section:

def add(pid, {:add, name}) do
	GenServer.cast(pid, {:add, name})
end

def remove(pid, {:remove, name}) do
	GenServer.cast(pid, {:remove, name})
end

As you can observe, both functions take in similar arguments — the first one being the parent process’ ID, and the second one being information to be passed to our server callbacks. Let’s define them now.

Under the ShoppingList.init function, add the following functions:

def handle_cast({:add, name}, shopping_list) do
	new_shopping_list = [name | shopping_list] # append item to exisitng list
	{:noreply, new_shopping_list} 
end

def handle_cast({:remove, name}, shopping_list) do
	new_shopping_list = List.delete(shopping_list, name) # delete item from list
	{:noreply, new_shopping_list} 
end

Let’s parse through what’s going on here. The client functions we have added, add/2 and remove/2, call GenServer.cast/2.

GenServer then subsequently pattern matches the value of the second argument of the GenServer.cast/2 function to find the corresponding handle_cast/2 callback.

Within each handle_cast/2 callback, a new state is constructed using our List append/delete function, and returns a tuple that conforms to the expected return values.

handle cast

In this scenario, handle_cast/2 returns a 2 element tuple, re-defining the internal state of our worker using the second element.

Let’s run the worker in our REPL now and see it in action:

> iex -S mix
iex(1)> {:ok, pid} = GenServerExample.ShoppingList.start_link
{:ok, #PID<0.134.0>}
iex(2)> GenServerExample.ShoppingList.add(pid, "Eggs")
:ok
iex(3)> GenServerExample.ShoppingList.remove(pid, "Eggs")
:ok

Great! Now let’s figure out a way to print out our shopping list to see if it actually works. To do this, we need to use synchronous calls.

Handling Synchronous Calls with GenServer

In the last section, we used asynchronous calls to add objects into our ShoppingList worker. In this section we will look at how we can write the synchronous function print/2 so we can see our shopping list.

When deciding whether or not to use a asynchronous or synchronous call, we revolve it around a single principle: if you expect a reply from the server, use a synchronous function, else use an asynchronous one.

Because print will expect some type of response from the server, it will be written as a synchronous function.

Under the remove function, add the following client function to our worker:

def print(pid) do
	GenServer.call(pid, :print)
end

and the following callback function under the init/1 function:

def handle_call(:print, _from, shopping_list) do
	{:reply, shopping_list, shopping_list}
end

As you can see, the patterns remain fairly similar to those used for asynchronous functions. We pattern match the symbol passed as a second argument in our client API print/1, that calls the handle_call(:print, _from, shopping_list callback.

handle call

Referring again to our table above, we use the {:reply, reply, state} tuple to exit out of the callback. Reply represents the value to be returned to the caller of the function, while state is what you will define if modifying the internal state of the worker.

Let’s run the worker in our REPL now and see it in action:

> iex -S mix
iex(1)> {:ok, pid} = GenServerExample.ShoppingList.start_link
{:ok, #PID<0.134.0>}
iex(2)> GenServerExample.ShoppingList.add(pid, "Eggs")
:ok
iex(3)> GenServerExample.ShoppingList.add(pid, "Bread")
:ok
iex(4)> GenServerExample.ShoppingList.print(pid)
["Bread", "Eggs"]
iex(5)> GenServerExample.ShoppingList.remove(pid, "Bread")
:ok
iex(6)> GenServerExample.ShoppingList.print(pid)
["Eggs"]

Voila! You have built your very first basic GenServer application!

Conclusion

In this post, we covered:

  • What is GenServer and OTP
  • Why OTP is designed for developers to strive for a consistent structure in code for GenServer applications.
  • When to use GenServer.call/3 and GenServer.cast/2
  • How OTP takes care of asynchronous and synchronous behaviours automatically; abstracting away the complexity of handling the calls manually.

Want to learn more?