Elixir/OTP : Basics of GenServer
GenServer is one of the main abstractions provided by the OTP framework that standardises creation, usage of stateful server processes and their interactions with client processes in Elixir. It is a behaviour that abstracts away the common low level api that act on processes and provides a high level standardised contract for server modules, that focuses only on the specific implementation details.
It is built on top of various concepts related to the concurrency primitive, the processes, such as process spawning, registration of processes, process linking, message passing, synchronous message sending, managing state using recursion of receive block etc. Some of the main applications of GenServer are creating long running server processes that manage state, respond to requests synchronously and asynchronously, support resilience via supervisors, provide tracing, debugging, monitoring, state update during hot code swapping and error reporting capabilities.
The GenServer module contains common client API functions that will be called by client processes in order to communicate with GenServer processes. The user-defined module that implements the GenServer behaviour will contain GenServer API contract callback implementations that will be executed in the server processes to handle requests that were made by the client processes using the client API functions.
Starting the GenServer
Since GenServer is a behaviour, in order to make use of it, an Elixir module must be defined and the GenServer behaviour must be implemented by the module.
defmodule ServerExample do
@behaviour GenServer
end
For an Elixir module that implements the GenServer behaviour, the callbacks defined in the GenServer behaviour contract must be implemented inside the module as required. The only callback that is required to be implemented by the GenServer behaviour is init/1
that will be called for initialising the state of the server process. The rest of the callbacks are optional and can be implemented if the server process needs to provide a particular functionality.
The GenServer modules can be started using the client API functions GenServer.start/3
and GenServer.start_link/3
, that takes in the name of the server module as the first argument, an Elixir term as the second argument that will in turn be passed in to the init/1
GenServer API callback defined in the server module, and an optional keyword list as the third argument with options to configure the server process. The difference between the two client functions is that the latter creates a bidirectional process link between the caller and the server process while the former does not. Once one of the above two functions is called, a process is spawned, then the init callback code is executed which returns a two element tuple, based on which the next steps are determined. The init call is blocking and it can be used to customise the process flag values, perform other initialisations and set the initial state, after which the spawned process will start the receive block loop to start processing messages. Please note that while the init callback is being executed, the process is already alive and can receive messages. But the received messages will be processed only after the init callback has finished executing.
The various allowed options that can be passed into the third argument of the start/3
and start_link/3
functions are
:name
, whose atom value will be used to register the process with. If the provided value is already used, then starting the server will fail by returning{:error, {:already_started, pid}}
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(init_arg), do: {:ok, init_arg}
end
---------------------------------------------------------------------------
GenServer.start(ServerExample, [], name: ServerExample)
{:ok, #PID<0.143.0>}
Process.whereis(ServerExample)
#PID<0.143.0>
:timeout
, whose value in milliseconds, is the maximum time theinit
callback can take to execute before returning the result. If this option is used and the init callback exceeds the provided time, then the:GenServer.start/3
or:GenServer.start_link/3
function will fail returning{:error, :timeout}
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(_init_arg), do: Process.sleep(2000)
end
---------------------------------------------------------------------------
GenServer.start(ServerExample, [], timeout: 1000)
{:error, :timeout}
:debug
, which takes in a list of values including:trace
,:log
,:statistics
etc and initiates the respective debugging function in the:sys
module.
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(init_arg), do: {:ok, init_arg}
end
---------------------------------------------------------------------------
debug_opts = [:trace, :statistics]
{:ok, server_pid} = GenServer.start(ServerExample, [], debug: debug_opts)
send(ServerExample, :test)
*DBG* 'Elixir.ServerExample' got test
*DBG* 'Elixir.ServerExample' new state []
:sys.statistics(server_pid, :get)
{:ok,
[
start_time: {{2024, 1, 28}, {19, 13, 36}},
current_time: {{2024, 1, 28}, {19, 15, 25}},
reductions: 239,
messages_in: 1,
messages_out: 0
]}
:spawn_opt
, which takes in a list of options, that will be internally passed into theProcess.spawn/4
call used for spawning the process. The options include:link
,:monitor
and other process flags related options.
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(init_arg), do: {:ok, init_arg}
end
---------------------------------------------------------------------------
opts = [priority: :high, min_heap_size: 500]
{:ok, server_pid} = GenServer.start(ServerExample, [], spawn_opt: opts)
Process.info(server_Pid, :min_heap_size)
{:min_heap_size, 610}
Process.info(server_Pid, :priority)
{:priority, :high}
:hibernate_after
, which takes in milliseconds as its value. If the process does not receive any message within the provided time, the:proc_lib.hibernate/3
method will be implicitly called to put the process on hibernation. Once the process receives a message, then it will be awakened. Normally when a process does not have any messages in its mailbox, it will only be put in thewaiting
state. But hibernating a process additionally involves clearing the stack and performing forced garbage collection on the process’s heap to reduce the memory usage.
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(init_arg), do: {:ok, init_arg}
end
---------------------------------------------------------------------------
opts = [hibernate_after: 5000]
{:ok, server_pid} = GenServer.start(ServerExample, [], opts)
Process.info(server_pid, :heap_size)
{:heap_size, 233}
Process.info(server_pid, :stack_size)
{:stack_size, 11}
#after 5 seconds of no messages and hibernation
Process.info(server_pid, :heap_size)
{:heap_size, 36}
Process.info(server_pid, :stack_size)
{:stack_size, 1}
The possible return values of the init/1
callback and their outcomes are as follows.
{:ok, state}
will use the second element of the tuple as the initial state of the process and make the process move into the receive loop to start processing the messages. The client API functionsstart/3
orstart_link/3
will in turn return{:ok, server_pid}
to the caller.{:ok, state, timeout_in_milliseconds}
will perform the same thing mentioned above and in addition to that, start a timer for the milliseconds provided as the third element. If the process does not have any messages in the message queue before the timer elapses, then thehandle_info/2
callback will be called with the message as:timeout
.
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(init_arg), do: {:ok, init_arg, 5000}
@impl true
def handle_info(:timeout, state) do
IO.puts("timeout called")
{:noreply, state}
end
end
---------------------------------------------------------------------------
{:ok, server_pid} = GenServer.start(ServerExample, [])
#after 5 seconds of no messages
timeout called
{:ok, state, :hibernate}
will again perform the same as{:ok, state}
and in addition to that, the process will be hibernated if the message queue of the process is empty when the init callback has finished executing. If the process has already received some messages during the time between its creation and completion of the init callback’s execution, then the process will not be hibernated.
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(init_arg), do: {:ok, init_arg, :hibernate}
end
---------------------------------------------------------------------------
{:ok, server_pid} = GenServer.start(ServerExample, [])
Process.info(server_pid, :heap_size)
{:heap_size, 36} # reduced heap and stack size due to hibernation - GC
Process.info(server_pid, :stack_size)
{:stack_size, 1}
{:ok, state, {:continue, continue_arg}}
will invoke thehandle_continue/2
callback before the process starts looping the receive block. The passedcontinue_arg
from the init callback will be passed as the first argument of thehandle_continue/2
callback. This is mainly used to make the init callback as quick as possible since it is blocking, and move additional initialisation to thehandle_continue/2
callback which will be performed before the process enters the receive loop.
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(init_arg), do: {:ok, init_arg, {:continue, :arg_2}}
@impl true
def handle_continue(arg, state) do
IO.puts("continue instruction :: #{arg} :: #{state}");
{:noreply, state}
end
end
---------------------------------------------------------------------------
{:ok, server_pid} = GenServer.start(ServerExample, :init_arg)
continue instruction :: arg_2 :: init_arg
{:stop, reason}
will stop the process with reason as the providedreason
before handling any messages. Thestart/3
orstart_link/3
function will in turn return{:error, reason}
to the caller.
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(_init_arg), do: {:stop, :test}
end
---------------------------------------------------------------------------
GenServer.start(ServerExample, [])
{:error, :test}
:ignore
will stop the process but with reason as:normal
, so that it can be restarted later whenever required.
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(_init_arg), do: :ignore
end
---------------------------------------------------------------------------
GenServer.start(ServerExample, [])
:ignore
Handling requests synchronously
The GenServer.call/3
client API function can be used to initiate a synchronous call to a GenServer process. It takes in the server PID or its registered name atom as the first argument, the message to be sent as the second argument and an optional timeout milliseconds as its third argument. The default value for the timeout is passed as 5000. Since it is a synchronous call, this function call blocks the client process until the server replies with a result message or until the timeout occurs. If there is a timeout, then an error will be thrown in the client process, which, if not caught, will crash the client process.
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(init_arg), do: {:ok, init_arg}
@impl true
def handle_call(:test, _, state) do
Process.sleep(2000)
{:reply, :call_result, state}
end
end
---------------------------------------------------------------------------
GenServer.start(ServerExample, [], name: Server)
GenServer.call(Server, :test, 1000) #calling process crashes after 1 second
** (exit) exited in: GenServer.call(ServerExample, :test, 1000)
** (EXIT) time out
(elixir 1.16.0) lib/gen_server.ex:1114: GenServer.call/3
iex:232: (file)
---------------------------------------------------------------------------
GenServer.call(Server, :test)
:call_result
In order to handle the synchronous calls sent by the clients using the GenServer.call/3
function, the handle_call/3
GenServer API callback must be implemented. Failing to implement this callback in the server module will lead to an exit crash in both the calling process and the server process, when a GenServer.call
is made. Internally three arguments will be passed into the handle_call
callback such as the request message sent by the client, a two element tuple containing the client PID and a unique reference for the request, and the current state of the server process. Based on the request message, the appropriate processing must be handled in this callback and a tuple must be returned, based on which the next steps are decided. It is a common practice to create multiple function clauses of the callback to pattern match on the request message and perform the associated processing in different clauses. Again, if none of the clauses match the incoming request message from the client, then an exit crash will happen in both the calling process and the server process.
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(init_arg), do: {:ok, init_arg}
@impl true
def handle_call(:test, from, state) do
IO.inspect(from, label: "from")
{:reply, :call_result, state}
end
end
---------------------------------------------------------------------------
GenServer.start(ServerExample, [], name: Server)
GenServer.call(Server, :invalid_msg)
17:17:10.425 [error] GenServer Server terminating #Server process crash
** (FunctionClauseError) no function clause matching in ServerExample.handle_call/3
iex:6: ServerExample.handle_call(:invalid_msg, {#PID<0.109.0>, [:alias | #Reference<0.0.13955.2302241477.788332546.30907>]}, [])
(stdlib 5.0.2) gen_server.erl:1113: :gen_server.try_handle_call/4
(stdlib 5.0.2) gen_server.erl:1142: :gen_server.handle_msg/6
(stdlib 5.0.2) proc_lib.erl:241: :proc_lib.init_p_do_apply/3
Last message (from #PID<0.109.0>): :invalid_msg
State: []
** (exit) exited in: GenServer.call(Server, :invalid_msg, 5000) #client process crash
** (EXIT) an exception was raised:
** (FunctionClauseError) no function clause matching in ServerExample.handle_call/3
iex:232: ServerExample.handle_call(:testsss, {#PID<0.109.0>, [:alias | #Reference<0.0.13955.2545004690.797769729.243998>]}, [])
(stdlib 5.0.2) gen_server.erl:1113: :gen_server.try_handle_call/4
(stdlib 5.0.2) gen_server.erl:1142: :gen_server.handle_msg/6
(stdlib 5.0.2) proc_lib.erl:241: :proc_lib.init_p_do_apply/3
(elixir 1.16.0) lib/gen_server.ex:1114: GenServer.call/3
iex:235: (file)
Process.whereis(Server)
nil
-------------------------------------------------------------------------------
GenServer.start(ServerExample, [], name: Server)
GenServer.call(Server, :test)
from: {#PID<0.109.0>, [:alias | #Reference<0.0.13955.2545004690.797769729.235185>]}
:call_result
The various formats of tuples that can be returned from the handle_call/3
callback and their outcomes are as follows.
{:reply, call_result, new_server_state}
will return the 2nd element to the caller process as the return value of theGenServer.call/3
function. The 3rd element will be set as the updated state of the server and the server process will continue to process other messages in its mailbox.
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(_init_arg), do: {:ok, []}
@impl true
def handle_call({:add, el}, _from, current_state) do
new_state = [el | current_state]
{:reply, new_state, new_state}
end
end
---------------------------------------------------------------------------
GenServer.start(ServerExample, [], name: Server)
GenServer.call(Server, {:add, 1})
[1]
GenServer.call(Server, {:add, 2})
[2, 1]
{:reply, call_result, new_server_state, timeout_in_milliseconds}
will perform the same things as the tuple format above, and in addition to that, will start a timer for the milliseconds provided as the fourth element. If the process does not have any messages in the message queue before the timer elapses, then thehandle_info/2
callback will be called with the message as:timeout
.
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(_init_arg), do: {:ok, []}
@impl true
def handle_call({:add, el}, _from, current_state) do
new_state = [el | current_state]
{:reply, new_state, new_state, 5000}
end
@impl true
def handle_info(:timeout, state) do
IO.puts("timeout called from server process")
{:noreply, state}
end
end
---------------------------------------------------------------------------
GenServer.start(ServerExample, [], name: Server)
GenServer.call(Server, {:add, 1})
[1]
# after 5 seconds of no messages in server process's mail box
timeout called from server process
{:reply, call_result, new_server_state, :hibernate}
will again perform the same as{:reply, call_result, new_server_state}
and in addition to that, the process will be hibernated if the message queue of the process is empty when thehandle_call/3
callback has finished executing.{:reply, call_result, new_server_state, {:continue, continue_arg}}
will perform the same actions as{:reply, result, new_state}
and in addition to that, invoke thehandle_continue/2
callback withcontinue_arg
as its first argument andnew_state
as its second argument. This can be used for handling additional background tasks without blocking the caller process. The continue instruction handler will be executed before the server process picks up another message for processing.{:noreply, new_state}
will not send any result to the caller process that has called theGenServer.call/3
function. Thenew_state
will be used as the updated state and the process will move on to processing other messages. But the caller process will still be waiting for the result. In such cases theGenServer.reply/2
function will be used to explicitly return the result to the caller process. This is used in scenarios where the result is known beforehand and can be sent first before doing some background processing or if the work has been delegated to another process from where the reply needs to be sent. The first argument of theGenServer.reply/2
function must be thefrom
tuple that identifies the sender process. Thefrom
tuple contains the caller’s pid and a unique reference for request. It is passed in as the second argument to thehandle_call
callback. The second argument of theGenServer.reply/2
function contains the result term that needs to be sent to the caller process.
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(_init_arg), do: {:ok, []}
@impl true
def handle_call({:process, data}, from, current_state) do
GenServer.cast(BackgroundServer, {:background_process, from, data})
IO.puts("Server process moving to next message")
{:noreply, current_state}
end
end
---------------------------------------------------------------------------
defmodule BackgroundServer do
@behaviour GenServer
@impl true
def init(_init_arg), do: {:ok, []}
@impl true
def handle_cast({:background_process, caller_ref, data}, cur_state) do
IO.puts("Background process processing data")
GenServer.reply(caller_ref, :processed_data)
{:noreply, cur_state}
end
end
---------------------------------------------------------------------------
GenServer.start(ServerExample, [], name: Server)
GenServer.start(BackgroundServer, [], name: BackgroundServer)
GenServer.start(ServerExample, [], name: Server)
Server process moving to next message
Background process processing data
:processed_data
{:noreply, new_state, timeout_in_milliseconds}
,
{:noreply, new_state, :hibernate}
and
{:noreply, new_state, {:continue, continue_arg}}
will perform the same way as above and additionally will perform actions according to the third element, as mentioned in the other responses above.{:stop, reason, call_result, new_state}
will send the third element as the return value to the client process that has called theGenServer.call/3
function. Ifterminate/2
server API callback has been implemented, then it will be invoked with the second elementreason
and the fourth elementnew_state
. Theterminate/2
callback is used to perform clean up operations before the process exits. Finally, the process will exit with the second element as reason.
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(_init_arg), do: {:ok, []}
@impl true
def handle_call({:add, data}, _from, current_state) do
new_state = [data | current_state]
{:stop, :test_reason, new_state, new_state}
end
@impl true
def terminate(reason, new_state), do: IO.puts("cleanup :: #{reason}")
end
---------------------------------------------------------------------------
GenServer.start(ServerExample, [], name: Server)
GenServer.call(Server, {:add, 1})
cleanup :: test_reason
[1]
Process.whereis(Server)
nil
{:stop, reason, new_state}
does not reply to the caller process and performs everything else the same way as the response above.
Handling requests asynchronously
The GenServer.cast/2
client api function is used to initiate an asynchronous request to a GenServer process. The function call takes in the server PID or the registered name as the first argument and the message to send as the second argument. It immediately returns :ok
irrespective of whether the request has reached the server or not.
The asynchronous requests initiated by the client using the GenServer.cast/2
function must be handled by using the handle_cast/2
GenServer api callback. Two arguments such as the request message and the server’s current state are passed into the handle_cast/2
callback. Similar to the handle_call/3
callback, multiple implementations that pattern match the request message are commonly used for handling asynchronous requests. If there are no proper callback implementations that can handle an incoming cast request, then the server process will crash with an exit.
The various possible return value formats that handle_cast/2
supports are {:noreply, new_state}
, {:noreply, new_state, timeout_in_milliseconds}
, {:noreply, new_state, :hibernate}
, {:noreply, new_state, {:continue, continue_arg}}
and {:stop, reason, new_state}
. Each of the formats behave the same way as described above in the handle_call/3
response formats.
handle_info/2
The handle_info/2
callback can be implemented to process messages sent to GenServer processes that are not call and cast requests. If a specific implementation is not defined, then these messages will be ignored. The allowed return value formats are the same as the handle_cast/2
callback.
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(_init_arg), do: {:ok, []}
@impl true
def handle_info(msg, cur_state) do
IO.puts("Info message :: #{msg}")
{:noreply, cur_state}
end
end
---------------------------------------------------------------------------
GenServer.start(ServerExample, [], name: Server)
send(Server, :test)
Info message :: test
handle_continue/2
The handle_continue/2
callback is used to handle continue instructions originating from other callbacks such as init
, handle_call
, handle_cast
and handle_info
. They are mainly used for handling additional background processing without needing to block the client process that has called the GenServer.start
, GenServer.start_link
or GenServer.call
functions. If there are no specific implementations for the handle_continue
callback and if continue instructions are used, then the server process will crash with an exit. The continue instructions, once invoked, will be executed before the process picks up another request to process. The allowed return value formats for the handle_continue
callback is the same as the handle_cast
callback. Two arguments are passed into the handle_continue
callback. The first is the element tagged by :continue
, present as part of the return values of the callbacks that invoke the continue instructions,{:continue, continue_arg}
. The second argument is the process’s current state.
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(init_arg), do: {:ok, init_arg, {:continue, :arg_2}}
@impl true
def handle_continue(arg, state) do
IO.puts("continue instruction :: #{arg} :: #{state}");
{:noreply, state}
end
end
---------------------------------------------------------------------------
{:ok, server_pid} = GenServer.start(ServerExample, :init_arg)
continue instruction :: arg_2 :: init_arg
terminate/2
The terminate/2
callback, if implemented, is executed before the process exits. It has access to the process’s exit reason and the state, which can be used to perform clean up. Two arguments such as the process exit reason and the server’s current state are passed into the terminate
callback. The terminate/2
callback is executed only on certain scenarios, which include callbacks such as init
, handle_call
, handle_cast
, handle_info
and handle_continue
using the :stop
atom in their reply tuples; callbacks returning a value that is not an allowed format; an error being raised inside the process using raise
macro; process explicitly exiting using exit
and when GenServer.stop/3
function is being called on a process.
When the process is not trapping exits and the exit reason is anything other than :normal
or when the process receives an exit signal with :kill
as reason, then the process will be abruptly killed and the terminate
callback will not be executed. The terminate callback is only executed after all the messages, which were already present in the mailbox prior to receiving the exit signal, have been processed. Any messages that were received after receiving the exit signal will not be processed. In some cases, such as when using GenServer.stop/3
, the process could be enforced with a timeout within which it should exit. In such cases if the process is still processing the already received messages and if the timeout has occurred, then the process will be killed without executing the terminate callback. Hence crucial clean up functionality should not be executed within the terminate callback and instead within other designated processes.
format_status/2
The format_status/2
callback is used to format the internal state of the server process when it is being exposed. It is mainly used to reduce the amount of information printed in logs and to hide sensitive information from the server’s state. Two arguments are passed in to the callback such as a reason atom and a list containing the process dictionary and current state. The reason can be either :normal
, passed when using the :sys.get_status
function and :terminate
, passed when an error is logged due to the process exit or termination. If the GenServer process’s exit reason is not :normal
, :shutdown
or {:shutdown, term}
, then an error will be logged that contains the state of the exited process.
defmodule ServerExample do
@behaviour GenServer
@impl true
def init(_init_arg), do: {:ok, %{secret: "xyz1", long_list: [1,2,3,4,5]}}
@impl true
def format_status(reason, [_pdic, %{long_list: list_val} = state]) do
case reason do
:normal -> %{state | Enum.take(list_val, 2)}
:terminate -> Map.put(state, :secret, "***") |>
Map.put(:long_list, Enum.take(list_val, 2))
end
end
end
---------------------------------------------------------------------------
{:ok, server_pid} = GenServer.start(ServerExample, :init_arg)
:sys.get_status(server_pid)
{:status, #PID<0.137.0>, {:module, :gen_server},
[
[
"$initial_call": {ServerExample, :init, 1},
"$ancestors": [#PID<0.109.0>, #PID<0.101.0>]
],
:running,
#PID<0.137.0>,
[],
[
{:header, ~c"Status for generic server Elixir.Server"},
{:data,
[
{~c"Status", :running},
{~c"Parent", #PID<0.137.0>},
{~c"Logged events", []}
]},
%{secret: "xyz1", long_list: [1, 2]} # formatted state
]
]}
-------------------------------------------------------------------------
GenServer.stop(server_pid, :kill)
13:59:06.649 [error] GenServer #PID<0.137.0> terminating
** (stop) :kill
Last message: []
State: %{secret: "***", long_list: [1, 2]} # masked secret,formatted status
code_change/3
The code_change/3
callback is used to update the state of a running GenServer process when a new version of the module’s code is loaded to replace the old version of code. Elixir supports hot code swapping where the old code can be swapped automatically with new code without needing to halt the existing running application. Any new function call will automatically use the new version of the code, while the functions that are already running, use the old code until they finish execution.
When a GenServer process has already been started and is currently running using old code, if a new version of code for the module associated with the GenServer process is loaded, then there after, any new function calls to the GenServer will be executed using the new code. But the state of the GenServer will still be the same structure as defined in the old code. Hence if new code has updated the structure of the server’s state and the GenServer process is still running with its old state of a different structure, any new request to the server that pattern matches the old state with the new state’s structure will crash the server, as they won’t match.
defmodule Counter do
@behaviour GenServer
@impl true
def init(_), do: {:ok, 0}
@impl true
def handle_call(:increment, _, state), do: {:reply, state + 1, state + 1}
end
---------------------------------------------------------------------------
{:ok, server_pid} = GenServer.start(Counter, nil, name: Counter)
GenServer.call(Counter, :increment)
1
---------------------------------------------------------------------------
defmodule Counter do # load new code
@behaviour GenServer
@impl true
def init(_), do: {:ok, %{count: 0}}
@impl true
def handle_call(:increment, _, %{count: count}) do
{:reply, count + 1, %{count: count + 1}}
end
end
warning: redefining module Counter (current version defined in memory)
└─ iex:6: Counter (module)
---------------------------------------------------------------------------
GenServer.call(Counter, :increment)
20:52:22.223 [error] GenServer Counter terminating
** (FunctionClauseError) no function clause matching in Counter.handle_call/3
iex:20: Counter.handle_call(:increment, {#PID<0.109.0>, [:alias | #Reference<0.0.13955.3098795204.3681091588.257420>]}, 1)
(stdlib 5.0.2) gen_server.erl:1113: :gen_server.try_handle_call/4
(stdlib 5.0.2) gen_server.erl:1142: :gen_server.handle_msg/6
(stdlib 5.0.2) proc_lib.erl:241: :proc_lib.init_p_do_apply/3
Last message (from #PID<0.109.0>): :increment
State: 1
As you can see above, there is a GenServer process already running with the old code, where the process state is an integer that gets incremented. But when an updated version of the module, that has changed the state of the server from an integer to a map, is loaded, then hot code swapping makes sure that any new function calls will be handled by the new code definition. So when we call the :increment
on the already running server, the process state is still an integer and the handle_call
callback of the new code pattern matches the integer state as a map, which will cause an error and lead to the server process’s crash. In order to handle this, the state of the process must also be updated in order to be compatible with the changes in the new code. This is what the code_change/3
callback is used for. It takes in three arguments such the version of the old code, process’s current state of old structure and a term for any extra data required. The modules are tagged with a specific version using a module attribute @vsn
and the value of this module attribute is updated every time the code definition is updated. This attribute is referred for upgrading and downgrading code.
In production applications, the code_change/3
callback is called automatically by release configurations when trying to upgrade or downgrade code in a running application. In order to manually trigger the code_change/3
callback in IEx, the running process must first be suspended using :sys.suspend/1
during the code update. The server will receive messages but will not process them except for internal system messages during this time. Hence if a call request is sent to the suspended process, it will block the caller process until the code updating is complete. Once the process is suspended, the new version of the code is loaded into the application, after which the code_change/3
callback can be triggered using :sys.change_code(serverPID/name, module_name, old_version, extra_data)
. The code_change/3
callback will receive the respective arguments and it should return {:ok, new_state}
to update the process’s state to new_state
. If there is a failure in the callback and an error is raised or if the callback explicitly returns {:error, reason}
, then the state will not be modified and the process will retain the old format of the state. After this, the process can be resumed with :sys.resume/1
to process the received messages with new code and its compatible updated state.
defmodule Counter do
@vsn "1"
@behaviour GenServer
@impl true
def init(_), do: {:ok, 0}
@impl true
def handle_call(:increment, _, state), do: {:reply, state + 1, state + 1}
end
---------------------------------------------------------------------------
{:ok, server_pid} = GenServer.start(Counter, nil, name: Counter)
GenServer.call(Counter, :increment)
1
GenServer.call(Counter, :increment)
2
--------------------------------------------------------------------------
:sys.suspend(server_pid) # suspend process
defmodule Counter do # load new code
@vsn "2"
@behaviour GenServer
@impl true
def init(_), do: {:ok, %{count: 0}}
@impl true
def handle_call(:increment, _, %{count: count}) do
{:reply, count + 1, %{count: count + 1}}
end
@impl true
def code_change(old_vsn, state, extra) do
IO.puts("code change - #{old_vsn}, #{state}, #{extra}")
{:ok, %{count: state}}
end
end
warning: redefining module Counter (current version defined in memory)
└─ iex:6: Counter (module)
---------------------------------------------------------------------------
:sys.change_code(server_pid, Counter, "1", nil) # trigger code change
code change - 1, 2,
:sys.resume(server_pid) # resume process
GenServer.call(Counter, :increment)
3
:sys.get_state(server_pid) #updated state
%{count: 3}
It is a common practice to also include another code_change/3
callback function clause that is used to alter the state in case of downgrading the version. In such cases, its first argument will be a 2 element tuple in the format of {:down, old_vsn}
.
Client API functions
We have already seen some of the client API functions that GenServer module provides such as GenServer.start/3
, GenServer.start_link/3
, GenServer.call/3
, GenServer.cast/3
, GenServer.reply/2
and GenServer.stop/3
. Some of the other functions that the GenServer module provides are GenServer.multicall/4
and GenServer.abcast/3
that let you perform call and cast requests to multiple processes present in different nodes. In Elixir, multiple Beam VM instances can be run in the same machine or can be distributed across different machines. Each Beam VM instance is called a node and each node has an identifier atom. In distributed applications, the same GenServer process from the same module can be run in multiple nodes and can store consistent state to serve multiple clients and distribute the load. In such cases, the GenServer.multicall/4
and GenServer.abcast/3
can be used to perform the same request on such GenServer processes present in multiple nodes.
The GenServer.multicall
function takes in four arguments such as a list of node identifier atoms, the local registered name of the process, the message and an optional timeout in milliseconds. Once the requests are fired, the caller process is blocked until all the servers reply with the result or if timeout occurs. The return value is a 2 element tuple where the first element is a list of two element tuples in the format {node, call_reply}
, each representing a node and its reply for the call request. The second element of the 2 element tuple is a list of bad nodes that did not reply or respond to the call request. The GenServer.abcast
function similarly takes in a list of node identifier atoms, locally registered name of the process and the message. Since it is asynchronous, the function call will immediately return the atom :abcast
. The GenServer.whereis/1
function takes in the registered name and returns the pid associated with the name.
Debugging the GenServer process
We have already seen some of the debug flags that can be passed in to the GenServer.start
and GenServer.start_link
functions such as :trace
, :log
, :statistics
etc. The :sys
module contains their equivalent functions which are used for inspecting the state of a process and for performing other debug operations.
- The
:sys.get_state/1
takes in a pid and returns the current process state while the:sys.replace_state/2
can be used to replace the current process state. - The
:sys.get_status
function takes in a pid and returns a lot of information about the process such as its current status, parents, logged events, process state etc. - The
:sys.trace/2
takes in a pid and a boolean flag that prints all the events such as received messages and process state into the:stdio
. - The
:sys.log/2
takes in a pid and a boolean flag to log the events of the process, which can be obtained using the:sys.get_log/1
function and printed to:stdio
using the:sys.print_log/1
function. Similarly the:sys.log_to_file/2
can be used to log the events to a file. - The
:sys.statistics/2
function takes in a pid and a flag to turn on, off and get collected statistics such as start time, number of reductions, no of messages received and sent etc. - The
:sys.install
function can be used to create a custom debug function that can be used on a process. - Finally the
:sys.no_debug/1
function can be used to turn off all the debug options that have been turned on for a process.
KeyValue store example
defmodule KeyValueStore do
@behaviour GenServer
# client api
def start() do
GenServer.whereis(__MODULE__) ||
GenServer.start(__MODULE__, %{}, name: __MODULE__)
:ok
end
def get(key), do: GenServer.call(__MODULE__, {:get, key})
def put(key, val), do: GenServer.cast(__MODULE__, {:put, key, val})
def delete(key), do: GenServer.cast(__MODULE__, {:delete, key})
# server api
@impl true
def init(init_state) when is_map(init_state), do: {:ok, init_state}
def init(_), do: {:ok, %{}}
@impl true
def handle_call({:get, key}, _, state), do: {:reply, state[key], state}
def handle_call(_, _, state), do: {:reply, :invalid_request, state}
@impl true
def handle_cast({:put, key, val}, state) do
{:noreply, Map.put(state, key, val)}
end
def handle_cast({:delete, key}, state) do
{:noreply, Map.delete(state, key)}
end
def handle_cast(_, state), do: {:noreply, state}
end
---------------------------------------------------------------------------
KeyValueStore.start()
:ok
KeyValueStore.put(:one, 1)
:ok
KeyValueStore.put(:two, 2)
:ok
KeyValueStore.get(:one)
1
KeyValueStore.delete(:two)
:ok
KeyValueStore.get(:two)
nil
In the above example, we have created a simple KeyValueStore
module that contains both client and server api functions. It is a common practice to abstract all the generic GenServer client api functions into module specific client api functions. In our case, we have abstracted all the GenServer.start
, GenServer.call
and GenServer.cast
functions into module specific client functions such as start
, get/1
, put/2
and delete/1
. We have used a call request for the get
function since the client will need the result while for put
and delete
we have used cast as the clients need not wait for the result.
For the GenServer callback functions such as handle_call
and handle_cast
we have created separate function clauses to pattern match and handle each message separately. We have also added a match-all clause since any call or cast request that does not have a matching callback function will crash the server process.
use GenServer
The use
macro is used to inject common code present in a source file into other files during compilation. The GenServer module provides support for the use
macro which will inject boilerplate code and default implementations for the server api callbacks.
defmodule ServerExample do
use GenServer
end
---------------------------------------------------------------------------
#injected code by the use GenServer macro call
defmodule ServerExample do
@behaviour GenServer
def child_spec(init_arg) do
default = %{ id: __MODULE__, start: {__MODULE__, :start_link, [init_arg]}}
Supervisor.child_spec(default, unquote(Macro.escape(opts)))
end
@doc false
def init(init_arg), do: {:ok, init_arg}
@doc false
def handle_call(msg, _from, state) do
proc = case Process.info(self(), :registered_name) do
{_, []} -> self()
{_, name} -> name
end
raise "attempted to call GenServer #{inspect(proc)} but no handle_call/3 clause was provided"
end
@doc false
def handle_info(msg, state) do
proc = case Process.info(self(), :registered_name) do
{_, []} -> self()
{_, name} -> name
end
:logger.error(%{label: {GenServer, :no_handle_info},
report: %{ module: __MODULE__, message: msg, name: proc}},
%{domain: [:otp, :elixir], error_logger: %{tag: :error_msg},
report_cb: &GenServer.format_report/1})
{:noreply, state}
end
@doc false
def handle_cast(msg, state) do
proc = case Process.info(self(), :registered_name) do
{_, []} -> self()
{_, name} -> name
end
raise "attempted to cast GenServer #{inspect(proc)} but no handle_cast/2 clause was provided"
end
@doc false
def terminate(_reason, _state), do: :ok
@doc false
def code_change(_old, state, _extra), do: {:ok, state}
end
The use GenServer
macro call will be executed during compilation and a similar version of the above code will be injected into the destination module, thus providing default implementations and boiler plate code for the GenServer implementation. For any of the above default callbacks, if the user has already defined specific implementations in the module, then the default implementations will be overridden with specific implementations during compilation. The child_spec/1
function is part of the supervision feature which will be discussed in detail in another article.