Taking the “happy path” Taking the happy path Photo by Felix Kayser on Unsplash

In Elixir and Erlang, a process is where we keep our state.

The story goes like this:

  • a process starts with an initial state
  • the process waits for messages
  • send messages to the process to change it’s state
  • process modifies it’s state and passes itself the new version of it’s state
  • waits for messages (rinse and repeat)

That’s it.

There are a number of different interfaces to do this, but underneath it all, they are all doing the same thing.

The built-in ways look like this:

  • spawn a process
  • Use a GenServer behavior
  • Use an Agent behavior (a subset of GenServer functionality)

Why is this a recommended pattern?

  • it is efficient
  • it is easy to write pure functions that test the behavior (given the same input, the same output results)
  • it is common convention, others will understand what you are doing

I setup a simple Github project that makes it easy to play with these approaches.

https://github.com/brainlid/meetup_process_state

Basic Spawn Example

    defmodule ProcessState.SpawnBasic do
      require Logger

      def start(initial_state) do
        Logger.info("Starting separate process with initial state: #{inspect initial_state}")
        spawn __MODULE__, :main_loop, [initial_state]
      end

      def main_loop(state) do
        Logger.info("main_loop state: #{inspect state}")
        new_state =
          receive do
            {:add, increment} ->
              Logger.info("Received :add message: #{inspect increment}")
              add(state, increment)
            _ ->
              Logger.info("Received some other message. State unchanged.")
              state
          end
        main_loop(new_state)
      end

      defp add(%{counter: current} = _state, increment) do
        %{counter: current + increment}
      end
    end

When we call start, it spawns a new process. That process sets up it’s initial state and calls main_loop. There is nothing special about this function name. I just picked something. The process then calls receive. This blocks the process until it receives a message. This example only responds to a message of {:add, number}. When that message is received, it modifies the state and uses “tail recursion” to call itself again with the new state.

Experiment by sending messages and seeing the log output in the console. The Logger config was customized to output the pid as part of the metadata. This makes it easier to see which process is running which commands.

You might see something like this…

    iex(1)> pid = ProcessState.SpawnBasic.start(%{counter: 0})

    19:47:08.640 pid=<0.114.0> [info]  Starting separate process with initial state: %{counter: 0}

    19:47:08.640 pid=<0.116.0> [info]  main_loop state: %{counter: 0}
    #PID<0.116.0>

    iex(2)> send(pid, {:add, 10})

    19:47:12.469 pid=<0.116.0> [info]  Received :add message: 10

    19:47:12.469 pid=<0.116.0> [info]  main_loop state: %{counter: 10}
    {:add, 10}

    iex(3)> send(pid, {:add, 1})

    19:47:14.758 pid=<0.116.0> [info]  Received :add message: 1
    {:add, 1}

    19:47:14.758 pid=<0.116.0> [info]  main_loop state: %{counter: 11}

If you open Observer and inspect the process (in this example it would be 0.116.0) the “State” tab will show you “Information could not be retrieved, system messages may not be handled by this process.” This basic spawn works as intended, but it doesn’t support being inspected and handling system-level messages. That’s one reason it’s better to use a GenServer.

GenServer Example

The GenServer approach is preferred. It makes it easier to supervise the process and it has built-in support for handling system messages.

    defmodule ProcessState.GenServerState do
      require Logger
      use GenServer

      # Client

      def start(initial_state) do
        Logger.info("Starting genserver with initial state: #{inspect initial_state}")
        GenServer.start(__MODULE__, initial_state)
      end

      def add(pid, increment) do
        Logger.info("Sending :add message")
        GenServer.call(pid, {:add, increment})
      end

      # Server (callbacks)

      def handle_call({:add, increment}, _from, %{counter: current} = _state) do
        Logger.info("Adding #{inspect increment} to state.")
        new_state = %{counter: current + increment}
        Logger.info("New state is #{inspect new_state}")
        {:reply, current + increment, new_state}
      end

    end

Here’s an example of how to interact with it in IEx.

    alias ProcessState.GenServerState
    {:ok, pid} = GenServerState.start(%{counter: 0})
    GenServerState.add(pid, 10)
    GenServerState.add(pid, 11)
    :observer.start
    # examine the "state" of the process

You might see something like this…

    iex(1)> alias ProcessState.GenServerState
    ProcessState.GenServerState

    iex(2)> {:ok, pid} = GenServerState.start(%{counter: 0})

    19:59:29.725 pid=<0.114.0> [info]  Starting genserver with initial state: %{counter: 0}
    {:ok, #PID<0.117.0>}

    iex(3)> GenServerState.add(pid, 10)
    10

    19:59:32.965 pid=<0.114.0> [info]  Sending :add message

    19:59:32.965 pid=<0.117.0> [info]  Adding 10 to state.

    19:59:32.965 pid=<0.117.0> [info]  New state is %{counter: 10}

    iex(4)> GenServerState.add(pid, 11)
    21

    19:59:38.822 pid=<0.114.0> [info]  Sending :add message

    19:59:38.822 pid=<0.117.0> [info]  Adding 11 to state.

    19:59:38.822 pid=<0.117.0> [info]  New state is %{counter: 21}

Note the pid responsible for running each line? All the same things are happening here that are happening in the spawn example. The GenServer approach makes it less explicit and adds support for handling other system messages.

Running Observer, I found the pid (0.117.0 in my case) in the “Processes” tab. Inspecting that process, the “State” tab looks like this.

observer inspecting GenServer State

The last line shows the “state” structure in the GenServer process.

Conclusion

Using the GenServer or Agent approach for storing runtime state in your Elixir application is the standard, recommended way. They have built-in support for being supervised. There is built-in support for handling system-level events. Observer understands them and can work with them natively. Most importantly perhaps, the functions can be “pure” and predictable. Given the same input, the same output is returned.

This is the “clean” version of managing state in a process.