Estructura Líder/Trabajador en Erlang

Erlang es muy bueno para programación distribuida, y paralela, y concurrente, así mismo se hace muy simple la creación de servidores, tal y como he mostrado en entradas anteriores (servidor UDP y servidor TCP), pero nos queda una tipo de comportamiento que es algo complejo llevar a la práctica. Me refiero al paradigma de Maestro-Esclavo.

La teoría

El paradigma Maestro-Esclavo se basa en que, para acceder a un recurso que está disponible solo desde un punto o solo para un servicio, ya sea por que su acceso es crítico en concurrencia o porque solo se pueda acceder desde un único punto cada vez, se hace necesaria una estructura en la que un único punto sea el responsable de ese acceso, quedando todos los demás supeditados a este.

Esto genera igualmente un problema de cuello de botella, pero la misión no es distribuir, en este caso, para ganar más potencia de procesamiento, sino conseguir: una mayor alta disponibilidad para un recurso accesible solo desde un punto cada vez.

En la terminología de Erlang, la nomenclatura empleada es líder (leader) para referirse al maestro elegido cada vez, y trabajador (worker) para referirse a los esclavos. Esto es así por dos sutiles diferencias que existen entre el modelo clásico de maestro-esclavo frente a este de lider-trabajador:

  • El maestro es siempre uno y se carga con las tareas de replicación y de escritura, el líder, sin embargo, se carga con todas las tareas y no replica nada entre sus trabajadores.
  • El esclavo se encarga de las tareas más frecuentes, en su caso, las lecturas, sin pedir nada al maestro. Si le llega una escritura puede remitirla a su maestro o ignorarla, depende de la implementación. El trabajador, sin embargo, delega todos los trabajos al líder.
  • Cuando un maestro muere, los esclavos quedan atendiendo lecturas, pero el sistema de escritura queda sin servicio, a menos que haya algún mecanismo que permita, por alta disponibilidad, levantar a otro maestro, pero no es la base del paradigma. Cuando un líder muere, en cambio, de entre los trabajadores se elige a un nuevo líder, que se encarga de las peticiones. El esquema está pensado en sí para tener una alta disponibilidad.

Aunque es un comportamiento bastante básico, aún no se ha introducido en la base de Erlang, ya que sus algoritmos de elección han sufrido cambios, incluso hasta hace pocos meses. Supongo que incluso aún habrá algún que otro cambio más, por lo que conviene mantenerse al día. El repositorio oficial (de momento) es este de github, gen_leader_revival de abecciu.

Los autores de la base son Andrew Thompson, Dave Fayram, Hans Svensson y Ulf Wiger.

La implementación

El código podemos descargarlo del repositorio indicado anteriormente, solo necesitaremos el código de gen_leader.erl y el código de skeleton.erl.

La plantilla nos da lo básico para que el gen_leader funcione. Si compilamos ambas y lanzamos desde una consola el skeleton, podemos ver que funciona correctamente. Es más, vamos a modificarlo muy poquito agregando solo impresiones por pantalla para saber en qué momento se ejecuta cada uno:

elected(State, Election, undefined) ->
    Synch = [],
    io:format("no one elected? ~p~n", [Election]),
    {ok, Synch, State};
 
elected(State, Election, Node) ->
    io:format("elected node [~p]: ~p~n", [Node, Election]),
    {reply, [], State}.
 
surrendered(State, Synch, Election) ->
    io:format("surrendered (~p): ~p~n", [Synch, Election]),
    {ok, State}.
 
handle_leader_call(Request, _From, State, Election) ->
    io:format("leader_call (~p): ~p~n", [gen_leader:leader_node(Election), Request]),
    {reply, ok, State}.
 
from_leader(Synch, State, Election) ->
    io:format("from_leader (~p): ~p~n", [gen_leader:leader_node(Election), Synch]),
    {ok, State}.
 
handle_DOWN(Node, State, Election) ->
    io:format("DOWN (~p): ~p~n", [Node, gen_leader:leader_node(Election)]),
    {ok, State}.

El código modificado es llamado como callback cuando se suceden las siguientes situaciones:

  • elected (con nodo undefined), es llamada cuando el nodo es elegido para ser líder, si la variable de Nodo no es undefined, entonces es llamado cuando un nuevo trabajador se ha unido al líder.
  • surrendered, llamada en trabajadores cuando un líder ha sido elegido, o cuando un nuevo nodo entra en el clúster, y se decide de nuevo quién es el líder, siendo llamado elected en el líder elegido y surrendered en el nuevo trabajador.
  • handle_leader_call, esta llamada la realiza quien necesita algo del cluster, o cualquiera de los trabajadores al líder, pero en esencia, solo se procesa en el líder.
  • from_leader, mensajes que se emiten desde el líder hacia los trabajadores. Si la devolución de handle_leader_call es {reply, Reply, Broadcast, State}, se envía el mensaje de Broadcast a todos los trabajadores, con lo que pueden hacer alguna sincronización con su estado interno o realizar la acción que se requiera.
  • handle_DOWN, mensaje de caída recibida por el líder de los nodos que han caído. Cuando cae el líder, un nuevo líder es elegido y se le envía la señal de caída del nodo del antiguo líder.

