Getting familiar with how GenServer uses send and receive

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.