featured image

FSM o Finite State Machine, lo que podríamos traducir como máquina de estados finitos, es una de las potentes herramientas que nos proporciona OTP para el desarrollo de aplicaciones y soluciones.

Las máquinas de estados finitos se pueden emplear para definir los elementos con los que debe de interactuar una aplicación, y que puede darse el caso de iniciar su existencia (por llamarlo de alguna forma) en un estado definido, y mediante una sucesión ordenada o específica de eventos, ir realizando las transiciones específicas, hasta su finalización.

Actualización 30/06/2021: gen_fsm quedó desfasada hace tiempo y en las nuevas versiones de Erlang fue eliminada. Si quieres saber más sobre su reemplazo gen_statem te recomiendo echarle un vistazo al libro [Erlang/OTP Volumen II: Las Bases de OTP][2]

Una máquina de estados finitos puede ser algo tan simple como un despertador (o sistema de cron) o una transacción bancaria o una llamada telefónica, entre otros.

En este artículo voy a desarrollar un ejemplo para que se vea lo simple que es realizar este tipo de desarrollos en Erlang, usando OTP.

El generador de máquinas de estados

En el conjunto de funciones OTP de Erlang, hay uno específico que recibe el nombre de gen_fsm. Esta es la acortación de generator: finite state machine. Como su nombre indica es un generador. Los generadores, en Erlang/OTP son comportamientos, código base que se emplea, a modo de framework, para desarrollar un comportamiento específico.

Un módulo en Erlang es tan simple como definir la primera línea diciendo el nombre del módulo, especificar la exportación de funciones, y acto seguido definir las funciones. El módulo más pequeño podría constar, sin problemas, de tres líneas de código.

Los generadores son distintos, ya que necesitan de la línea específica que indica de qué comportamiento (behaviour) vamos a tomar el código base, y la definición de todos los callbacks para el código base.

En sí, el gen_fsm es el código base que se mantendrá en ejecución y, ante la llegada de un evento, realizará una llamada a una función específica. Por convención, los callbacks tienen una forma específica, tanto en nombre como en parámetros, y los retornos también están normalizados.

Ejemplo del ascensor

El ejemplo que voy a poner va a ser muy simple. Supongamos que tenemos un ascensor de tres plantas: bajo, primera y segunda. Este ascensor tiene solo dos botones etiquetados como: subir y bajar; para en todas las plantas cada vez. Por tanto, según en la planta que esté, los botones tienen funciones válidas, o no.

Si estamos dentro del ascensor en la primera planta, pulsemos el botón subir o bajar, el ascensor tendrá un estado al que saltar, pero si estamos en la planta baja, al presionar el botón de bajar no tendrá efecto.

Desarrollando la solución

El código, tomando la plantilla del gen_fsm sería bastante simple. Tenemos tres estados y dos eventos por estado. Esto nos hace tener un total de seis funciones a desarrollar:

-module(ascensor).
-behaviour(gen_fsm).

-compile([export_all]). % para simplificar, cambiar por -export().

-record(state, {}).

start_link() ->
    gen_fsm:start_link({local, ?MODULE}, ?MODULE, [], []).

