What are Server Processes
A server process can be defined as one with the following behaviour :
- Long running process.
- It can maintain state.
- It can process messages and react to them.
- It may modify the internal state.
- It may send a reply back to the client.
Server processes are important in building highly concurrent systems.
Erlang provides an important abstraction to handle server processes in it’s
portion of OTP framework, known as a Genserver
. It’s the essential backbone
over which much of the parallel processing in Elixir is built.
Implementing the Server Process
Inorder to have a better understanding of the Genserver
behaviour. Let’s build
a minimal version of this abstraction ourselves using the very basic construct
of OTP, processes
. To do this, we have to implement a process which has all
the characteristics mentioned above, of a long running server process.
What we want to achieve at the end:
1. Define a behaviour(for a long running process with state) which can be extended
using callback modules.
2. The behaviour should create a process and initialize it by calling the
respective callback module.
3. The behaviour should have a mechanism to keep the process runnning until a signal
to stop or kill the process is given.
4. The behaviour should expose interface functions to deal with the server process.
5. The interface functions should include functions to make synchronous and
asynchronous calls to the process.
First steps first, create a new project with :
mix new server_process
Inside your project create a module ServerProcess.GenericServer
in
lib/server_process/generic_server.ex
.
This module will handle our generic code(start process, maintain state etc.) implmentation,
the specifics of which(e.g. initalizing state, processing, etc.) would be handled
by a concrete implementation. These implementations can be done using callbacks
.
(will become more clear as we move forward).
defmodule ServerProcess.GenericServer do
def start(callback_module) do
spawn(fn ->
initial_state = callback_module.init()
loop(callback_module, initial_state)
end)
end
...
end
GenericServer.start(callback_module)
takes a module name, and spawns a process
with the initial state, being set by a function init()
exposed in the
callback_module
. In order to enforce this rule of exposing the init
function
we can use behaviours
.
defmodule Server.GenericServer do
@callback init()
...
end
With this change all the callback modules implmenting the GenericServer
behaviour
will have to implement the callbacks added.
Next step would be to add the loop(callback, state)
function. The loop function
makes use of a tail recursion call to maintain the state and
keep the process running forever using infinite recursion.
defmodule Server.GenericServer do
@callback init() :: any
@callback handle_call(request :: tuple(), state :: any) ::
{response :: any, new_state :: any}
@callback handle_cast(request :: tuple(), state :: any) :: {new_state :: any}
@callback terminate() :: any}
....
def loop(callback_module, state) do
new_state =
receive do #waits for a message to come in the mail box
{:call, from, request} ->
{result, state} = callback_module.handle_call(request, state)
send(from, {:result, result})
state
{:cast, _from, request} ->
callback_module.handle_cast(request)
{:stop, _from, reason} ->
callback_module.terminate()
Process.exit(self(), reason)
end
loop(callback_module, new_state)
end
.....
end
The loop/2
function handles the generic logic of sending and receiving messages,
the specific logic of what to do with the request is handled by the callback module’s
handle_call/2
and handle_cast
functions. This brings us to the next set of
callbacks that should be implemented. The last callback terminate
allows
us to perform any cleanups before terminating our genserver with Process.exit/2
.
Whenever we are using tail recursion inside loop function with a
receive
block, the process waits for any message to come in its mailbox and executes it when it receives one. One important thing to note here is that when a process is waiting for a message atreceive
, it is put in a suspended state. This prevents wastage of any CPU cycles for the process.
A request can be synchronous or asynchronous depending on the requirement. To
differentiate between them we use :call
(synchronous) and :cast
(asynchronous)
going with the convention in Erlang. In a synchronous
request the calling process
waits for a response from the server process. This blocks the client for the
duration in which processing happens. In an asynchronous
the client doesn’t
expect a return value so it just sends the request and continues with its work.
The requests are pattern matched to continue with further execution, all the requests
coming in must be tagged with :cast
or :call
.
Now that our long running process with state is started, let’s move on to writing the interface functions which will help us in interacting with this process. The functions we would implement are:
cast
: makes an aysnchromous call, starts the processing and returns.call
: makes a synchronous call and returns with the value after processing.stop
: stops the server process with provided reason.
defmodule ServerProcess.GenericServer do
....
def cast(pid, request_params) do
send(pid, {:cast, self(), request_params})
end
def call(pid, request_params) do
send(pid, {:call, self(), request_params})
receive do
{:result, result} ->
IO.puts(result)
end
end
def stop(pid, reason) do
send(pid, {:stop, self(), reason})
end
....
end
The cast/2
function takes the pid of the server process
and the request params
that would be sent to the callback_module
for processing. Internally it calls
the Kernel.send/2
with the pid
and {:cast, self(), request_params}
. This
will send a message to the mailbox of our server process. On receiving a message
the process will pattern match it, in our case it is {:cast, _from, request}
inside the receive block, which will further call the handle_cast
callback
in the callback_module
we initiated our process with.
The call/2
function similarly takes the pid and the request params and sends
a message to the process mailbox using Kernel.send/2
. However, since we are
expecting a result we make use of a receive
block. Processes in elixir interact
with each other using the process mailbox. As can be seen from the loop block that
the server process sends a message back to the calling process. The receive block
prints any message received for our client process.
The stop/2
function takes the pid
and reason
(:normal, :kill). The function
sends a message to the our server process with a reason for termination. On
receiving the message the server_process makes a call to terminate callback in
the callback module to perform cleanups and then terminates itself.
This is our server process module in it’s entirety.
defmodule ServerProcess.GenericServer do
############### interface functions ############
# The following functions are run in the client context
def start(callback_module) do
spawn(
fn ->
initial_state = callback_module.init()
loop(callback_module, initial_state)
end
)
end
def cast(pid, request_params) do
send(pid, {:cast, self(), request_params})
end
def call(pid, request_params) do
send(pid, {:call, self(), request_params})
receive do
{:result, result} ->
IO.puts(result)
end
end
def stop(pid, reason) do
send(pid, {:stop, self(), reason})
end
################ callbacks for the behaviour ######
@callback init() :: any
@callback handle_call(request :: tuple(), state :: any) ::
{response :: any, new_state :: any}
@callback handle_cast(request :: tuple(), state :: any) :: {new_state :: any}
@callback terminate() :: any
################ server calls #################
# The following functions are run in the server process context
def loop(callback_module, state) do
new_state =
receive do
{:call, from, request} ->
{result, state} = callback_module.handle_call(request, state)
send(from, {:result, result})
state
{:cast, _from, request} ->
callback_module.handle_cast(request, state)
{:stop, _from, reason} ->
callback_module.terminate()
Process.exit(self(), reason)
end
loop(callback_module, new_state)
end
end
Creating a Callback Module
Let’s implement a callback module to test out our generic server. We will create a simple key value store and implement the callbacks defined by the behaviour.
defmodule ServerProcess.ExampleModule do
@moduledoc """
Simple key value store that extends generic server behaviour.
"""
@behaviour ServerProcess.GenericServer
@impl true
def init() do
%{}
end
@impl true
def handle_call({:get, key} = _message, state) do
result = Map.get(state, key, "not found")
{result, state}
end
@impl true
def handle_cast({:put, key, value} = _message, state) do
Map.put(state, key, value)
end
@impl true
def terminate() do
:ok
end
end
We use the @behaviour
key to use our ServerProcess.GenericSServer
behaviour.
We implement the callbacks mentioned and make use of pattern matching on the
messages we are receiving.
init()
: Initiates our process with map that is placed in the process state.handle_call({:get, key} = _message, state)
: receives a{:get, key}
message to find the key in ourstate
map and returns with the result and state.handle_cast({:put, key, value} = _message, state)
: receives a message to put a key in the state and returns the state.terminate
: performs any cleanup.
Let’s try this out in an iex shell
$server_process-> iex -S mix
iex(1)> alias ServerProcess.GenericServer
ServerProcess.GenericServer
iex(2)> alias ServerProcess.ExampleModule
ServerProcess.ExampleModule
iex(3)> pid = GenericServer.start ExampleModule
#PID<0.151.0>
iex(4)> Process.alive? pid
true
iex(5)> GenericServer.cast pid, {:put, :lang, "Elixir"}
{:cast, #PID<0.147.0>, {:put, :lang, "Elixir"}}
iex(6)> GenericServer.call pid, {:get, :lang}
Elixir
:ok
iex(7)> GenericServer.stop pid, :normal
{:stop, #PID<0.147.0>, :normal}
iex(8)> Process.alive? pid
false
We are able to run all our functions. This brings us to the end of the blog.
Summary
We implemented the GenServer Behaviour ourselves, we created it a manner that heavy lifting of initializing server process and maintaining the state is handled by generic code, whereas the specifics are being handled by using callback module. The callback module implements callbacks we created in the behaviour. Although, we can create such an abstraction ourselves it’s important to use the abstractions provided by OTP as they are much more stable and provide other functionalities which are crticial for our application.