Gen servers - Abstracting state management and task run together

Cherry Ramatis - Oct 15 '23 - - Dev Community

In Elixir, every function runs inside a separate process, and we can deal with processes in a very natural way offered by the language. It's possible to manage state between multiple long-running processes through known abstractions such as Agent. In this article, we'll talk a little about an abstraction that allows the management of long-running processes with state and message communication for running functions accordingly! I hope it'll be fun and easy to understand.

Table of Contents

What is Gen server and what problem it solves

It's really important to understand what problem we're trying to solve before we even consider which implementation we should try, right? So the problem here is lack of abstraction around long running processes. Today, we can write our own module that handles some state and implement a method that uses the receive block to create a "mailbox", or we can use simple abstractions such as Agent and Task to either hold state only or manage asynchronous tasks only.

First, let's rewind a bit and understand what we do to share state in a process using simpler abstractions. If you want to know more in detail about those modules, reach out to the previous article linked at the beginning. :)

Agent

The Agent module provides a simple and efficient way to manage state, personally I view this module as an data structure for the process architecture because you can control any primitive value through a pid and getters/setters. It's pretty easy to use as you can see below in the example:

# Creating an agent
{:ok, agent} = Agent.start_link(fn -> [] end)

# Updating the state inside an agent
Agent.update(agent, fn list -> ["cherry" | list] end)

# Retrieving the state inside an agent
Agent.get(agent, fn list -> list end)
Agent.get(agent, fn list -> Enum.find(fn item -> item == "cherry" end) end)
Enter fullscreen mode Exit fullscreen mode

As you can see, we have a getter and a setter method that both accept a function callback to manage the internal state, with this simple API we can manage it just an data structure.

Task

Another abstraction that we can use with processes is the Task module that works around the idea of handling asynchronous processes (specifically abstracting the spawn function). It has a really simple API, and works like the following example:

# To initiate a new task
task = Task.async(fn ->
  # Do whatever you want here
  IO.puts("Task is running")

  42
end)

# Get the return value of this particular task
answer_to_everything = Task.await(task)
Enter fullscreen mode Exit fullscreen mode

If you know languages like JavaScript or csharp, you may recall the async await syntax from them.

After all this, you may be wondering why we need to use a Gen Server if we already have those modules presented above. Well, using a Gen server is similar to gluing all those concepts together (both async processes and state management) with a single and easy-to-use API and with a very important concept implemented: a mailbox for message-based architecture.

A mailbox is a common term in the elixir community that describes a module that can receive messages and act on it. The common way is by using the receive block (under the hood or explicitly) to handle those messages via pattern matching and spawn another processes with the correct action to take.

While using this abstraction, we're able to separate our client and server code by defining handle methods for each message and acting correctly on them. We'll see more about it below!

How to use the Gen server

The Gen Server abstraction provides an architecture that allows us to separate our module code into message senders and handle receivers. It's possible to either use GenServer.call or GenServer.cast with a message atom to activate a specific method via pattern matching.

For example, while using the Agent module, you'll most likely keep all the logic in the same place, as shown below:

def put(bucket, key, value) do
  # Here is the client code
  Agent.update(bucket, fn state ->
    # Here is the server code
    Map.put(state, key, value)
  end)
  # Back to the client code
end
Enter fullscreen mode Exit fullscreen mode

Since the Agent module didn't provide enough abstraction, you're forced to provide the function that needs to take action on the state right there in the update function. But by using a message-base abstraction, we can modularize our code a lot more, like below:

# Client code
def put(pid, key, value) do
  GenServer.call(pid, {:insert, key, value})
end

# Server code
def handle_call({:insert, key, value}, _from, state) do
  {:reply, :ok, Map.put(state, key, value)}
end
Enter fullscreen mode Exit fullscreen mode

See? We were able to separate a lot more code by simply using the power of Elixir pattern matching and the message architecture that the Gen server provided.

The usage of this abstraction is quite straightforward; you need to implement the behavior as follows:

defmodule Example do
  use GenServer

  # Client world (we'll add later)

  # Server world

  @impl true
  def init(state) do
    {:ok, state}
  end

  @impl true
  def handle_call(:list_all, _from, state) do
    {:reply, state, state}
  end

  @impl true
  def handle_cast({:insert, name}, state) do
    :timer.sleep(5000)

    {:noreply, [name | state]}
  end
end
Enter fullscreen mode Exit fullscreen mode
  • The init function will be called when we start the Gen Server with GenServer.start_link. This works as our entry point to define the initial state that will be managed.
  • The handle_call function enters the realm of "mailbox" receivers, this function will be evoked when we use GenServer.call with a specific action that will be pattern-matched (:list_all in the example). It's also important to point out that these functions will be run synchronously.
  • The handle_cast function serves the same purpose as the handle_call; it'll be called from a GenServer.cast call, and its pattern is matched correctly. The important difference is that this function will run asynchronously.

After understanding the server part, let's complement it with some client-code API for us to call on the REPL:

defmodule Example do
  use GenServer

  # Client world

  def add_name(pid, name) do
    GenServer.cast(pid, {:insert, name})
  end

  def list_names(pid) do
    GenServer.call(pid, :list_all)
  end

  # Server world
  # ...
end
Enter fullscreen mode Exit fullscreen mode

The client code in this specific case will serve as an easy API for the external world, so we don't need to keep referring to the GenServer module always. With this set of methods we can handle sync code (the list_names method with call) and async code (the add_name method with cast) easily!

Observe also that on our handle_cast for :insert we're using the timer to sleep 5 seconds on purpose!, below we'll show how this works on the REPL to understand the difference between async and sync calls.

To view the correct delay, watch the below video:

To refer to the methods used on the REPL:

iex(1)> {:ok, pid} = GenServer.start_link(Example, [])
{:ok, #PID<0.135.0>}
iex(2)> Example.list_names(pid)
[]
iex(3)> Example.add_name(pid, "Cherry")
:ok
iex(4)> Example.list_names(pid)
["Cherry"]
Enter fullscreen mode Exit fullscreen mode

The add_name method returned instantly, but the list_names method suffered from the timer that we imposed. The balance between those two concepts turn this abstraction really useful and flexible!

References

Conclusion

This is another part of the learning through processes in elixir! I hope it's useful for anyone reading this, it's been an awesome experience for me since I'm writing while learning the language and all those new concepts. If I can help with anything, just reach out ! May the force be with you 🍒

. . . . . . . . . . . . . . . . . . . . . .