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.