Getting familiar with how GenServer uses send and receive
No matter how experienced you are in the Erlang/Elixir world, the generic server
(GenServer
) is something you should hold close to your heart. Being the go
to abstraction when it comes to keeping state, enabling concurrency/fault
tolerance/distribution it is for sure going to be a part of your application no
matter the size. So how does it really work beneath the surface? Let’s get into
that!
The process
GenServer
is basically an abstraction that helps working with processes. A
process is a green thread with a mailbox that is keeping track of incoming
messages which it is supposed to process.
You can spawn a process by using spawn/1
or spawn_link/1
. They both take a
function as their single argument, which is executed and shuts down the process
once it is finished. You could try it out like this:
pid = spawn(fn -> 1 + 1 end)
Process.alive?(pid) # => false
Spawning processes to do jobs and shut down is good for concurrency and fault
tolerance (assuming it is being supervised) but it won’t really enable us to
handle state. To enable that processes have a keyword named receive
. The
receive
keyword will look in the mailbox to find a message or block the
process execution until a message is sent to it (by using send/3
) which makes
it start to execute again, as shown below:
pid = spawn(fn ->
receive do
message -> IO.puts("Got message: " <> message)
end
end)
Process.alive?(pid) # => true
sent_message = send(pid, "Hello")
# "Got message: Hello
IO.puts(sent_message)
# Hello
While it is easy to expect that the process will answer with whatever we return in the receive block we can see that it isn’t the case, it’ll only return the message that we sent.
Storing state in a process
Now that we know how we can send and receive messages in a process it’s time to
figure out how to store state. Instead of the anonymous function we used earlier
we will create a small module to hold our logic and instead start the process
with spawn/3
which takes a module, an atom that defines the function to be
used, and a list of arguments.
By defining a function named loop/1
that contains the receive
block we can
make us of tail recursion to “restart” the loop with a new state like shown
below
defmodule Counter do
def start(initial_state) do
loop(initial_state)
end
defp loop(state) do
IO.inspect(state, label: :current_state)
receive do
number -> loop(state + number)
end
end
end
pid = spawn(Counter, :start, [0])
# current_state: 0
send(pid, 2)
# current_state: 2
send(pid, 2)
# current_state: 4
…Aaaaaand now we’re storing state in a process!
Responding to a message
While storing state is fine and all, it’s not really useful unless we can retrieve it somehow, which is what we’re going to do to our counter. But first we need to go through how we actually respond to messages.
Since all processes have a mailbox that can receive messages, so does our main process. Let’s have a look at how we can receive responses from a process by sending the reference to the current process:
pid = spawn(fn ->
receive do
{message, respond_to} ->
send(respond_to, String.reverse(message))
end
end) # => PID#<0.25.0>
send(pid, {"Hello", self()})
response = receive do
message -> message
end
response # => "olleH"
This means we now know what we’ll need to know in order to implement a way to retrieve the state from our counter. Now that our module will need to handle two different types of messages we’ll define how we’d like to handle the messages based on a tuple that we can pattern match against.
defmodule Counter do
def start(initial_state) do
loop(initial_state)
end
defp loop(state) do
receive do
{:add, number} ->
loop(state + number)
{:get, caller} ->
send(caller, state)
loop(state)
end
end
end
pid = spawn(Counter, :start, [0])
send(pid, {:add, 10})
send(pid, {:get, self()})
current_state = receive do
state -> state
end
current_state # => 10
By now you should have a basic understanding on the core mechanics of
GenServer
and why its API’s is defined they way they are!
You should be aware that the real implementation of GenServer
is quite
sophisticated and has evolved over the course of 20 years to tackle a lot of
edge cases connected to fault tolerance. So in case you feel like implementing
your own GenServer
, go ahead, but do it only for educational purposes.