Share
Embed
Transcript
46 slides
-
–Nigerian proverb “In the moment of crisis, the wise build
bridges and the foolish build dams.” -
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 -
What is a state machine? a mathematical model of computation
used to design both computer programs and sequential logic circuits -
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 -
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 -
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 -
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 -
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 -
Pretty cool! ..but few (if any) people are using Elixir
to build turnstiles. -
• Network protocols • Trading systems • Compilers • Databases
• Games • Batch/Stream processing The list goes on and on… -
A lot of these problems are much more complicated than
our turnstile. They’re also pretty common. -
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 -
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 -
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 -
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 -
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? -
Let’s take a look at our turnstile example again, but
this time using GenStateMachine. -
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 -
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 -
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 -
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 -
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. -
Selective receive is often used as a way to implicitly
postpone events. -
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 -
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! -
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 -
Now, let’s take a look at how we could solve
the problem with GenStateMachine. -
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 -
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 -
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 -
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! -
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 -
Is gen_statem production ready? Yes, gen_statem is used in the
implementation of SSL and (D)TLS in OTP. -
Is GenStateMachine production ready? I believe so. It has been
reviewed by the community and has a full test suite.