Teniendo estas características en cuenta, podemos ejecutar el código y ver cómo se comporta:

En lider1@bosqueviejo:

(lider1@bosqueviejo)1> {ok, Pid} = datos:start_link(['lider1@bosqueviejo', 'lider2@bosqueviejo']).
no one elected? {election,<0.55.0>,none,datos,lider1@bosqueviejo,
                          [lider1@bosqueviejo,lider2@bosqueviejo],
                          [],
                          [lider2@bosqueviejo],
                          [],[],none,norm,
                          {1,6,0},
                          [],[],5000,
                          {interval,#Ref<0.0.0.49>},
                          lider2@bosqueviejo,6,1,sender}
{ok,<0.55.0>}

Ahora levantamos en lider2@bosqueviejo:

(lider2@bosqueviejo)1> {ok, Pid} = datos:start_link(['lider1@bosqueviejo', 'lider2@bosqueviejo']).
{ok,<0.55.0>}
surrendered ([]): {election,<6327.55.0>,none,datos,lider1@bosqueviejo,
                            [lider1@bosqueviejo,lider2@bosqueviejo],
                            [],[],
                            [{lider1@bosqueviejo,lider1@bosqueviejo},
                             {#Ref<0.0.0.59>,lider1@bosqueviejo},
                             {#Ref<0.0.0.57>,lider1@bosqueviejo}],
                            [],none,norm,
                            {1,6,0},
                            [],[],5000,undefined,undefined,5,1,sender}

En lider1@bosqueviejo se sucede el siguiente mensaje al levantar al lider2@bosqueviejo:

elected node [lider2@bosqueviejo]: {election,<0.55.0>,none,datos,
                                       lider1@bosqueviejo,
                                       [lider1@bosqueviejo,lider2@bosqueviejo],
                                       [],[],
                                       [{#Ref<0.0.0.74>,lider2@bosqueviejo}],
                                       [],none,norm,
                                       {1,6,0},
                                       [],[],5000,
                                       {interval,#Ref<0.0.0.49>},
                                       lider2@bosqueviejo,6,1,sender}

Ahora en lider1@bosqueviejo enviamos un mensaje:

(lider1@bosqueviejo)2> gen_leader:leader_call(Pid, "hola").
leader_call (lider1@bosqueviejo): "hola"
ok

Y lo que pasaba al mismo tiempo en lider2@bosqueviejo:

from_leader (lider1@bosqueviejo): [118,117,101,115,116,114,111,32,108,105,100,
                                   101,114,32,100,105,99,101,58,32,"hola",
                                   "\n"]

En caso de que salgamos de la consola del lider1@bosqueviejo vemos lo siguiente en lider2@bosqueviejo:

no one elected? {election,<0.55.0>,<6327.55.0>,datos,lider2@bosqueviejo,
                          [lider1@bosqueviejo,lider2@bosqueviejo],
                          [],
                          [lider1@bosqueviejo],
                          [{#Ref<0.0.0.59>,lider1@bosqueviejo},
                           {#Ref<0.0.0.57>,lider1@bosqueviejo}],
                          [],none,norm,
                          {2,5,1},
                          [],[],5000,
                          {interval,#Ref<0.0.0.75>},
                          lider2@bosqueviejo,5,2,sender}
DOWN (lider1@bosqueviejo): lider2@bosqueviejo

Es decir, se elige a un nuevo líder y se envía el mensaje de caída del líder.

Conclusiones

Este esquema es bastante útil ya que simplifica el problema de tener varios servidores, por ejemplo, y recursos que solo pueden estar disponibles en un solo servidor cada vez. Por ejemplo, el acceso a un recurso propietario que solo nos da una licencia de uso, el acceso con control de concurrencia a un recurso.

Su interfaz de broadcast, permite que el sistema pueda recibir peticiones de lectura y escritura, haciendo que su broadcast deje la información en cada worker para, en caso de que cayese el líder, puedan seguir funcionando sin problema alguno.

Realmente, otro elemento más para facilitar la creación de sistemas servidores de alta disponibilidad en Erlang.