Getting familiar with how GenServer uses send and receive

Published by Johan Tell

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 special form named receive. The receive special form will look in the mailbox to find a message or block the process execution until a message is sent to it (by using send/2) 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 use 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.

All processes have a mailbox that can receive messages, and 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"

Now we know everything we need in order to retrieve the state from our counter. Since our module will need to handle two different types of messages we’ll define how we’d like to handle them 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 of the core mechanics of GenServer and why its APIs are defined the 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.

Johan Tell