Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Stately Machines

Stately Machines

OTP 19, Elixir, and gen_statem

https://github.com/antipax/gen_state_machine

Eric Entin

July 17, 2016
Tweet

More Decks by Eric Entin

Other Decks in Programming

Transcript

  1. OTP 19 • Released June 21st, 2016 • mnesia_ext: plug

    external storage into mnesia • dialyzer: extended support for maps • gen_statem: a new state machine behavior
  2. What is a state machine? a mathematical model of computation

    used to design both computer programs and sequential logic circuits
  3. State machine terminology • a state machine is an abstract

    machine that can be in one of a finite number of states • only one state at a time; the state it is in at any given time is called the current state • change from one state to another when initiated by a triggering event or condition; this is called a transition
  4. State transition table (for a turnstile) Current State Input Next

    State Output Locked Coin Unlocked Unlock turnstile so customer can enter Push Locked None Unlocked Coin Unlocked None Push Locked When customer has entered, lock turnstile
  5. defmodule Turnstile do use GenServer # Client def start_link(), do:

    GenServer.start_link(__MODULE__, :locked) def push(turnstile), do: GenServer.call(turnstile, :push) def coin(turnstile), do: GenServer.call(turnstile, :coin) def report(turnstile), do: GenServer.cast(turnstile, :report) # Server def handle_call(:coin, _, :locked), do: {:reply, :unlock, :unlocked} def handle_call(:push, _, :locked), do: {:reply, :noop, :locked} def handle_call(:coin, _, :unlocked), do: {:reply, :noop, :unlocked} def handle_call(:push, _, :unlocked), do: {:reply, :lock, :locked} def handle_cast(:report, :locked), do: IO.puts("I am currently locked!") && {:noreply, :locked} def handle_cast(:report, :unlocked), do: IO.puts("I am currently unlocked!") && {:noreply, :unlocked} end
  6. A turnstile GenServer def handle_call(:coin, _, :locked), do: {:reply, :unlock,

    :unlocked} def handle_call(:push, _, :locked), do: {:reply, :noop, :locked} def handle_call(:coin, _, :unlocked), do: {:reply, :noop, :unlocked} def handle_call(:push, _, :unlocked), do: {:reply, :lock, :locked} Current State Input Next State Output Locked Coin Unlocked Unlock turnstile so customer can enter Push Locked None Unlocked Coin Unlocked None Push Locked When customer has entered, lock turnstile Input Current State Output Next State
  7. Turnstile in use iex(1)> {:ok, turnstile} = Turnstile.start_link() {:ok, #PID<0.86.0>}

    iex(2)> Turnstile.report(turnstile) I am currently locked! :ok iex(3)> Turnstile.push(turnstile) :noop iex(4)> Turnstile.coin(turnstile) :unlock iex(5)> Turnstile.report(turnstile) I am currently unlocked! :ok iex(6)> Turnstile.coin(turnstile) :noop iex(7)> Turnstile.push(turnstile) :lock iex(8)> Turnstile.push(turnstile) :noop
  8. • Network protocols • Trading systems • Compilers • Databases

    • Games • Batch/Stream processing The list goes on and on…
  9. A lot of these problems are much more complicated than

    our turnstile. They’re also pretty common.
  10. What have we learned? • State machines are a useful

    tool for modeling solutions for many problems • We can easily build simple state machines in Elixir using just the standard library • Elixir + Nerves: clearly the best way to build firmware for a turnstile
  11. Could we do better than GenServer? • Each state’s code

    is spread amongst handle_call/3, handle_cast/2, and handle_info/2, making it hard to see what the code does in a given state at a glance • Current machine state is a part of GenServer state data • Events cannot easily be postponed (or replied to) from a later state, or originate from the state machine itself
  12. gen_statem • An OTP behaviour for implementing a state machine

    • Allows you to gather each state’s code in one block, regardless of whether you are handling call, cast, or info • Separates current state and data into two parameters • Enables easy postponement of messages, timeouts, multiple replies, and internal events • Allows you to perform multiple “actions” as a result of an event • Has two “callback modes” for maximum flexibility
  13. gen_statem callback modes • :handle_event_function • current state can be

    any term (“infinite state machine”) • events flow through a single callback function for all states • :state_functions • like gen_fsm, requires the current state to be an atom (“~1,048,576 state machine”) • the state is used as the name of the current callback function
  14. gen_statem and Elixir • Works just fine, but: • You

    have to remember to annotate your callback module with the behaviour • You have to implement all of the callbacks yourself (including the ones you probably don’t care about) • Erlang-style API, rather than an idiomatic Elixir one • Erlang error logs, rather than nice Elixir ones • Why not reduce boilerplate and enhance usability by wrapping gen_statem the same way GenServer wraps gen_server?
  15. defmodule Turnstile do use GenStateMachine # By default, uses callback

    mode :handle_event_function # Client def start_link(), do: GenStateMachine.start_link(__MODULE__, {:locked, nil}) def push(turnstile), do: GenStateMachine.call(turnstile, :push) def coin(turnstile), do: GenStateMachine.call(turnstile, :coin) def report(turnstile), do: GenStateMachine.cast(turnstile, :report) # Server def handle_event({:call, from}, :coin, :locked, data), do: {:next_state, :unlocked, data, [{:reply, from, :unlock}]} def handle_event({:call, from}, :push, :locked, _data), do: {:keep_state_and_data, [{:reply, from, :noop}]} def handle_event(:cast, :report, :locked, _data), do: IO.puts("I am currently locked!") && :keep_state_and_data def handle_event({:call, from}, :coin, :unlocked, _data), do: {:keep_state_and_data, [{:reply, from, :noop}]} def handle_event({:call, from}, :push, :unlocked, data), do: {:next_state, :locked, data, [{:reply, from, :lock}]} def handle_event(:cast, :report, :unlocked, _data), do: IO.puts("I am currently unlocked!") && :keep_state_and_data end
  16. Turnstile in use iex(1)> {:ok, turnstile} = Turnstile.start_link() {:ok, #PID<0.86.0>}

    iex(2)> Turnstile.report(turnstile) I am currently locked! :ok iex(3)> Turnstile.push(turnstile) :noop iex(4)> Turnstile.coin(turnstile) :unlock iex(5)> Turnstile.report(turnstile) I am currently unlocked! :ok iex(6)> Turnstile.coin(turnstile) :noop iex(7)> Turnstile.push(turnstile) :lock iex(8)> Turnstile.push(turnstile) :noop
  17. defmodule Turnstile.StateFunctions do use GenStateMachine, callback_mode: :state_functions # Client def

    start_link(), do: GenStateMachine.start_link(__MODULE__, {:locked, nil}) def push(turnstile), do: GenStateMachine.call(turnstile, :push) def coin(turnstile), do: GenStateMachine.call(turnstile, :coin) def report(turnstile), do: GenStateMachine.cast(turnstile, :report) # Server def locked({:call, from}, :coin, data), do: {:next_state, :unlocked, data, [{:reply, from, :unlock}]} def locked({:call, from}, :push, _data), do: {:keep_state_and_data, [{:reply, from, :noop}]} def locked(:cast, :report, _data), do: IO.puts("I am currently locked!") && :keep_state_and_data def unlocked({:call, from}, :coin, _data), do: {:keep_state_and_data, [{:reply, from, :noop}]} def unlocked({:call, from}, :push, data), do: {:next_state, :locked, data, [{:reply, from, :lock}]} def unlocked(:cast , :report, _data), do: IO.puts("I am currently unlocked!") && :keep_state_and_data end
  18. What have we learned? • By gathering all state code

    together, we can improve readability and maintainability • By providing two callback modes, we have the flexibility to express our state machine in the way that makes the most sense for our use case • GenStateMachine can reduce some of the boilerplate code that would be necessary when using gen_statem directly
  19. Additionally… • If we enter a digit while the door

    is locked, we should postpone it until the door is locked again (this probably isn’t what you’d actually want to do for a real lock, but humor me) • It would be nice if we had a way to tell the user how many digits are in the code, so they know how many they need to enter Copyright © 1997-2016 Ericsson AB. All Rights Reserved.
  20. defmodule ElectronicLock do # Client def start_link(code), do: Task.start_link(__MODULE__, :init,

    code) def button(lock, digit), do: send lock, {:button, digit} # Server def init(code), do: IO.puts("Locked!") && locked(code, code) def locked(code, [digit | remaining]) do receive do {:button, ^digit} when remaining == [] -> IO.puts("Unlocked!") && open(code) {:button, ^digit} -> locked(code, remaining) {:button, _} -> locked(code, code) end end def open(code), do: receive after: (10_000 -> IO.puts("Locked!") && locked(code, code)) end
  21. iex(1)> {:ok, lock} = ElectronicLock.start_link([1, 2, 3, 4]) Locked! {:ok,

    #PID<0.86.0>} iex(2)> Enum.each([1, 2, 3, 4], &ElectronicLock.button(lock, &1)) Unlocked! :ok iex(3)> Enum.each([1, 2, 3, 4], &ElectronicLock.button(lock, &1)) :ok # After 10 seconds... Locked! Unlocked! # After a final 10 seconds... Locked!
  22. There’s a problem • We cannot respond to any messages

    while we are in the unlocked state, because the receive is blocking • Tasks (aka plain old processes) should not be used for long running processes, because they do not respond to system messages, so we should use gen_* • gen_* does respond to system messages, but cannot use selective receive, because they must control the receive loop in order to respond to system messages
  23. Now, let’s take a look at how we could solve

    the problem with GenStateMachine.
  24. defmodule ElectronicLock do use GenStateMachine # Client def start_link(code), do:

    GenStateMachine.start_link(__MODULE__, code) def button(lock, digit), do: GenStateMachine.cast(lock, {:button, digit}) def code_length(lock), do: GenStateMachine.call(lock, :code_length) … end
  25. defmodule ElectronicLock do … # Server def init(code) do {:ok,

    :locked, %{code: code}, [{:next_event, :internal, :enter}]} end def handle_event(:internal, :enter, :locked, %{code: code} = data), do: IO.puts("Locked!") && {:keep_state, Map.put(data, :remaining, code)} def handle_event(:cast, {:button, digit}, :locked, data) do case data.remaining do [^digit] -> {:next_state, :open, data, [{:next_event, :internal, :enter}]} [^digit | rest] -> {:keep_state, %{data | remaining: rest}} [_ | _] -> {:keep_state, %{data | remaining: data.code}} end end … end
  26. defmodule ElectronicLock do … def handle_event(:internal, :enter, :open, data) do

    timer = :erlang.start_timer(10_000, self(), :lock) IO.puts("Unlocked!") {:keep_state, Map.put(data, :timer, timer)} end def handle_event(:info, {:timeout, timer, :lock}, :open, %{timer: timer} = data), do: {:next_state, :locked, data, [{:next_event, :internal, :enter}]} def handle_event(:cast, {:button, _digit}, :open, _data), do: {:keep_state_and_data, [:postpone]} def handle_event({:call, from}, :code_length, _state, %{code: code}), do: {:keep_state_and_data, [{:reply, from, length(code)}]} end
  27. iex(1)> {:ok, lock} = ElectronicLock.start_link([1, 2, 3, 4]) Locked! {:ok,

    #PID<0.146.0>} iex(2)> Enum.each([1, 2, 3, 4], &ElectronicLock.button(lock, &1)) Unlocked! :ok iex(3)> ElectronicLock.code_length(lock) 4 iex(4)> Enum.each([1, 2, 3, 4], &ElectronicLock.button(lock, &1)) :ok # After 10 seconds... Locked! Unlocked! # After a final 10 seconds... Locked!
  28. What have we learned? • Postponement of events plus internal

    events are a powerful combination • You can emulate the convenience of selective receive in a gen_* process without any of the downsides • OTP is really good for things with keypads
  29. Is gen_statem production ready? Yes, gen_statem is used in the

    implementation of SSL and (D)TLS in OTP.
  30. Is GenStateMachine production ready? I believe so. It has been

    reviewed by the community and has a full test suite.