Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Generic Server

reading/deprecated_generic_server.livemd

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 GenServers

Review 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.

Navigation

Home Report An Issue Process MailboxTesting GenServers