Elixir by Example

:gen_statem

Edit any cell and run it in your browser Download source

Many processes are really state machines: a connection is connecting, connected or closed; an order is new, paid or shipped. You can write one as a GenServer with a status field, but every callback then starts with "which state am I in?", and timers you forgot to cancel fire in the wrong state.

:gen_statem is OTP's behaviour for state machines. It keeps the current state apart from the data, and it adds things a GenServer doesn't have:

  • state enter calls: code that runs every time a state is entered, whichever way you got there.
  • state timeouts: timers cancelled automatically when the state changes.
  • postpone: "I can't handle this event in this state; give it to me again after the next state change."

Elixir has no wrapper for it, so you call the Erlang module directly.

A pedestrian crossing. Cars have a light that goes green, yellow, red. A pedestrian calls cross/1, which returns only when it's safe to walk.

defmodule Crossing do
  @behaviour :gen_statem

  def start_link, do: :gen_statem.start_link(__MODULE__, [], [])
  def cross(crossing), do: :gen_statem.call(crossing, :cross)
  def light(crossing), do: :gen_statem.call(crossing, :light)

  # `:state_functions` means one function per state:
  # `green/3`, `yellow/3`, `red/3`. Each gets the event
  # type, the event and the data. `:state_enter` turns
  # on enter calls.
  @impl true
  def callback_mode, do: [:state_functions, :state_enter]

  # `init/1` returns the first state and the data. The
  # data here counts the people who crossed.
  @impl true
  def init([]), do: {:ok, :green, 0}

  # An enter call gets `:enter` and the previous state.
  # It may set timeouts, but it can't change state.
  def green(:enter, _previous, _crossed) do
    IO.puts("cars: green")
    :keep_state_and_data
  end

  # Someone wants to cross: switch to yellow and
  # `:postpone` the call. Postponed events come back
  # after every state change, so the same call is
  # handled again in `yellow/3`.
  def green({:call, _from}, :cross, crossed) do
    {:next_state, :yellow, crossed, [:postpone]}
  end

  def green(type, event, crossed), do: common(type, event, :green, crossed)

  # A state timeout: 30 ms after entering yellow, the
  # event `:turn_red` arrives. Leaving yellow any other
  # way would cancel it.
  def yellow(:enter, _previous, _crossed) do
    IO.puts("cars: yellow")
    {:keep_state_and_data, [{:state_timeout, 30, :turn_red}]}
  end

  def yellow(:state_timeout, :turn_red, crossed), do: {:next_state, :red, crossed}

  # Still not safe. Postpone again; the caller keeps
  # waiting inside `cross/1`.
  def yellow({:call, _from}, :cross, _crossed), do: {:keep_state_and_data, [:postpone]}
  def yellow(type, event, crossed), do: common(type, event, :yellow, crossed)

  # Red for cars. Every postponed `cross` is retried
  # here, in arrival order, and finally gets its reply.
  def red(:enter, _previous, _crossed) do
    IO.puts("cars: red")
    {:keep_state_and_data, [{:state_timeout, 50, :turn_green}]}
  end

  def red({:call, from}, :cross, crossed) do
    {:keep_state, crossed + 1, [{:reply, from, :walk}]}
  end

  def red(:state_timeout, :turn_green, crossed), do: {:next_state, :green, crossed}
  def red(type, event, crossed), do: common(type, event, :red, crossed)

  # Events every state handles the same way. With
  # `:state_functions` each state has to pass them on.
  defp common({:call, from}, :light, state, crossed) do
    {:keep_state_and_data, [{:reply, from, {state, crossed}}]}
  end
end

Starting the machine enters the first state, so the enter call prints.

{:ok, crossing} = Crossing.start_link()
IO.inspect(Crossing.light(crossing), label: "light")
cars: green
light: {:green, 0}

Two pedestrians press the button. Both block in cross/1 through yellow and get :walk once cars have red. Nobody had to keep a list of waiting callers, as Backpressure does with a GenServer; the postponed calls are that list.

pedestrians = for _ <- 1..2, do: Task.async(fn -> Crossing.cross(crossing) end)
IO.inspect(Task.await_many(pedestrians), label: "pedestrians")
cars: yellow
cars: red
pedestrians: [:walk, :walk]

