Erlang (and Elixir) strongly advocates concurrency, scalability, and fault tolerance via multiprocessing and the use of specific design patterns based on it.
An Erlang process is not an operative system process: it is faster, lighter, with much smaller memory footprint.
By spawning hundreds, thousands or even millions of these processes you can achieve great scalability, as
this recent post
explains.
The server/client architecture builds on such lightweight processes, can handle state, and is one of the keys to the
great results that can be achieved using Elixir.
Let’s build a server starting from this simple calculator module code:
defmodule Calculator do
def calc(value, operator, n) do
case operator do
:+ -> value + n
:- -> value - n
:* -> value * n
:/ -> value / n
end
end
end
Calculator.calc 50, :-, 8
# 42
This code doesn’t handle state, just as you would expect when using a functional language: for multiple operations you
need to pipe the functions:
Calculator.calc(50, :-, 8)
|> Calculator.calc(:-, 21)
|> Calculator.calc(:*,2)
Spawning an Erlang process is very easy, fast and cheap:
pid = spawn fn -> end
The variable pid
is a reference to the process and can be used to communicate with it.
A server is basically a never-ending process
that recursively handles some kind of requests. Let’s build some looping process first, with the ability to
handle state as well. Copy and paste this code in your iex
shell:
defmodule Server do
def start do
spawn fn -> loop(1) end
end
defp loop(n) do
IO.puts "looping #{n} times"
:timer.sleep 1000
loop n+1
end
end
Server.start
# looping 1 times
# looping 2 times
# ...
Every second a new message is printed on the screen: these letters come from the spawned server process,
which is non blocking (you can still use the repl) and kind of stateful
(the number is incremented after each iteration).
Let’s go back to our calculator example. Let’s write the CalcServer
module that complements the
Calculator
core module. Here the loop
function will at first handle the incoming
messages, then recursively call itself with the new updated state:
defmodule CalcServer do
import Calculator
def start do
spawn fn -> loop(0) end
end
def loop value do
new_value = receive do
{:value, caller} ->
send caller, value
value
{operator, n} -> calc(value, operator, n)
end
loop new_value
end
end
Let’s play a little with it:
calc = CalcServer.start
send calc, {:+, 23}
send calc, {:-, 2}
send calc, {:*, 2}
The variable calc
references the calculator server process (returned by spawn
),
so we can send messages to it. These messages then get pattern-matched in the loop
receive
block, precisely here:
{operator, n} -> calc(value, operator, n)
The subsequent loop function will be called with the calculation result,
courtesy of the Calculator
module and its function calc
that has
been imported using the import Calculator
directive.
The messages we sent up until this point were fire-and-forget, meaning we didn’t receive any response from the server.
But now we need to read the total, so we can resort to bidirectional communication. First we need to send
the appropriate message to the server, which replies to our process with another message that we can read in our
main process:
send calc, {:value, self}
receive do
total -> IO.puts "Current total is #{total}"
end
# Current total is 42
We have successfully built from scratch a simple server that relies on processes and asyncronous communication.
Of course this is buggy and trivial code, but it was useful to analyze some details behind the GenServer architecture.
Now that we have a grasp of how things works, we can convert the existing code to use the GenServer behaviour. GenServer stands for “generic server”,
an abstraction/design-pattern that helps build servers with consistent and predictable interface. Here is the new server code:
defmodule CalcGenServer do
use GenServer
import Calculator
def start do
GenServer.start __MODULE__, 0
end
def handle_call :value, _, value do
{:reply, value, value}
end
def handle_cast {operator, n}, value do
{:noreply, calc(value, operator, n)}
end
end
In order to use the GenServer behaviour we only need to define a few functions: start
, with the
module name to delegate calls and casts (here the current module with __MODULE__
), and the start value 0
.
Casts are requests that don’t need an answer, fire-and-forget. Calls are requests that require an answer, and here we have
one example of each: reading the total value is a call, while operations with the calculator are handled with a cast.
Now, here is how we can use the new server:
{:ok, calc} = CalcGenServer.start
GenServer.cast calc, {:+, 84}
GenServer.cast calc, {:/, 2}
GenServer.call calc, :value
# 42.0
This is fairly straightforward, and we don’t need to listen explicitly for responses anymore, as we did with the
previous example.
In this post we just scratched the surface of GenServer, for a detailed introduction and explanation I strongly recommend reading the official Elixir
documentation.
Happy coding!
Leave a Reply