init([]) ->
    {ok, planta_baja, #state{}.

planta_baja(bajar, State) ->
    io:format("Beeep!, opcion incorrecta~n", []),
    {next_state, planta_baja, State};
planta_baja(subir, State) ->
    io:format("Subiendo a la planta primera~n", []),
    {next_state, planta_primera, State}.

planta_primera(bajar, State) ->
    io:format("Bajando a la planta baja~n", []),
    {next_state, planta_baja, State};
planta_primera(subir, State) ->
    io:format("Subiendo a la planta segunda~n", []),
    {next_state, planta_segunda, State}.

planta_segunda(bajar, State) ->
    io:format("Bajando a la planta primera~n", []),
    {next_state, planta_primera, State};
planta_segunda(subir, State) ->
    io:format("Beeep!, opcion incorrecta~n", []),
    {next_state, planta_segunda, State}.

% agregamos funciones para facilitar las llamadas
% estas son opcionales:

boton_subir() ->
    gen_fsm:send_event(?MODULE, subir).

boton_bajar() ->
    gen_fsm:send_event(?MODULE, bajar).

Si se mira la imagen, y la conversión al código, se verá que hay una relación directa entre el esquema que se dibuja y cómo se programa el módulo en sí. Esto hace que sea muy fácil desarrollar un sistema de estados relativo a temas de los que ya comentábamos antes algunos: transferencia bancaria, pago por internet, subasta con varios participantes, una llamada telefónica, un concurso, un videojuego, etc.

Cómo funciona

Las funciones de start_link, y las que se definen abajo, no son realmente del sistema en sí, sino que son facilitadores del trabajo de lanzar la máquina de estados e interactuar con ella.

La máquina de estados, nada más lanzarse, lo primero que hace es ejecutar init. La ejecución de esta función deja un estado marcado como el que está en ejecución, y permite pasar los datos que se mantienen entre llamadas o generación de eventos al FSM.

Una vez está en ejecución, el código base es el que se encarga de llamar a la función con el estado actual en el que se encuentra, cuando se recibe el estado que debe de casar con el que se indica como parámetro.

Ejecutando el código

Una vez escrito el código, podemos entrar en la máquina virtual de Erlang y ejecutarlo sin problemas:

$ erl
[...]
1> c(ascensor). % compila el código.
{ok,ascensor}
2> ascensor:start_link(). % lanzamos la máquina de estados.
{ok,<0.38.0>}
3> ascensor:boton_bajar().
Beeep!, opcion incorrecta
ok
4> ascensor:boton_subir().
Subiendo a la planta primera
ok

La máquina de estados va realizando las llamadas a las funciones, según el estado en el que se encuentre y el evento que se pase.

Agregamos tiempo

Una de las cosas buenas que tiene FSM, es que, no solo afectan los eventos provocados por una llamada, sino también por tiempo, por lo que, en el caso de ejemplo, podemos agregar que, no se mantenga en una misma planta más de 5 segundos, yendo de arriba hacia abajo cada vez, a menos que se indique lo contrario.

Aquí también necesitaremos la memoria para saber, en caso de que vengamos de arriba, que siga hacia abajo, cuando llegue a la planta primera, y viceversa.

Agregar memoria es tan simple como modificar el registro del estado para decirle la dirección que se lleva:

-record(state, {direccion}).

init([]) ->
    {ok, planta_baja, #state{direccion = subir}, 5000}.

El tiempo se mide en milisegundos, con lo que, este código hará que, el ascensor se inicie y, si no llega ningún evento en 5 segundos, el ascensor subirá solo. En este caso se agrega un nuevo evento:

Las nuevas funciones:

planta_baja(bajar, _State) ->
    io:format("Beeep!, opcion incorrecta~n", []),
    {next_state, planta_baja, #state{direccion = subir}, 5000};
planta_baja(subir, _State) ->
    io:format("Subiendo a la planta primera~n", []),
    {next_state, planta_primera, #state{direccion = subir}, 5000};
planta_baja(timeout, _State) ->
    io:format("Subiendo automatico a la planta primera~n", []),
    {next_state, planta_primera, #state{direccion = subir}, 5000}.

planta_primera(bajar, State) ->
    io:format("Bajando a la planta baja~n", []),
    {next_state, planta_baja, State, 5000};
planta_primera(subir, State) ->
    io:format("Subiendo a la planta segunda~n", []),
    {next_state, planta_segunda, State, 5000};
planta_primera(timeout, State) ->
    NextState = case State#state.direccion of
        subir -> 
            io:format("Subiendo a planta segunda~n", []),
            planta_segunda;
        bajar ->
            io:format("Bajando a planta baja~n", []),
            planta_baja
    end,
    {next_state, NextState, State, 5000}.

planta_segunda(bajar, State) ->
    io:format("Bajando a la planta primera~n", []),
    {next_state, planta_primera, State, 5000};
planta_segunda(subir, State) ->
    io:format("Beeep!, opcion incorrecta~n", []),
    {next_state, planta_segunda, State, 5000};
planta_segunda(timeout, _State) ->
    io:format("Bajando automatico a la planta primera~n", []),
    {next_state, planta_primera, #state{direccion = bajar}, 5000}.

Con esto, ya tenemos nuestros tres estados, con los tres eventos posibles, es decir, la programación de las nueve funciones posibles que se pueden dar en ejecución.

En caso de tener muchos más estados, se podría usar el formato handle_event, de modo que el nombre de estado viajaría en modo de parámetro y se podría gestionar mediante código. Los eventos ya lo hacen así, por lo que en este sentido, se pueden unificar u optimizar en caso de ser mucho más numerosos.

Conclusión

Los sistemas FSM para Erlang, nos dan una gran potencia al poder realizar, de forma fácil, un elemento que, en caso de ser consultado o que se generen los eventos desde varios puntos concurrentes, su interfaz hace que su comportamiento sea el esperado en cada momento.