Getting to Know GenServers in Elixir

Recently, I have been trying learning Elixir and it has been quite the transition for me coming from Ruby. The toughest hurdle for me so far is understanding processes and how state is handled in Elixir. Primarily, the different ways messages are passed around between processes, because there are no objects in the elixir-land. Personally, I took storing/handling state for granted using an OO language like Ruby first, because it is really easy.

For example, if I had a program to track how many times i've listened to a certain song I would set up a class like this:

 class SongTracker 

   def initialize(song)
     @song = song
     @play_count = 0
   end

   def play
     @play_count += 1
   end
 end 

 song = SongTracker.new("93' Til Infinity")
 song.play_count # => 0
 song.play
 song.play_count # => 1

The class SongTracker describes some behavior and instances of the class take care of state. The instance variable @play_count holds the current state of the instance of the SongTracker class and allows the state to be changed over time. This is really easy and allows us to create more objects, each holding their own respective state. Classes are the union between state and behavior.

  song = SongTracker.new("93' Til Infinity")
  song2 = SongTracker.new("Daybreak")
  song.play_count # => 0
  song.play
  song.play_count # => 1
  song2.play_count # => 0

Handling State in Elixir

Now I will show you how to handle state in Elixir by designing a similar program, starting off very generic and then showing you the power of the GenServer module. Modules in Elixir are responsible for describing behavior and processes are the part of elixir that handles state.

A basic implementation of the Ruby program in Elixir would look something like this:

  defmodule SongTracker do 
    def new do 
      0
    end

    def play(counter) do 
      counter + 1 
    end 

    def play_count(counter) do 
      counter 
    end 
  end 

   song = SongTracker.new # => 0 
   song = SongTracker.play(song) # => 1
   song = SongTracker.play(song) # => 2
   song = SongTracker.play_count(song) # => 2

This looks okay, but it's not really handling state. If we do not cary around a reference to the value of a variable, state would be loss. In order for the play count to be tracked, each of our functions return values must be captured and subsequently referenced. Adding more functionality to this would be difficult and not very abstract, because here we are capturing values, not state.

Handling state with processes and message passing

One way we would start handling state in Elixir is by utilizing processes and message passing. In Elixir we use messages to communicate with processes, and as I mentioned earlier, processes are responsible for handling state.

In Elixir, all code runs inside processes. Processes are isolated from each other, run concurrent to one another and communicate via message passing. Processes are not only the basis for concurrency in Elixir, but they also provide the means for building distributed and fault-tolerant programs.

Here is the SongTracker implementation using processes and message passing:

defmodule SongTracker do 
  def start do 
    loop(0)
  end

  defp loop(count) do 
    receive do -> 
      {:play, from} -> 
        send(from, count + 1)
        loop(count + 1)
    end
   end
 end  

Next, I will open my terminal and spawn the process.

iex(1) pid = spawn(SongTracker, :start, []) 
#PID<0.67.0>
iex(2) send(pid, {:play, self})
{:play, #PID<0.57.0>
iex(3) flush
1
:ok
iex(4) send(pid, {:play, self})
iex(5) send(pid, {:play, self})
iex(6) send(pid, {:play, self})
flush
2
3
4
:ok

Here I am spawning a process using MFA, or Module, Function, Arity syntax. The start function calls the loop function with an initial count of 0. To interact with the newly spawned process, we need to teach it how to respond to messages from other processes. To do this, we add a receive block to our loop function. The receive block inside of the loop function is then listening for a message that it can respond to. In this case, it is looking for a message, in the form of a tuple, with play as the message and the process id of sender. In the IEX shell, I send a message to this process by running send(pid, {:play, self}).

Self in this instance is the process id of the IEX shell and pid was captured when I spawned the initial process. The process in the receive block takes this information and sends the return value (increment the play count by 1) to the IEX process. By running flush I get to see all of the messages in the processes mailbox. When a message is sent to a process, the message is stored in the process mailbox, so it is important that you give all of your processes a way to respond. A full mailbox is no good.

As you can imagine, adding more functionality will cause such a simple program to grow and our loop function could become hard to manage. Also, there are some edge cases that aren't being handled here. What happens if a message is sent that cannot be handled? What is the process dies? etc.

use GenServer

Because this concept of passing messages and handling state is normal behavior, the GenServer module gives us a ton of functionality and can take care of our simple program with relative ease.

Let's take a look:

defmodule SongTracker do 
  use GenServer

 def new do 
   {:ok, pid} = GenServer.start_link(SongTracker, 0)
   pid
 end

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

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

 def reset(pid) do 
   GenServer.call(pid, {:reset, new_count})
 end 

 def handle_call(:play, _from, count) do 
   {:reply, count + 1, count + 1} 
 end 

 def handle_call(:play_count, _from, count) do 
   {:reply, count, count} 
 end 

  def handle_call({:reset, new_count}, _from, _count) do 
    {:reply, :ok, new_count} 
  end 
end

There's a lot going on here and i've added some functionality. Also, you may be wondering why in the world there are a bunch of functions called handle_call. By adding the line use GenServer we get a ton of functionality from this module. The two that you will most commonly see are callback functions called handle_call and handle_cast. Calls are synchronous and the server must send a response back to such requests. Casts are asynchronous and the server won’t send a response back. Remember that our modules are not responsible for handling state so each function is expecting a process as an argument.

Let's take a look at how we would run this program:

iex(10) pid = SongTracker.new
#PID<0.102.0>
iex(11) SongTracker.play(pid)
1 
iex(12) SongTracker.play(pid)
2
iex(13) SongTracker.play_count(pid)
2
iex(14) SongTracker.reset(pid, 0)
:ok
iex(15) SongTracker.play_count(pid)
0

First we call SongTracker.new. If we look at the code for this function, it calls GenServer.start_link(SongTracker, 0). This spawns a new process of the Song Tracker module with an arity of 0. By using pattern matching, I captured the process id and made that the return value of this function.

Next, I call SongTracker.play(pid) which then runs GenServer.call(pid). This sends a message to that process with a message of play. Because I have set up this function

  def handle_call(:play, _from, count) do
    {:reply, count + 1, count + 1}
  end

the message of :play is handled and can respond appropriately. We use the handle_call callback because we care about the reply.

{:reply, count + 1, count + 1}

This line was very confusing to me first, but seeing it this way might make it easier to understand.

{:reply, thing_to_reply_with, state}

You can see that in the function that responds to :play_count,

def handle_call(:play_count, _from, count) do 
    {:reply, count, count} 
end

the response is just the current state of the process. In the :reset function that responds to {:reset, new_value},

def handle_call({:reset, new_count}, _from, _count do 
    {:reply, :ok, new_count}
end

we ignore the current state of the process and update it. The server responds with a message of :ok and changes the state of the process to the value that was passed in the message.

Conclusion

As you can see, you can set this module up to handle any message that you want it to. A GenServer, like any other process, can be set up to keep state, execute code asynchronously, etc. This post only skims the surface of what GenServers are capable of. I encourage you to read some of the Elixir documentation to learn more.

Show Comments
comments powered by Disqus