Generic Server
Mix.install([
{:jason, "~> 1.4"},
{:kino, "~> 0.9", override: true},
{:youtube, github: "brooklinjazz/youtube"},
{:hidden_cell, github: "brooklinjazz/hidden_cell"}
])
Navigation
Home Report An Issue Process MailboxTesting GenServersReview Questions
- What is the purpose of a GenServer?
- Explain the lifecycle of sending a message to a GenServer and receiving a response.
- bonus: What happens when we send a GenServer too many messages? How might that bottleneck our program?
Generic Server
It’s a common pattern to implement a GenericServer
process which can
receive messages, delegate to some module, then send a message back to the caller.
A generic server stores a short-term in-memory state. The state can only be updated by other processes sending messages to the generic server.
sequenceDiagram
Process-->> Generic Server: send message
activate Generic Server
Generic Server->>Generic Server: handle message
Generic Server->>Generic Server: new state
Generic Server-->> Process: send response
deactivate Generic Server
Typically the callback module defines an init
function to define the initial state of
the GenericServer
process.
sequenceDiagram
Generic Server ->> Generic Server: spawns
Generic Server ->> Callback Module: init callback
Callback Module ->> Generic Server: init value
Generic Server ->> Generic Server: initializes state
defmodule GenericServer do
def start(callback_module) do
spawn(fn ->
initial_state = callback_module.init()
loop(callback_module, initial_state)
end)
end
end
defmodule CallbackModule do
def init() do
"example state"
end
end
GenericServer.start(CallbackModule)
The GenericServer
should be able to receive a message and return a response.
sequenceDiagram
Process ->> Generic Server: send
Generic Server ->> Generic Server: receive
Generic Server ->> Process: send
Process->> Process: receive
defmodule GenericServer
def call(server_pid, request) do
send(server_pid, {request, self()})
receive do
{:response, response} ->
response
end
end
...
end
The callback module will also define event handlers for when the GenericServer
receives
a message.
sequenceDiagram
Generic Server ->> Callback Module: handle_call(request, state)
Callback Module ->> Generic Server: response, new state
defmodule CallbackModule do
...
def handle_call(:increment, state) do
new_state = state + 1
response = {:ok, new_state}
{response, new_state}
end
end
The GenericServer
then loops and receives messages while delegating to the CallbackModule
defmodule GenericServer do
...
defp loop(callback_module, current_state) do
receive do
{request, caller} ->
{response, new_state} =
callback_module.handle_call(
request,
current_state
)
send(caller, {:response, response})
loop(callback_module, new_state)
end
end
end
Putting all of that together, we get the full flow of our GenericServer
and CallbackModule
.
sequenceDiagram
Caller Process ->> Generic Server: spawn
Generic Server ->> Callback Module: init
Callback Module ->> Generic Server: init
Generic Server ->> Generic Server: loop receive
Caller Process ->> Generic Server: send
Generic Server ->> Generic Server: receive
Generic Server ->> Callback Module: handle_call
Callback Module ->> Generic Server: response, new state
Generic Server ->> Generic Server: update state
Generic Server ->> Caller Process: send
Caller Process ->> Caller Process: receive
Here’s the fully functional GenericServer
module.
defmodule GenericServer do
def call(server_pid, request) do
send(server_pid, {request, self()})
receive do
{:response, response} ->
response
end
end
def start(callback_module) do
spawn(fn ->
initial_state = callback_module.init()
loop(callback_module, initial_state)
end)
end
defp loop(callback_module, current_state) do
receive do
{request, caller} ->
{response, new_state} =
callback_module.handle_call(
request,
current_state
)
send(caller, {:response, response})
loop(callback_module, new_state)
end
end
end
GenericServer
is incredibly powerful because it lets us reuse generic state and message
passing functionality with more domain-specific callback modules.
Let’s re-implement the Counter
with some extra
functionality to use with the GenericServer
.
The Counter
module is now a domain-specific CallbackModule
.
defmodule Counter do
def init() do
0
end
def handle_call(:increment, state) do
new_state = state + 1
response = {:ok, new_state}
{response, new_state}
end
end
counter_process = GenericServer.start(Counter)
Now we can call the counter process to update its internal state.
GenericServer.call(counter_process, :increment)
handle_call/2
lets us define generic messages we can send to the Counter process.
For example, in the message, we could :add
an integer as a payload.
defmodule AddableCounter do
def init() do
0
end
def handle_call({:add, payload}, state) do
new_state = state + payload
response = {:ok, new_state}
{response, new_state}
end
end
add_counter_process = GenericServer.start(AddableCounter)
Here’s how we would send the :add
message with a payload.
GenericServer.call(add_counter_process, {:add, 10})
Your Turn
In the Elixir cell below, create a NoteBook
callback module.
A NoteBook
‘s initial state should be an empty list. It should implement a handle_call/2
function
adds a new note like so.
note_book = GenericServer.start(NoteBook)
{:ok, ["new note 1"]} = GenericServer.call(note_book, {:add_note, "new note 1"})
{:ok, ["new note 1", "new note 2"]} = GenericServer.call(note_book, {:add_note, "new note 2"})
defmodule NoteBook do
end
GenServer
It’s useful to build your own generic server for the purpose of understanding how they work. However, we can and should rely on the built-in GenServer provided by Elixir.
We use
the GenServer provided by Elixir. Under the hood, this defines the generic server functionality
for the module.
Then much like our GenericServer
we can define an init
function. The init function
now accepts options, which we could use to set the initial state. We’ll ignore _opts
for now.
We use @impl
to specify that init
is a callback function for the GenServer. You can
see the @impl documentation for
more on why.
defmodule SimpleServer do
use GenServer
@impl true
def init(_opts) do
{:ok, 0}
end
end
We use GenServer.start_link/3 to start the new Counter
process with the new counter process.
There are no options, so the second parameter is an empty list.
{:ok, pid} = GenServer.start_link(SimpleServer, [])
We can define handle_call/3
functions. These look mostly the same as before, except
the second parameter will be the caller pid, and the third will be the state.
We also always return {:reply, response, new_state}
in a handle_call/3
function.
defmodule CounterServer do
use GenServer
@impl true
def init(_opts) do
{:ok, 0}
end
@impl true
def handle_call(:increment, _from, state) do
new_state = state + 1
response = {:ok, new_state}
{:reply, response, new_state}
end
end
{:ok, pid} = GenServer.start_link(CounterServer, 0)
Now let’s test our new Counter
! You can execute this Elixir cell below a few times and notice
that the count increments.
GenServer.call(pid, :increment)
GenServer.call/3 executes the handle_call/3
function in the internal code of GenServer, just like our
GenericServer
. The parent process then receives a response.
sequenceDiagram
Caller Process ->> GenServer: start_link
GenServer ->> Callback Module: init
Callback Module ->> GenServer: {:ok, state}
GenServer ->> Caller Process: pid
Caller Process ->> GenServer: GenServer.call/2
GenServer ->> Callback Module: handle_call/3
Callback Module ->> GenServer: new state, response
GenServer ->> Caller Process: response
This is the heart of generic servers. We can create an in-memory process, have it store some state, and then send the process messages to perform some work and return a response. Generic servers are often the go-to tool for short-term in-memory persistence.
The built-in GenServer also has some additional functionality, which will become more important as you work with concurrency. But, for now, we’re going to focus on using it as a tool for persistence, so you have everything you currently need.
Your Turn
In the Elixir cell below, use the built-in GenServer module to create a Journal
module where you can :add_entry
.
All journal entries should be stored in state and returned as a list
for the response.
{:ok, journal_pid} = GenServer.start_link(Journal, [])
GenServer.call(journal_pid, {:add_entry, "first entry"})
["first entry"]
GenServer.call(journal_pid, {:add_entry, "second entry"})
["second entry", "first entry"]
Example Solution
defmodule Journal do
use GenServer
@impl true
def init(_opts) do
{:ok, []}
end
@impl true
def handle_call({:add_entry, message}, _from, entries) do
new_entries = [message | entries]
{:reply, new_entries, new_entries}
end
end
Client / Server APIs
Often we organize GenServers into client functions and server callbacks. Rather than use the GenServer module directly, we’ll create functions that abstract that implementation detail away.
Commonly, the GenServer module will define a start_link/1
function for starting the GenServer, and a function for each message we can send to the GenServer.
Here’s our Counter
server with an increment/1
client API function.
defmodule CounterClientAndServer do
use GenServer
# Client API
def start_link(_opts) do
GenServer.start_link(CounterClientAndServer, [])
end
def increment(counter_pid) do
GenServer.call(counter_pid, :increment)
end
# Server callbacks
@impl true
def init(_opts) do
{:ok, 0}
end
@impl true
def handle_call(:increment, _from, state) do
next_count = state + 1
{:reply, next_count, next_count}
end
end
Let’s start our GenServer and bind its PID to the pid
variable.
{:ok, pid} = CounterClientAndServer.start_link([])
Now execute the Elixir cell below a few times and notice that the counter increments on each execution just as before.
CounterClientAndServer.increment(pid)
__MODULE__
Whenever referring to the current module, instead of using the full module name CounterClientAndServer
, we can use the special syntax __MODULE__
which automatically returns the name of the current module. This is useful if we ever change the module name to avoid refactoring.
defmodule CounterClientAndServer do
use GenServer
# Client API
def start_link(_opts) do
GenServer.start_link(__MODULE__, [])
end
...
end
See Client / Server APIs for more.
Name Registration
Sometimes we want to name our processes so there is only ever a single named process rather than referring to many processes by their PID. These are sometimes called singletons.
Here, we have a simplified NamedCounter
GenServer that will serve as our singleton example.
defmodule NamedCounter do
use GenServer
@impl true
def init(_opts) do
{:ok, 0}
end
@impl true
def handle_call(:increment, _from, state) do
{:reply, state + 1, state + 1}
end
end
We can spawn a process as a named process singleton when we call GenServer.start_link/3 by passing in a :name
option. The name can be any atom we choose.
GenServer.start_link(NamedCounter, [], name: :my_counter)
You’ll notice if we try to start this named process twice we get an error, because the process has already started.
GenServer.start_link(NamedCounter, [], name: :my_counter)
Now, rather than referring to the process by it’s PID, we can use the process name.
GenServer.call(:my_counter, :increment)
It’s common to name the process after the module that defines it. We usually include this configuration in a start_link/1
function inside of the named module.
Here’s a CounterSingleton
module which demonstrates how to configure the module as a named process.
defmodule CounterSingleton do
use GenServer
def start_link(init_arg) do
GenServer.start_link(__MODULE__, init_arg, name: __MODULE__)
end
@impl true
def init(_opts) do
{:ok, 0}
end
@impl true
def handle_call(:increment, _from, state) do
{:reply, state + 1, state + 1}
end
end
Now we can start the named process using CounterSingleton.start_link/1
.
CounterSingleton.start_link([])
Now we can send the named process messages using the module name.
GenServer.call(CounterSingleton, :increment)
Further Reading
Consider the following resource(s) to deepen your understanding of the topic.
Commit Your Progress
DockYard Academy now recommends you use the latest Release rather than forking or cloning our repository.
Run git status
to ensure there are no undesirable changes.
Then run the following in your command line from the curriculum
folder to commit your progress.
$ git add .
$ git commit -m "finish Generic Server reading"
$ git push
We’re proud to offer our open-source curriculum free of charge for anyone to learn from at their own pace.
We also offer a paid course where you can learn from an instructor alongside a cohort of your peers. We will accept applications for the June-August 2023 cohort soon.