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
6
7 # 2
8 def handle_cast({:put, key, value}, state) do
9 {:noreply, Map.put(state, key, value)}
10 end
11
12 # 3
13 def handle_call({:get, key}, _from, state) do
14 {:reply, Map.fetch!(state, key), state}
15 end
16end
This example has three callback functions to initialize the store, put new values in the store, and query values by key.
The
init/1
function takes a state or defaults to an empty map. TheGenServer
API requires the state to be returned in an ok-tuple.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.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"})
4:ok
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
3
4 def start do
5 GenServer.start(KeyValue, %{})
6 end
7
8 def put(server, key, value) do
9 GenServer.cast(server, {:put, key, value})
10 end
11
12 def get(server, key) do
13 GenServer.call(server, {:get, key})
14 end
15
16 # Callbacks
17
18 def init(state) do
19 {:ok, state}
20 end
21
22 def handle_cast({:put, key, value}, state) do
23 {:noreply, Map.put(state, key, value)}
24 end
25
26 def handle_call({:get, key}, _from, state) do
27 {:reply, Map.fetch!(state, key), state}
28 end
29end
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")
4:ok
5iex(4)> KeyValue.get(pid, :foo)
6"bar"
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!"
4end
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
5end
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
7
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
15
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
21
22 loop(state, module)
23 end
24
25 # ...
26
27end
The
start/2
function starts the server process. It calls theinit/1
function on the passed module to allow it to set initial state and then spawns a process that runs theloop/2
function with thestate
andmodule
variables as its arguments.The
loop/2
function accepts incoming messages.
- When a
:"$gen_cast"
-message comes in, thehandle_cast/2
function is called on the module passed to thestart/2
function (which isKeyValue
in our example). It expects a:noreply
-tuple to be returned with the new state. The new state is then used to run theloop/2
function again to wait for more messages. :"$gen_call"
-messages call thehandle_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 theloop/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"})
4:ok
5iex> GenServer.call(pid, {:get, :foo})
6"bar"
To understand the cast/2
and call/2
functions, we'll implement those on our GenServer as well.
1defmodule MyGenServer do
2 # ...
3
4 # 1
5 def cast(pid, message) do
6 send(pid, {:"$gen_cast", {self(), nil}, message})
7 :ok
8 end
9
10 # 2
11 def call(pid, message) do
12 send(pid, {:"$gen_call", {self(), nil}, message})
13
14 receive do
15 {_, response} -> response
16 end
17 end
18end
- To cast a message to our server, we'll send a message in the cast-format to our server and return an ok-atom.
- 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"})
4:ok
5iex> MyGenServer.call(pid, {:get, :foo})
6"bar"
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
6
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
13
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
19
20 loop(state, module)
21 end
22
23 def cast(pid, message) do
24 send(pid, {:"$gen_cast", {self(), nil}, message})
25 :ok
26 end
27
28 def call(pid, message) do
29 send(pid, {:"$gen_call", {self(), nil}, message})
30
31 receive do
32 {_, response} -> response
33 end
34 end
35end
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
5
6 def put(server, key, value) do
7 MyGenServer.cast(server, {:put, key, value})
8 end
9
10 def get(server, key) do
11 MyGenServer.call(server, {:get, key})
12 end
13
14 # Callbacks
15
16 def init(state) do
17 {:ok, state}
18 end
19
20 def handle_cast({:put, key, value}, state) do
21 {:noreply, Map.put(state, key, value)}
22 end
23
24 def handle_call({:get, key}, _from, state) do
25 {:reply, Map.fetch!(state, key), state}
26 end
27end
1iex> {:ok, pid} = KeyValue.start
2{:ok, #PID<0.113.0>}
3iex> KeyValue.put(pid, :foo, "bar")
4:ok
5iex> KeyValue.get(pid, :foo)
6"bar"
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.