Red has its own state timeout, so the light turns green again without anyone asking.

defmodule Wait do
  def until(check) do
    if check.(), do: :ok, else: Process.sleep(1) && until(check)
  end
end

Wait.until(fn -> match?({:green, _}, Crossing.light(crossing)) end)
IO.inspect(Crossing.light(crossing), label: "light")
cars: green
light: {:green, 2}

The other callback mode is :handle_event_function: every event goes to one handle_event/4, with the state as an argument. Use it when states share a lot of handling, or when the state isn't a plain atom.

A fighting-game combo: press down, right, punch, and don't wait more than 50 ms between buttons. The state is the list of buttons still needed.

defmodule Combo do
  @behaviour :gen_statem
  @combo [:down, :right, :punch]

  def start_link, do: :gen_statem.start_link(__MODULE__, [], [])
  def press(combo, button), do: :gen_statem.call(combo, {:press, button})

  @impl true
  def callback_mode, do: :handle_event_function

  # Everything lives in the state, so the data is unused.
  @impl true
  def init([]), do: {:ok, @combo, nil}

  # The last button of the combo.
  @impl true
  def handle_event({:call, from}, {:press, button}, [button], _data) do
    {:next_state, @combo, nil, [{:reply, from, :special_move}]}
  end

  # The right button, more to go. `{:timeout, ms, msg}`
  # is an *event timeout*: any event that arrives first
  # cancels it, so it fires only after 50 ms of silence.
  def handle_event({:call, from}, {:press, button}, [button | rest], _data) do
    {:next_state, rest, nil, [{:reply, from, :ok}, {:timeout, 50, :too_slow}]}
  end

  # A wrong button at the start is ignored.
  def handle_event({:call, from}, {:press, _}, @combo, _data) do
    {:keep_state_and_data, [{:reply, from, :ok}]}
  end

  # A wrong button midway: back to the start, and
  # postpone the press, so it's tried again as the first
  # button of a new combo.
  def handle_event({:call, _from}, {:press, _}, _midway, _data) do
    {:next_state, @combo, nil, [:postpone]}
  end

  def handle_event(:timeout, :too_slow, _midway, _data), do: {:next_state, @combo, nil}
end

{:ok, combo} = Combo.start_link()
press = fn buttons -> Enum.map(buttons, &Combo.press(combo, &1)) end

IO.inspect(press.([:down, :right, :punch]), label: "combo")
combo: [:ok, :ok, :special_move]

The second :down breaks the first attempt, but the postponed press starts a new one, so the combo still lands.

IO.inspect(press.([:down, :down, :right, :punch]), label: "combo")
combo: [:ok, :ok, :ok, :special_move]

Too slow: the event timeout fires during the pause and the machine is back at the start before the punch.

first = press.([:down, :right])
Process.sleep(100)
IO.inspect(first ++ press.([:punch]), label: "too slow")
too slow: [:ok, :ok, :ok]

The three timeouts, side by side:

  • {:timeout, ms, msg}: event timeout, cancelled by any event.
  • {:state_timeout, ms, msg}: cancelled by a state change.
  • {{:timeout, name}, ms, msg}: generic timeout, lives until it fires or you set it again (to :infinity to cancel). Several can run at once.

Pitfall: an event timeout is cancelled by any event, including a harmless status query, and it stays cancelled unless the handler for that event sets it again. One status query in the middle of a combo and the 50 ms window never closes. Use a state timeout or a generic timeout when other events can arrive.

Pitfall: postponed events wait until the state changes. If it never does, they pile up in the process, and the callers of postponed calls time out (5 s by default for :gen_statem.call/2).

Pitfall: @behaviour :gen_statem doesn't define child_spec/1 the way use GenServer does. To put the machine under a supervisor, write one: def child_spec(arg), do: %{id: __MODULE__, start: {__MODULE__, :start_link, [arg]}}.

Pitfall: an enter call can't change state or postpone; returning {:next_state, other, ...} from it crashes the machine. Decide the next state in the event handler, and keep enter calls to side effects and timeouts.