Capítulo 4. Servidores genéricos
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
Después de haber descompuesto el asignador de radiofrecuencias en módulos genéricos y específicos y de haber investigado algunos de los casos de esquina que pueden darse al tratar con la concurrencia, te habrás dado cuenta de que no es necesario pasar por este proceso cada vez que tengas que implementar un comportamiento cliente-servidor. En este capítulo, presentamos el comportamiento gen_server
OTP, un módulo de la biblioteca que contiene toda la funcionalidad genérica cliente-servidor, a la vez que maneja un gran número de casos de esquina. Los servidores genéricos son el patrón de comportamiento más utilizado, sentando las bases para otros comportamientos, todos los cuales pueden ser (y en los primeros días de OTP lo fueron) implementados utilizando este módulo.
Servidores genéricos
El módulo gen_server
implementa el comportamiento cliente-servidor que extrajimos en el capítulo anterior. Forma parte de la aplicación de la biblioteca estándar y está disponible como parte de la distribución Erlang/OTP. Contiene el código genérico que interactúa con el módulo de devolución de llamada a través de un conjunto de funciones de devolución de llamada. El módulo de devolución de llamada, que en nuestro ejemplo contiene el código específico del servidor de frecuencias, es implementado por el programador. El módulo de devolución de llamada tiene que exportar una serie de funciones que sigan las convenciones de denominación y tipado, de modo que sus entradas y valores de retorno se ajusten al protocolo requerido por el comportamiento.
Como se ve en la Figura 4-1, las funciones del módulo de comportamiento y del módulo de devolución de llamada se ejecutan dentro del ámbito el mismo proceso servidor. En otras palabras, un proceso hace un bucle en el módulo servidor genérico, invocando las funciones de llamada de retorno del módulo de llamada de retorno según sea necesario.
El módulo de biblioteca gen_server
proporciona funciones para iniciar y detener el servidor. Proporcionas código de llamada de retorno para inicializar el sistema, y en caso de terminación normal o anormal del proceso, es posible llamar a una función de tu módulo de llamada de retorno para limpiar el estado antes de la terminación. En concreto, ya no necesitas enviar mensajes a tu proceso. Los servidores genéricos encapsulan todo el paso de mensajes en dos funciones: una para enviar mensajes síncronos y otra para enviar mensajes asíncronos. Éstas se encargan de todos los casos límite de los que hablamos en el capítulo anterior, y de muchos otros de los que probablemente ni siquiera nos habíamos dado cuenta de que podían ser un problema o causar una condición de carrera. También hay una funcionalidad integrada para las actualizaciones de software, que te permite suspender el proceso y migrar los datos de una versión del sistema a la siguiente. Los servidores genéricos también proporcionan tiempos de espera, tanto en el lado del cliente al enviar solicitudes, como en el lado del servidor cuando no se reciben mensajes en un intervalo de tiempo predeterminado.
Ahora cubrimos todas las funciones de llamada de retorno necesarias cuando se utilizan servidores genéricos. Entre ellas están:
La función de devolución de llamada
init/1
inicializa un proceso servidor creado por la llamadagen_server:start_link/4
.La función de devolución de llamada
handle_call/3
gestiona las peticiones síncronas enviadas al servidor porgen_server:call/2
. Cuando se ha gestionado la solicitud,call/2
devuelve un valor calculado porhandle_call/3
.Las peticiones asíncronas se atienden en la función de devolución de llamada
handle_cast/2
. Las peticiones se originan en la llamadagen_server:cast/2
, que envía un mensaje a un proceso servidor y vuelve inmediatamente.La finalización se gestiona cuando cualquiera de las funciones de llamada de retorno del servidor devuelve un mensaje de parada, lo que hace que se llame a a la función de llamada de retorno
terminate/2
.
Examinaremos estas funciones con más detalle, incluyendo todos sus argumentos, valores de retorno y llamadas de retorno asociadas, en cuanto hayamos cubierto las directivas del módulo.
Directrices de comportamiento
Cuando estamos implementando un comportamiento OTP, necesitamos incluir directivas de comportamiento en las declaraciones de nuestros módulos.
-
module
(
frequency
)
.
-
behavior
(
gen_server
)
.
-
export
(
[
start_link
/
1
,
init
/
1
,
.
.
.
]
)
.
start_link
(
.
.
.
)
-
>
.
.
.
La directiva de comportamiento es utilizada por el compilador para emitir advertencias sobre funciones de retrollamada no definidas, no exportadas o definidas con una aridad incorrecta. La herramienta dializador también utiliza estas declaraciones para comprobar discrepancias de tipo. Un uso aún más importante de la directiva de comportamiento es para las pobres almas1 que tienen que dar soporte, mantener y depurar tu código mucho después de que tú hayas pasado a otros proyectos emocionantes y estimulantes. Verán estas directivas e inmediatamente sabrán que has estado utilizando los patrones genéricos del servidor. Si quieren ver la inicialización del servidor, irán a a la función init/1
. Si quieren ver cómo se limpia el servidor, van de a terminate/3
. Esto supone una gran mejora con respecto a una situación en la que cada empresa, proyecto o desarrollador reinventa sus propias implementaciones cliente-servidor, posiblemente defectuosas. No se pierde tiempo entendiendo este marco, lo que permite a quien lee el código concentrarse en lo específico.
En nuestro código de ejemplo, las advertencias del compilador se producen como resultado de la directiva -behavior(gen_server).
porque omitimos la función code_change/3
, una llamada de retorno que cubrimos en el Capítulo 12 al hablar de las actualizaciones de versiones. Además de esta directiva, a veces utilizamos una segunda directiva opcional , -vsn(Version)
, para hacer un seguimiento de las versiones de los módulos durante la actualización (y degradación) del código. Hablaremos de las versiones con más detalle en el Capítulo 12.
Iniciar un servidor
Con el conocimiento de nuestras directivas de módulo, vamos a iniciar un servidor. Los servidores genéricos y otros comportamientos OTP no se inician con los BIF de generación, sino con funciones dedicadas que hacen algo más entre bastidores que simplemente generar un proceso:
gen_server
:
start_link
(
{
local
,
Name
}
,
Mod
,
Args
,
Opts
)
-
>
{
ok
,
Pid
}
|
ignore
|
{
error
,
Reason
}
La función start_link/4
toma cuatro argumentos. El primero le dice al módulo gen_server
que registre el proceso localmente con el alias Name
. Mod
es el nombre del módulo de llamada de retorno, donde se encontrarán el código específico del servidor y las funciones de llamada de retorno. Args
es un término Erlang pasado a la función de llamada de retorno que inicializa el estado del servidor. Opts
es una lista de opciones de proceso y depuración que cubrimos en el Capítulo 5. Por el momento, vamos a mantenerlo simple y pasar la lista vacía para Opts
. Si ya hay un proceso registrado con el alias Name
, se devuelve {error,
{already_started, Pid}}
. Vigila qué proceso ejecuta qué funciones. Puedes fijarte en ellos en la Figura 4-2, donde el servidor vinculado al proceso Pid
es iniciado por el supervisor. El supervisor se denota con un doble anillo, ya que está atrapando salidas.
Cuando se ha generado el proceso gen_server
, se registra con el alias Name
, llamando posteriormente a la función init/1
en el módulo de llamada de retorno Mod
. La función init/1
toma Args
, el tercer parámetro de la llamada a start_link
, como argumento, independientemente de si es necesario. Si no se necesitan argumentos, la función init/1
puede ignorarlos con la variable don't care. Ten en cuenta que Args
puede ser cualquier término válido de Erlang; no estás obligado a utilizar listas.
Nota
Si Args
es una lista (posiblemente vacía), la lista se pasará a init/1
como una lista, y no dará lugar a que se llame a un init
de aridad diferente. Por ejemplo, si pasas [foo, bar]
, se llamará a init([foo,bar])
, no a init(foo, bar)
. Éste es un error común que cometen los desarrolladores al pasar de Erlang a OTP, ya que confunden las propiedades de spawn
y spawn_link
con las de las funciones de comportamiento start
y start_link
.
La función de devolución de llamada init/1
se encarga de inicializar el estado del servidor. En nuestro ejemplo, esto implica crear la variable que contiene las listas de frecuencias disponibles y asignadas:
start
()
->
% frequency.erl
gen_server
:
start_link
({
local
,
frequency
},
frequency
,
[],
[]).
init
(_
Args
)
->
Frequencies
=
{
get_frequencies
(),
[]},
{
ok
,
Frequencies
}.
get_frequencies
()
->
[
10
,
11
,
12
,
13
,
14
,
15
].
Si tiene éxito, la función de devolución de llamada init/1
devuelve {ok, LoopData}
. Si el inicio falla pero no quieres afectar a otros procesos iniciados por el mismo supervisor, devuelve ignore
. Si quieres afectar a otros procesos, devuelve {stop, Reason}
. Trataremos ignore
en el Capítulo 8y stop
en "Terminación".
En nuestro ejemplo, start_link/4
pasa la lista vacía []
a init/1
, que a su vez utiliza la variable _Args
don't care para ignorarla. Podríamos haber pasado cualquier otro término Erlang, siempre que dejemos claro a quien lea el código que no se necesitan argumentos. El átomo undefined
o la tupla vacía {}
son otros de nuestros favoritos.
Al pasar {timeout, Ms}
como opción en la lista Opts
, permitimos que nuestro servidor genérico Ms
tarde milisegundos en iniciarse. Si tarda más, start_link/4
devuelve la tupla {error, timeout}
y no se inicia el proceso de comportamiento. No se lanza ninguna excepción. Trataremos las opciones con más detalle en el Capítulo 5.
Iniciar un proceso de comportamiento de servidor genérico es una operación sincrónica. Sólo cuando la función de devolución de llamada init/1
devuelve {ok,
LoopData}
al bucle del servidor, la función gen_server:start_link/4
devuelve {ok,
Pid}
. Es importante comprender la naturaleza sincrónica de start_link
y su importancia para una secuencia de inicio repetible. La capacidad de reproducir determinísticamente un error es importante a la hora de solucionar problemas que se producen en el arranque. Podrías iniciar asíncronamente todos los procesos, comprobando después cada uno de ellos para asegurarte de que todos se iniciaron correctamente. Pero como resultado de cambiar las implementaciones del programador y los valores de configuración ejecutándose en arquitecturas multinúcleo, desplegándose en hardware o sistemas operativos diferentes, o incluso el estado de la conectividad de red, los procesos no necesariamente inicializarían siempre su estado y completarían la secuencia de arranque en el mismo orden. Si todo va bien, no tendrás problemas con la variabilidad inherente a un enfoque de inicio asíncrono, pero si se manifiestan condiciones de carrera, intentar averiguar qué ha ido mal y cuándo, especialmente en entornos de producción, no es para los débiles de corazón. El enfoque de arranque síncrono implementado en start_link
garantiza claramente, por su simplicidad, que cada proceso se ha iniciado correctamente antes de pasar al siguiente, proporcionando determinismo y errores de arranque reproducibles en un solo nodo. Si los errores de inicio están influidos por factores externos, como redes, bases de datos externas o el estado del hardware o SO subyacentes, intenta contenerlos. En los casos en que el determinismo no ayude, un procedimiento de arranque controlado elimina cualquier elemento de duda sobre dónde puede estar el problema .
Paso de mensajes
Una vez que ha iniciado nuestro servidor genérico y ha inicializado sus datos de bucle, ahora veremos cómo funciona la comunicación. Como habrás comprendido en el capítulo anterior, enviar mensajes utilizando el operador !
está pasado de moda. OTP utiliza interfaces funcionales que proporcionan un mayor nivel de abstracción. El módulo gen_server
exporta funciones que nos permiten enviar mensajes tanto síncronos como asíncronos, ocultando al programador la complejidad de la programación concurrente y la gestión de errores.
Paso síncrono de mensajes
Aunque Erlang incorpora el paso de mensajes asíncronos como parte del lenguaje, nada nos impide implementar llamadas síncronas utilizando las primitivas existentes. Esto es lo que hace la función gen_server:call/2
. Envía un Message
síncrono al servidor y espera un Reply
mientras el servidor gestiona la petición en una función de devolución de llamada. El Reply
se pasa como valor de retorno a la llamada. El mensaje y la respuesta siguen un protocolo específico y contienen una etiqueta única (o referencia), que coincide con el mensaje y la respuesta. Veamos la función gen_server:call/2
con más detalle:
gen_server
:
call
(
Name
,
Message
)
-
>
Reply
Name
es el pid del servidor o el nombre registrado del proceso del servidor. El Message
es un término Erlang que se reenvía como parte de la solicitud al servidor. Las peticiones se reciben como mensajes Erlang, se almacenan en el buzón y se gestionan secuencialmente. Al recibir una solicitud sincrónica , se invoca la función de devolución de llamada handle_call(Message, _From,
LoopData)
en el módulo de devolución de llamada. El primer argumento es el valor Message
pasado a gen_server:call/2
. El segundo argumento, _From
, contiene un identificador único de solicitud e información sobre el cliente. De momento lo ignoraremos, vinculándolo a una variable don't care. El tercer argumento es el LoopData
devuelto originalmente por la función de llamada de retorno init/1
. Deberías poder seguir el flujo de la llamada en la Figura 4-3.
La función de devolución de llamada handle_call/3
contiene todo el código necesario para gestionar la solicitud. Es una buena práctica tener una cláusula handle_call/3
separada para cada solicitud y utilizar la concordancia de patrones para elegir la correcta, en lugar de utilizar una sentencia case
para señalar los mensajes individuales. En la cláusula de función, ejecutaríamos todo el código de esa solicitud concreta y, cuando terminemos, devolveremos una tupla con el formato {reply, Reply, NewLoopData}
. Un módulo de devolución de llamada utiliza el átomo reply
para indicar a gen_server
que el segundo elemento, Reply
, debe enviarse de vuelta al proceso cliente, convirtiéndose en el valor de retorno de la petición gen_server:call/2
. El tercer elemento, NewLoopData
, es el nuevo estado del módulo de devolución de llamada, que el gen_server
pasa a la siguiente iteración de su bucle recursivo de recepción-evaluación. Si LoopData
no cambia en el cuerpo de la función, simplemente devolvemos el valor original en la tupla de respuesta. El gen_server
se limita a almacenarlo sin inspeccionarlo ni manipular su contenido. Una vez que devuelve la tupla de respuesta al cliente, el servidor está preparado para gestionar la siguiente solicitud. Si no hay mensajes en cola en el buzón de procesos, el servidor queda suspendido a la espera de que llegue una nueva solicitud.
En nuestro ejemplo del servidor de frecuencias , la asignación de una frecuencia necesita una llamada sincrónica porque la respuesta a la llamada debe contener la frecuencia asignada. Para gestionar la petición, llamamos a la función interna allocate/2
, que recordarás que devuelve {NewFrequencies, Reply}
. NewFrequencies
es la tupla que contiene las listas de frecuencias asignadas y disponibles, mientras que Reply
es la tupla {ok, Frequency}
o {error, no_frequency}
:
allocate
()
->
% frequency.erl
gen_server
:
call
(
frequency
,
{
allocate
,
self
()}).
handle_call
({
allocate
,
Pid
},
_
From
,
Frequencies
)
->
{
NewFrequencies
,
Reply
}
=
allocate
(
Frequencies
,
Pid
),
{
reply
,
Reply
,
NewFrequencies
}.
Una vez completada, la función allocate/0
llamada por el proceso cliente devuelve {ok, Frequency}
o {error,
no_frequency}
. Los datos actualizados del bucle que contienen las frecuencias disponibles y asignadas se almacenan en el bucle de recepción-evaluación del servidor genérico a la espera de la siguiente petición .
Paso asíncrono de mensajes
Si el cliente necesita enviar un mensaje al servidor pero no espera respuesta, puede utilizar peticiones asíncronas. Esto se hace utilizando la función de la biblioteca gen_server:cast/2
:
gen_server
:
cast
(
Name
,
Message
)
-
>
ok
Name
es el pid o el alias registrado localmente del proceso servidor. Message
es el término que el cliente quiere enviar al servidor. En cuanto la llamada cast/2
ha enviado su solicitud, devuelve el átomo ok
. En el lado del servidor, la solicitud se almacena en el buzón del proceso y se gestiona secuencialmente. Cuando se recibe, la Message
se pasa a la función de handle_cast/2
función de devolución de llamada, implementada por el desarrollador en el módulo de devolución de llamada.
La función de devolución de llamada handle_cast/2
toma dos argumentos. El primero es el Message
enviado por el cliente, mientras que el segundo es el LoopData
devuelto previamente por las llamadas de retorno init/1
, handle_call/3
o handle_cast/2
. Esto puede verse en la Figura 4-4.
La función de devolución de llamada handle_cast/2
tiene que devolver una tupla del formato {noreply, NewLoopData}
. El NewLoopData
se pasará como argumento a la siguiente llamada o petición de reparto.
En algunas aplicaciones, las funciones cliente devuelven un valor codificado, a menudo el átomo ok
, dependiendo de los efectos secundarios ejecutados en el módulo de devolución de llamada. Tales funciones podrían implementarse como llamadas asíncronas. En nuestro ejemplo de frecuencia, ¿te has dado cuenta de que frequency:deallocate(Freq)
siempre devuelve el átomo ok
? En realidad, no nos importa si la gestión de la solicitud se retrasa porque el servidor está ocupado con otras llamadas, lo que lo convierte en un candidato perfecto para un ejemplo que utilice un reparto genérico del servidor:
deallocate
(
Frequency
)
->
% frequency.erl
gen_server
:
cast
(
frequency
,
{
deallocate
,
Frequency
}).
handle_cast
({
deallocate
,
Freq
},
Frequencies
)
->
NewFrequencies
=
deallocate
(
Frequencies
,
Freq
),
{
noreply
,
NewFrequencies
};
La función cliente deallocate/1
envía una petición asíncrona al servidor genérico y devuelve inmediatamente el átomo ok
. Esta solicitud es recogida por la función handle_cast/2
que empareja el patrón del mensaje {deallocate, Frequency}
en el primer argumento y vincula los datos del bucle a Frequencies
en el segundo. En el cuerpo de la función, llama a la función de ayuda deallocate/2
, moviendo Frequency
de la lista de frecuencias asignadas a la lista de frecuencias disponibles. El valor de retorno de deallocate/2
se vincula a la variable NewFrequencies
, devuelta como los nuevos datos del bucle en la tupla de control noreply
.
Ten en cuenta que dijimos que sólo en algunas aplicaciones las funciones cliente ignoran los valores de retorno de las funciones servidor con efectos secundarios. Hacer ping a un servidor para asegurarse de que está vivo, por ejemplo, dependería de que gen_server:call/2
lanzara una excepción si el servidor hubiera terminado o si hubiera un retraso, posiblemente como consecuencia de una gran carga, en la gestión de la petición y el envío de la respuesta. Otro ejemplo en el que se utilizan las llamadas síncronas es cuando existe la necesidad de estrangular las peticiones y controlar el ritmo al que se envían los mensajes al servidor. Hablaremos de la necesidad de estrangular los mensajes en el Capítulo 15.
Al igual que en Erlang puro, las llamadas y los lanzamientos deben abstraerse en una API funcional si se utilizan desde fuera del módulo. Esto te da mayor flexibilidad para cambiar tu protocolo y ocultar información privada relacionada con la implementación a quien llama a la función. Coloca las funciones cliente en el mismo módulo que el proceso, ya que así es más fácil seguir el flujo de mensajes sin saltar entre módulos.
Otros mensajes
Los comportamientos OTP se implementan en como procesos Erlang. Así que, aunque lo ideal sería que la comunicación se produjera a través de los protocolos definidos en las funciones gen_server:call/2
y gen_server:cast/2
, no siempre es así. Mientras se conozca el pid o el nombre registrado, nada impide que un usuario envíe un mensaje utilizando la construcción Name ! Message
. En algunos casos, los mensajes Erlang son la única forma de hacer llegar información al servidor genérico. Por ejemplo, si el servidor está vinculado a otros procesos o puertos, pero ha llamado al BIF process_flag(trap_exit, true)
para atrapar salidas de esos procesos o puertos, podría recibir mensajes de la señal EXIT
. Además, la comunicación entre procesos y puertos o sockets se basa en el paso de mensajes. Y por último, ¿qué ocurre si estamos utilizando un monitor de procesos, monitorizando nodos distribuidos o comunicándonos con código heredado no compatible con OTP?
Todos estos ejemplos hacen que nuestro servidor reciba mensajes Erlang que no cumplen el protocolo interno de mensajería OTP del servidor. Conforme o no, si utilizas funciones que pueden generar mensajes a tu servidor, entonces tu código de servidor tiene que ser capaz de manejarlos. Los servidores genéricos proporcionan una función de llamada de retorno que se encarga de todos estos mensajes. Es la llamada de retorno handle_info(_Msg,
LoopData)
. Cuando se llama, tiene que devolver la tupla {noreply, NewLoopData}
o, cuando se detiene, {stop, Reason, NewLoopData}
:
handle_info
(_
Msg
,
LoopData
)
->
% frequency.erl
{
noreply
,
LoopData
}.
Es una práctica habitual, incluso si no esperas ningún mensaje, incluir esta función de llamada de retorno. No hacerlo y enviar al servidor un mensaje no compatible con OTP (¡llegan cuando menos te lo esperas!) provocaría un error de ejecución y la terminación del servidor, ya que se llamaría a la función handle_info/2
en el módulo de llamada de retorno, lo que provocaría un error de función no definida.
Hemos simplificado nuestro ejemplo de servidor de frecuencia. Ignoramos cualquier mensaje que entre, devolviendo el LoopData
sin cambios en la tupla noreply
. Si estás seguro de que no deberías recibir mensajes que no sean OTP, podrías registrar dichos mensajes como errores. Si quisiéramos imprimir un mensaje de error cada vez que un proceso al que está vinculado el servidor terminara de forma anormal, el código sería así (suponemos que el servidor en cuestión atrapa salidas):
handle_info
({
'EXIT'
,
_
Pid
,
normal
},
LoopData
)
->
{
noreply
,
LoopData
};
handle_info
({
'EXIT'
,
Pid
,
Reason
},
LoopData
)
->
io
:
format
(
"Process:
~p
exited with reason:
~p~n
"
,[
Pid
,
Reason
]),
{
noreply
,
LoopData
};
handle_info
(_
Msg
,
LoopData
)
->
{
noreply
,
LoopData
}.
Advertencia
Una de las desventajas de OTP es la sobrecarga resultante de la superposición de los distintos módulos de comportamiento y la sobrecarga de datos requerida por el protocolo de comunicación. En un intento de recortar unos microsegundos en sus llamadas, se sabe que los desarrolladores se saltan la función gen_server:cast
y utilizan en su lugar la construcción Pid ! Msg
o, peor aún, incrustan receive
en sus funciones de llamada de retorno para recibir estos mensajes. No lo hagas. Harás que tu código sea difícil de depurar, soportar y mantener, perderás muchas de las ventajas que aporta OTP y conseguirás que dejes de gustar a los autores de este libro. Si necesitas recortar microsegundos, optimiza sólo cuando sepas por mediciones reales de rendimiento que tu programa no es lo suficientemente rápido.
Mensajes no gestionados
Erlang utiliza la recepción selectiva cuando recupera mensajes del buzón del proceso. Pero permitirnos extraer ciertos mensajes mientras dejamos otros sin tratar conlleva el riesgo de fugas de memoria. ¿Qué ocurre si un tipo de mensaje no se lee nunca? Utilizando Erlang sin OTP, la cola de mensajes se haría cada vez más larga, aumentando el número de mensajes que hay que recorrer antes de que uno coincida con éxito con el patrón. Este crecimiento de la cola de mensajes se manifestará en la VM Erlang a través de un elevado uso de la CPU como resultado del recorrido del buzón, y de que la VM acabará por quedarse sin memoria y posiblemente tenga que reiniciarse a través del corazón, de lo que hablamos en el Capítulo 11.
Todo esto es válido si utilizamos Erlang puro, pero los comportamientos OTP adoptan un enfoque diferente. Los mensajes se manejan en el mismo orden en que se reciben. Pon en marcha tu servidor de frecuencia, e intenta enviarte un mensaje que no estés manejando:
1>frequency:start().
{ok,<0.33.0>} 2>gen_server:call(frequency, foobar).
=ERROR REPORT==== 29-Nov-2015::18:27:45 === ** Generic server frequency terminating ** Last message in was foobar ** When Server state == {data,[{"State", {{available,[10,11,12,13,14,15]}, {allocated,[]}}}]} ** Reason for termination == ** {function_clause,[{frequency,handle_call, [foobar, {<0.44.0>,#Ref<0.0.4.112>}, {[10,11,12,13,14,15],[]}], [{file,"frequency.erl"},{line,63}]}, {gen_server,try_handle_call,4, [{file,"gen_server.erl"},{line,629}]}, {gen_server,handle_msg,5, [{file,"gen_server.erl"},{line,661}]}, {proc_lib,init_p_do_apply,3, [{file,"proc_lib.erl"},{line,240}]}]}
Probablemente esto no es lo que esperabas . El servidor de frecuencia terminó con un error de ejecución function_clause
, imprimiendo un informe de error.2 Cuando llamas a una función, una de las cláusulas siempre tiene que coincidir. Si no lo haces, se produce un error de ejecución. Cuando se realiza una llamada o un reparto a gen_server
, siempre se recupera el mensaje del buzón en el bucle del servidor genérico, y se invoca a la función de devolución de llamada handle_call/3
o handle_cast/2
. En nuestro ejemplo, handle_call(foobar, _From,
LoopData)
no coincide con ninguna de las cláusulas, lo que provoca el error de cláusula de función que acabamos de ver. Lo mismo ocurriría con un reparto.
¿Cómo evitamos estos errores? Una opción es tener un cajón de sastre, en el que los mensajes desconocidos se asocien a una variable "don't care" y se ignoren. Esto es específico de la aplicación, y puede o no ser la respuesta. Un catch-all podría ser la norma con la llamada de retorno handle_info/2
cuando se trata de puertos, sockets, enlaces, monitores y monitoreo de nodos distribuidos en los que existe el riesgo de olvidarse de gestionar un mensaje concreto que la aplicación no necesita. Sin embargo, cuando se trate de llamadas y lanzamientos, todas las solicitudes deben originarse en el módulo de devolución de llamada del comportamiento y cualquier mensaje desconocido debe detectarse en las primeras fases de las pruebas.
En caso de duda, no te pongas a la defensiva y haz que tu servidor termine cuando reciba mensajes desconocidos. Trata estas terminaciones como errores, y gestiona los mensajes o corrígelos en su origen. Si decides ignorar los mensajes desconocidos, no olvides registrarlos en .
Sincronizar clientes
¿Qué ocurre en una situación en la que dos clientes envían cada uno una solicitud síncrona a un servidor, pero en lugar de responder inmediatamente a cada uno por separado, el servidor tiene que esperar a ambas solicitudes antes de responder a la primera? Lo demostramos en la Figura 4-5. Esto podría hacerse por motivos de sincronización o porque el servidor necesita los datos de ambas peticiones.
La solución a este problema es sencilla. ¿Recuerdas el campo From
en la función de handle_call(Message, From, State)
función de devolución de llamada? En lugar de devolver una respuesta al bucle de comportamiento, devolvemos {noreply, NewState}
. A continuación, utilizamos el atributo From
y la función
gen_server
:
reply
(
From
,
Reply
)
para luego devolver la respuesta al cliente cuando nos convenga. En el caso de tener que sincronizar dos clientes, podría ser en la segunda llamada de retorno a handle_call/3
, donde el valor de From
para el primer cliente se almacena entre las llamadas, bien como parte de NewState
o en una tabla o base de datos.
También puedes utilizar reply/2
si una solicitud sincrónica desencadena un cálculo que requiere mucho tiempo y la única respuesta que le interesa al cliente es un acuse de recibo de que la solicitud se ha recibido y está en proceso de cumplimentarse, sin tener que esperar a que se complete todo el cálculo. Para enviar un acuse de recibo inmediato, se puede utilizar la llamada a gen_server:reply/2
en la propia llamada de retorno:
handle_call
({
add
,
Data
},
From
,
Sum
)
->
gen_server
:
reply
(
From
,
ok
),
timer
:
sleep
(
1000
),
NewSum
=
add
(
Data
,
Sum
),
io
:
format
(
"From:
~p
, Sum:
~p~n
"
,[
From
,
NewSum
]),
{
noreply
,
NewSum
}.
Ejecutemos este código, suponiendo que se trata de un servidor genérico implementado en el módulo de devolución de llamada from
. La llamada timer:sleep/1
suspenderá el proceso , permitiendo que el proceso shell gestione la respuesta de gen_server:reply/2
antes de la llamada io:format/2
:
1>gen_server:start({local, from}, from, 0, []).
{ok,<0.53.0>} 2>gen_server:call(from, {add, 10}).
ok From:{<0.55.0>,#Ref<0.0.3.248>}, Sum:10
Observa el valor y el formato del argumento From
que estamos imprimiendo en el intérprete de comandos. Es una tupla que contiene el pid del cliente y una referencia única. Esta referencia se utiliza en una etiqueta con la respuesta enviada de vuelta al cliente, asegurando que es de hecho la respuesta prevista, y no un mensaje conforme al protocolo enviado desde otro proceso. Utiliza siempre From
como un tipo de datos opaco; no asumas que es una tupla, ya que su representación podría cambiar en futuras versiones de .
Terminación
¿Y si queremos detener un servidor genérico? Hasta ahora, hemos visto que las funciones de llamada de retorno init/1
, handle_call/3
, y handle_cast/2
devuelven {ok, LoopData}
, {reply, Reply, LoopData}
, y {noreply,
LoopData}
, respectivamente. Detener el servidor requiere que las llamadas de retorno devuelvan tuplas diferentes:
init/1
puede devolver{stop, Reason}
handle_call/3
puede devolver{stop, Reason, Reply, LoopData}
handle_cast/2
puede devolver{stop, Reason, LoopData}
handle_info/2
puede devolver{stop, Reason, LoopData}
Estos valores de retorno terminan con el mismo comportamiento que si se llamara a exit(Reason)
. En el caso de las llamadas y los lanzamientos, antes de salir, se llama a la función de devolución de llamada terminate(Reason, LoopData)
. Permite al servidor limpiarse antes de apagarse. Cualquier valor devuelto por terminate/2
se ignora. En el caso de init
, stop
debe ser devuelto si algo falla al inicializar el estado. En consecuencia, terminate/2
no será llamado. Si devolvemos {stop,
Reason}
en la llamada de retorno init/1
, la función start_link
devuelve {error,
Reason}
.
En nuestro ejemplo del servidor de frecuencias, la función cliente stop/0
envía un mensaje asíncrono al servidor. Al recibirlo, la llamada de retorno handle_cast/2
devuelve la tupla con el átomo de control stop
, que a su vez hace que se invoque la llamada terminate/2
. Echa un vistazo al código:
stop
()
->
gen_server
:
cast
(
frequency
,
stop
).
% frequency.erl
handle_cast
(
stop
,
LoopData
)
->
{
stop
,
normal
,
LoopData
}.
terminate
(_
Reason
,
_
LoopData
)
->
ok
.
Para simplificar el ejemplo, hemos dejado terminate
vacía. En un mundo ideal, probablemente habríamos matado a todos los procesos cliente que tuvieran asignada una frecuencia, terminando así las tareas que utilizaran esas frecuencias y asegurándonos de que, al reiniciarse, todas las frecuencias estuvieran disponibles.
Fíjate en el mensaje que gen_server:cast/2
envía al servidor de frecuencia. Observarás que es el átomo stop
, cuyo patrón coincide en el primer argumento de la llamada a handle_cast/2
. El mensaje no tiene más significado que el que le damos en nuestro código. Podríamos haber enviado cualquier átomo, como gen_server:cast(frequency,
donald_duck)
. La coincidencia de patrón donald_duck
en la llamada handle_cast/2
nos habría dado el mismo resultado. El único stop
que tiene un significado especial es el que aparece en el primer elemento de la tupla devuelta por handle_cast/2
, tal y como se interpreta en el bucle recibir-evaluar del servidor genérico.
Si vas a apagar tu servidor como parte de tu flujo de trabajo normal (por ejemplo, el socket que maneja se ha cerrado, o el hardware que controla y monitoriza se está apagando), es una buena práctica configurar tu Reason
en normal
. Una razón que no seanormal
, aunque es perfectamente aceptable, hará que el registrador SASL registre informes de error. Estas entradas podrían eclipsar las de las caídas reales. (El registrador SASL es otro regalo que obtienes al utilizar OTP. Lo cubrimos en el Capítulo 9).
Aunque los servidores pueden detenerse normalmente devolviendo la tupla stop
, puede darse el caso de que terminen como consecuencia de un error en tiempo de ejecución. En estos casos, si el servidor genérico está atrapando salidas (por haber llamado a la BIF process_flag(trap_exit,
true)
), también se llamará a terminate/2
, como se muestra en la Figura 4-6. Si no está atrapando salidas, el proceso simplemente terminará sin llamar a terminate/2
.
Si quieres que la función terminate/2
se ejecute después de terminaciones anormales, tienes que activar la bandera trap_exit
. Si no se establece, un supervisor o un proceso vinculado podría hacer caer el servidor sin permitir que se limpie.
Dicho esto, comprueba siempre el contexto de finalización. Si se ha producido un error de ejecución, limpia el estado del servidor con sumo cuidado, ya que podrías acabar corrompiendo tus datos y preparar así tu sistema para más errores de ejecución tras el reinicio del servidor. Al reiniciar, debes procurar recrear el estado del servidor a partir de fuentes de datos correctas (y únicas), no de una copia que almacenaste justo antes del fallo, ya que podría haberse corrompido por el mismo fallo que causó el fallo.
Tiempos de espera de llamada
Cuando envías mensajes síncronos a tu servidor mediante una llamada a gen_server
, deberías esperar una respuesta en milisegundos. Pero, ¿qué ocurre si hay un retraso en el envío de la respuesta? Puede que tu servidor esté extremadamente ocupado gestionando miles de solicitudes, o que haya cuellos de botella en dependencias externas como bases de datos, servidores de autenticación, redes IP, o cualquier otro recurso o API que se tome su tiempo para responder. Los comportamientos OTP tienen incorporado un tiempo de espera de 5 segundos en sus API síncronas gen_server:call
. Esto debería ser suficiente para atender la mayoría de las consultas en cualquier sistema de tiempo real suave, pero hay casos límite que deben tratarse de forma diferente. Si envías una solicitud síncrona utilizando comportamientos OTP y no has recibido respuesta en 5 segundos, el proceso cliente lanzará una excepción. Vamos a probarlo en el shell con el siguiente módulo de devolución de llamada:
-
module
(
timeout
).
-
behavior
(
gen_server
).
-
export
([
init
/
1
,
handle_call
/
3
]).
init
(_
Args
)
->
{
ok
,
undefined
}.
handle_call
({
sleep
,
Ms
},
_
From
,
LoopData
)
->
timer
:
sleep
(
Ms
),
{
reply
,
ok
,
LoopData
}.
En la función gen_server:call/2
, enviamos un mensaje del formato {sleep, Ms}
, donde Ms
es un valor utilizado en la llamada timer:sleep/1
ejecutada en la llamada de retorno handle_call/3
. Enviar un valor superior a 5.000 milisegundos debería hacer que la función gen_server:call/2
lanzara una excepción, ya que dicho valor supera el tiempo de espera por defecto. Vamos a probarlo en el intérprete de comandos. Suponemos que el módulo de tiempo de espera ya está compilado, para evitar las advertencias del compilador de las funciones de devolución de llamada que hemos omitido:
1>gen_server:start_link({local, timeout}, timeout, [], []).
{ok,<0.66.0>} 2>gen_server:call(timeout, {sleep, 1000}).
ok 3>catch gen_server:call(timeout, {sleep, 5001}).
{'EXIT',{timeout,{gen_server,call,[timeout,{sleep,5001}]}}} 4>flush().
Shell got {#Ref<0.0.0.300>,ok} 5>gen_server:call(timeout, {sleep, 5001}).
** exception exit: {timeout,{gen_server,call,[timeout,{sleep,5001}]}} in function gen_server:call/2 6>catch gen_server:call(timeout, {sleep, 1000}).
{'EXIT',{noproc,{gen_server,call,[timeout,{sleep,1000}]}}}
Iniciamos el servidor, y en el comando shell 2, enviamos un mensaje síncrono indicando al servidor que duerma durante 1.000 milisegundos antes de responder con el átomo ok
. Como esto está dentro del tiempo de espera por defecto de 5 segundos, obtenemos nuestra respuesta. Pero en el comando shell 3, aumentamos el tiempo de espera a 5.001 milisegundos, haciendo que la función gen_server:call/2
lance una excepción. En nuestro ejemplo, el comando shell 3 captura la excepción, permitiendo que la función cliente gestione cualquier caso especial que pueda surgir como consecuencia del tiempo de espera.
Si decides capturar las excepciones que se produzcan como consecuencia de un tiempo de espera, ten cuidado: si el servidor está vivo pero ocupado, enviará una respuesta después de que se haya producido la excepción de tiempo de espera. Esta respuesta debe ser tratada. Si el cliente es en sí mismo un comportamiento OTP, la excepción hará que se invoque la llamada handle_info/2
. Si no se ha implementado esta llamada, el proceso cliente se bloqueará.
Si la llamada procede de un cliente Erlang puro, la excepción se almacenará en el buzón del cliente y nunca se gestionará. Tener mensajes no leídos en el buzón consumirá memoria y ralentizará el proceso cuando se reciban nuevos mensajes, ya que los mensajes basura deben recorrerse antes de que los nuevos coincidan con el patrón. No sólo eso, sino que enviar un mensaje a un proceso con un gran número de mensajes sin leer ralentizará al remitente, porque la operación de envío consumirá más reducciones. Esto tendrá un efecto en cadena, pudiendo provocar más tiempos de espera y aumentando aún más el número de mensajes basura en el buzón del cliente.
La penalización de rendimiento al enviar mensajes a un proceso con una cola de mensajes larga no se aplica a los comportamientos que responden sincrónicamente al proceso donde se originó la solicitud. Si el proceso cliente tiene una cola de mensajes larga, gracias a las optimizaciones del compilador y de la máquina virtual, la cláusula receive
coincidirá con la respuesta sin tener que recorrer toda la cola de mensajes.
Vemos la prueba de esta fuga de memoria en el comando 4 del intérprete de comandos, donde se vacían los mensajes no leídos. Si no hubiéramos vaciado el mensaje, habría permanecido en el buzón del intérprete de comandos. A lo largo de este libro, no dejamos de recordarte que no debes manejar casos de esquina y errores inesperados en tu código, ya que corres el riesgo de introducir más fallos y errores de los que realmente resuelves. Éste es un ejemplo típico en el que los efectos secundarios resultantes de estos tiempos de espera probablemente sólo se manifestarán bajo carga extrema en un sistema vivo.
Ahora echa un vistazo al comando shell 5 y a la Figura 4-7. Tenemos una llamada que hace que el proceso cliente se bloquee, porque se ejecuta fuera del ámbito de una sentencia try-catch
. En la mayoría de los casos, si tu servidor no responde por alguna razón (posiblemente desconocida), hacer que el proceso cliente termine y dejar que el supervisor se ocupe de ello es probablemente el mejor enfoque. En este ejemplo, el proceso shell termina y se reinicia inmediatamente. El servidor de tiempo de espera envía una respuesta al antiguo pid del cliente (y shell) al cabo de 5.001 milisegundos. Como este proceso ya no existe, el mensaje se descarta. Entonces, ¿por qué falla el comando shell 6 con el motivo noproc
? Echa un vistazo a la secuencia de comandos del shell y comprueba si puedes averiguarlo antes de seguir leyendo.
Cuando iniciamos el servidor, lo vinculamos al shell, haciendo que el proceso shell actuara como cliente y como padre. El servidor de tiempo de espera terminó después de que ejecutáramos una llamada a gen_server:call/2
fuera del ámbito de un try-catch
en el comando 5 del shell. Como el servidor no atrapa salidas, cuando el shell terminó, la señal EXIT
se propagó al servidor, haciendo que también terminara. En circunstancias normales, el cliente y el padre del servidor que enlaza con él no serían el mismo proceso, por lo que esto no ocurriría. Estos problemas suelen aparecer cuando se prueban comportamientos desde el shell, así que tenlos en cuenta cuando trabajes en tus ejercicios.
Entonces, ¿cómo proporcionamos algo distinto al valor de tiempo de espera por defecto de 5 segundos en los comportamientos? Fácil: establecemos nuestro propio tiempo de espera. En los servidores genéricos, lo hacemos mediante la siguiente llamada a una función:
gen_server
:
call
(
Server
,
Message
,
TimeOut
)
-
>
Reply
donde TimeOut
es el valor deseado en milisegundos o el átomo infinity
.
Una llamada a un cliente consistirá a menudo en una cadena de peticiones síncronas a varios procesos de comportamiento, potencialmente distribuidos. Éstos, a su vez, pueden enviar solicitudes a recursos externos. La mayoría de las veces, elegir valores de tiempo de espera se vuelve complicado, ya que estos procesos acceden a servicios y API proporcionados por terceros completamente fuera de tu control. Los sistemas conocidos por responder en milisegundos a la mayoría de las solicitudes pueden tardar segundos o incluso minutos bajo cargas extremas. El rendimiento de tu sistema contado en operaciones por segundo puede seguir siendo el mismo, pero cuando hay una carga mayor -posiblemente muchos órdenes de magnitud superior- que lo atraviesa, la latencia de las solicitudes individuales será mayor.
La única forma de responder a la pregunta de qué TimeOut
debes establecer es empezar por tus requisitos externos. Si un cliente especifica un tiempo de espera de 30 segundos, empieza por él y sigue tu camino a través de la cadena de peticiones. ¿Cuáles son los tiempos de respuesta garantizados de tus dependencias externas? ¿Cómo responderán el acceso al disco y la E/S bajo una carga extrema? ¿Y la latencia de la red? Dedica mucho tiempo a las pruebas de estrés de tu sistema en el hardware de destino y ajusta tus valores en consecuencia. Si no estás seguro, empieza con el valor por defecto de 5.000 milisegundos. Utiliza el valor infinity
con extremo cuidado, evitándolo por completo a menos que no haya otra alternativa .
Bloqueos
Imagina dos servidores genéricos en un sistema mal diseñado. server1
realiza una llamada síncrona a server2
. server2
recibe la petición, y a través de una serie de llamadas en otros módulos acaba ejecutando (posiblemente sin saberlo) una llamada de retorno síncrona a server1
. Observando la Figura 4-8, este problema no se resuelve mediante complejos algoritmos de prevención de bloqueos, sino mediante tiempos de espera.
Si server1
no ha recibido una respuesta en 5.000 milisegundos, termina, provocando que server2
termine también. Dependiendo de lo que llegue primero, la finalización se desencadena a través de la señal de monitorización o a través de un tiempo de espera propio. Si hay más procesos implicados en el bloqueo, la finalización se propagará también a ellos. El supervisor recibirá las señales EXIT
y reiniciará los servidores en consecuencia. La finalización se almacena en un archivo de registro, donde es de esperar que se detecte y se solucione el error que provocó el bloqueo.
En 17 años de trabajo con Erlang, sólo me he encontrado con un punto muerto.3 El proceso A
llamó de forma síncrona al proceso B
, que a su vez realizó una llamada a procedimiento remoto a otro nodo que dio lugar a una llamada síncrona al proceso C
. El proceso C
llamó de forma síncrona al proceso D
, que realizó otra llamada a procedimiento remoto de vuelta al primer nodo. Esta llamada dio lugar a una devolución de llamada síncrona al proceso A
, que seguía esperando una respuesta de B
. Descubrimos este punto muerto al integrar los dos nodos por primera vez, y tardamos 5 minutos en resolverlo. El proceso A
debería haber llamado a B
de forma asíncrona, y el proceso B
debería haber respondido a A
con una devolución de llamada asíncrona. Así que, aunque existe el riesgo de que se produzcan bloqueos, si enfocas el problema correctamente, es mínimo, ya que la mayor causa de bloqueos se produce al controlar la ejecución y el fallo en secciones críticas, algo para lo que el enfoque de "nada compartido" de Erlang ofrece multitud de alternativas .
Tiempos de espera genéricos del servidor
Imagina un servidor genérico cuya tarea es monitorizar y comunicarse con un dispositivo hardware concreto. Si el servidor no ha recibido un mensaje del dispositivo en un tiempo de espera predefinido, debe enviar una solicitud ping para asegurarse de que el dispositivo está vivo. Estas solicitudes de ping pueden activarse mediante tiempos de espera internos, creados añadiendo un valor de tiempo de espera en las tuplas de control enviadas como resultado de las funciones de llamada de retorno del comportamiento:
init
/
1
->
{
ok
,
LoopData
,
Timeout
}
handle_call
/
3
->
{
reply
,
Reply
,
LoopData
,
Timeout
}
handle_cast
/
2
->
{
noreply
,
LoopData
,
Timeout
}
handle_info
/
2
->
{
noreply
,
LoopData
,
Timeout
}
El valor Timeout
es un número entero en milisegundos o el átomo infinity
. Si el servidor no recibe un mensaje en Timeout
milisegundos, recibe un mensaje timeout
en su función de devolución de llamada handle_info/2
. Devolver infinity
es lo mismo que no establecer un valor de tiempo de espera. Vamos a probarlo con un ejemplo sencillo en el que cada 5.000 milisegundos, generamos un tiempo de espera que recupera la hora actual e imprime los segundos. Podemos pausar el temporizador y reiniciarlo enviando los mensajes síncronos start
y pause
:
-
module
(
ping
).
-
behavior
(
gen_server
).
-
export
([
init
/
1
,
handle_call
/
3
,
handle_info
/
2
]).
-
define
(
TIMEOUT
,
5000
).
init
(_
Args
)
->
{
ok
,
undefined
,
?
TIMEOUT
}.
handle_call
(
start
,
_
From
,
LoopData
)
->
{
reply
,
started
,
LoopData
,
?
TIMEOUT
};
handle_call
(
pause
,
_
From
,
LoopData
)
->
{
reply
,
paused
,
LoopData
}.
handle_info
(
timeout
,
LoopData
)
->
{_
Hour
,_
Min
,
Sec
}
=
time
(),
io
:
format
(
"
~2.w~n
"
,[
Sec
]),
{
noreply
,
LoopData
,
?
TIMEOUT
}.
Suponiendo que el módulo ping esté compilado, lo iniciamos y generamos un tiempo de espera cada 5 segundos. Podemos suspender el tiempo de espera enviándole el mensaje pause
, que cuando se maneja en la segunda cláusula de la función handle_call/3
no incluye un tiempo de espera en su tupla de retorno. Volvemos a activarlo con el mensaje start
:
1>gen_server:start({local, ping}, ping, [], []).
{ok,<0.38.0>} 22 27 2>gen_server:call(ping, pause).
paused 3>gen_server:call(ping, start).
started 51 56 4>gen_server:call(ping, start).
started 4
Como fijamos un tiempo de espera relativamente alto, no generamos un mensaje de tiempo de espera a intervalos de 5.000 milisegundos. Enviamos un mensaje de tiempo de espera sólo si el comportamiento no ha recibido un mensaje. Si se recibe un mensaje, como ocurre con el comando shell 4 de nuestro ejemplo, se reinicia el temporizador.
Si necesitas temporizadores que no puedan reiniciarse o que tengan que ejecutarse a intervalos regulares independientemente de los mensajes entrantes, utiliza funciones como como erlang:send_after/3
o las proporcionadas por el módulo timer
, incluyendo apply_after/3
, send_after/2
, apply_interval/4
, y send_interval/2
.
Comportamientos de hibernación
Si en lugar de un valor de tiempo de espera o el átomo infinity
devolvemos el átomo hibernate
, el servidor reducirá su huella de memoria y entrará en estado de espera. Te convendrá utilizar hibernate
cuando los servidores que reciben peticiones intermitentes y que consumen mucha memoria estén provocando que el sistema se quede sin memoria. Si utilizas hibernate
, se descartará la pila de llamadas y se ejecutará una recogida de basura de barrido completo, colocando todo en un montón continuo. La memoria asignada se reducirá al tamaño de los datos del montón. El servidor permanecerá en este estado hasta que reciba un nuevo mensaje .
Advertencia
La hibernación de procesos tiene un coste asociado, ya que implica una recogida de basura completa antes de la hibernación y otra poco después de que el proceso se despierte. Utiliza la hibernación sólo si no esperas que el comportamiento reciba ningún mensaje en un futuro previsible y necesitas economizar memoria, no para servidores que reciban frecuentes ráfagas de mensajes. Utilizarla como medida preventiva es peligroso, sobre todo si tu proceso está ocupado, ya que puede costar (y probablemente costará) más hibernar el proceso que dejarlo como está. La única forma de saberlo con seguridad es realizar un benchmark de tu sistema bajo estrés y demostrar una ganancia de rendimiento junto con una reducción sustancial del uso de memoria. Añádelo a posteriori sólo si sabes lo que haces. En caso de duda, ¡no lo hagas!
Globalización
Los procesos de comportamiento pueden registrarse local o globalmente. En nuestros ejemplos, todos se han registrado localmente utilizando una tupla del formato {local,
ServerName}
, donde ServerName
es un átomo que denota el alias. Esto equivale a registrar el proceso utilizando el BIF register(ServerName, Pid)
. Pero, ¿qué ocurre si queremos transparencia de localización en un clúster distribuido?
Los procesos registrados globalmente se apoyan en el servidor de nombres global, que los hace accesibles de forma transparente en un clúster de nodos distribuidos (posiblemente particionados). El servidor de nombres almacena réplicas locales de los nombres en cada nodo y monitorea la salud de los nodos y los cambios en la conectividad, garantizando que no haya ningún punto central de fallo. Registra un servidor globalmente utilizando la tupla {global, Name}
como argumento del campo nombre del servidor. Es equivalente a registrar el proceso utilizando la función global:register_name(Name, Pid)
. Utiliza la misma tupla en tus llamadas síncronas y asíncronas:
gen_server
:
start_link
(
{
global
,
Name
}
,
Mod
,
Args
,
Opts
)
-
>
{
ok
,
Pid
}
|
ignore
|
{
error
,
Reason
}
gen_server
:
call
(
{
global
,
Name
}
,
Message
)
-
>
Reply
gen_server
:
cast
(
{
global
,
Name
}
,
Message
)
-
>
ok
Existe una API que te permite sustituir el registro global de procesos por uno que hayas implementado tú mismo. Puedes crear el tuyo propio cuando la funcionalidad proporcionada por el módulo global
no sea suficiente, o cuando quieras un comportamiento diferente que atienda a diferentes topologías de red. Necesitas proporcionar un módulo de devolución de llamada -digamos, Module
- que exporte las mismas funciones y valores de retorno definidos en el módulo global
, a saber register_name/2
, unregister_name/1
, whereis_name/1
, y send/2
. El registro de nombres utiliza entonces la tupla {via,
Module, Name}
, e iniciar tu proceso utilizando {via, global,
Name}
es lo mismo que registrarlo globalmente utilizando {global,
Name}
. Para los procesos registrados globalmente, el Name
no tiene que ser un átomo, sino que es válido cualquier término Erlang. Una vez que tengas tu módulo de llamada de retorno, puedes iniciar tu proceso y enviar mensajes utilizando:
gen_server
:
start_link
(
{
via
,
Module
,
Name
}
,
Mod
,
Args
,
Opts
)
-
>
{
ok
,
Pid
}
gen_server
:
call
(
{
via
,
Module
,
Name
}
,
Message
)
-
>
Reply
gen_server
:
cast
(
{
via
,
Module
,
Name
}
,
Message
)
-
>
ok
En el resto del libro, agregamos {via, Module,
Name}
, {local, Name}
, y {global, Name}
utilizando NameScope
. La mayoría de los servidores se registran localmente, pero dependiendo de la complejidad del sistema y de las estrategias de agrupación, también se utilizan global
y via
.
Cuando te comuniques con comportamientos, puedes utilizar sus pids en lugar de sus alias registrados. Registrar los comportamientos no es obligatorio; no registrarlos permite que varias instancias del mismo comportamiento se ejecuten en paralelo. Al iniciar los comportamientos, simplemente omite el campo nombre:
gen_server
:
start_link
(
Mod
,
Args
,
Opts
)
-
>
{
ok
,
Pid
}
|
ignore
|
{
error
,
Reason
}
Si difundes una petición a todos los servidores de un clúster de nodos, puedes utilizar la llamada genérica al servidor multi_call/3
si necesitas resultados de vuelta y abcast/3
si no los necesitas:
gen_server
:
multi_call
(
Nodes
,
Name
,
Request
[
,
Timeout
]
)
-
>
{
[
{
Node
,
Reply
}
]
,
BadNodes
}
gen_server
:
abcast
(
Nodes
,
Name
,
Request
)
-
>
abcast
En los servidores de los nodos individuales, las solicitudes se gestionan en las retrollamadas handle_call/3
y handle_cast/2
, respectivamente. Cuando se transmite de forma asíncrona con abcast
, no se comprueba si los nodos están conectados y siguen vivos. Las solicitudes a nodos que no se pueden alcanzar simplemente se desechan.
Vincular comportamientos
Cuando inicias comportamientos en el intérprete de comandos, vinculas el proceso del intérprete de comandos a ellos. Si el proceso del intérprete de comandos termina de forma anormal, su señal EXIT
se propagará a los comportamientos que inició y hará que terminen. Los servidores genéricos pueden iniciarse sin vincularlos a su padre llamando a gen_server:start/3
o gen_server:start/4
. Utiliza estas funciones con cuidado, y preferiblemente sólo con fines de desarrollo y prueba, porque los comportamientos siempre deben estar vinculados a su padre:
gen_server
:
start
(
NameScope
,
Mod
,
Args
,
Opts
)
gen_server
:
start
(
Mod
,
Args
,
Opts
)
-
>
{
ok
,
Pid
}
|
{
error
,
{
already_started
,
Pid
}
}
Los sistemas Erlang funcionarán durante años sin necesidad de reiniciar los ordenadores en los que se ejecutan. Pueden continuar incluso durante las actualizaciones de software para corregir errores, mejorar características y añadir nuevas funcionalidades, y mediante comportamientos que terminan de forma anómala y se reinician. Al cerrar un subsistema, tienes que estar seguro al 100% de que todos los procesos asociados a ese subsistema han terminado, y evitar dejar procesos huérfanos. La única forma de hacerlo con certeza es utilizando enlaces. Entraremos en más detalles cuando tratemos los comportamientos de los supervisores en el Capítulo 8.
Resumen
En este capítulo hemos introducido los conceptos y funciones más importantes del comportamiento genérico del servidor, el comportamiento que hay detrás de todos los comportamientos. Ahora ya deberías comprender las ventajas de utilizar el comportamiento gen_server
en lugar de crear el tuyo propio. Hemos cubierto la mayoría de las funciones y llamadas de retorno asociadas que se necesitan al utilizar este comportamiento. Aunque no es necesario que entiendas todo lo que ocurre entre bastidores, esperamos que ahora tengas una idea y aprecies que hay más de lo que parece a simple vista. Las funciones más importantes que hemos cubierto se enumeran en la Tabla 4-1.
función o acción gen_server | función callback gen_server |
---|---|
gen_server:start/3 , gen_server:start/4 , gen_server:start_link/3 , gen_server:start_link/4 | Module:init/1 |
gen_server:call/2 , gen_server:call/3 , gen_server:multi_call/2 , gen_server:multi_call/3 | Module:handle_call/3 |
gen_server:cast/2 , gen_server:abcast/2 , gen_server:abcast/3 | Module:handle_cast/2 |
Pid ! Msg monitores, mensajes de salida, mensajes de puertos y sockets, monitores de nodos y otros mensajes no-OTP | Module:handle_info/2 |
Se dispara al devolver {stop, ...} o al terminar anormalmente mientras atrapa salidas | Module:terminate/2 |
Al compilar módulos de comportamiento, habrás visto una advertencia sobre la falta de la llamada de retorno code_change/3
. Lo trataremos en el Capítulo 11, cuando veamos la gestión de versiones y actualizaciones de software. En el próximo capítulo, mientras utilizamos el comportamiento genérico del servidor como ejemplo, veremos temas avanzados y funcionalidades específicas del comportamiento que vienen con OTP.
En este punto, querrás asegurarte de revisar las páginas del manual del módulo gen_server
. Si te sientes valiente, lee el código del archivo fuente gen_server.erly el código fuente del módulo de ayuda gen
. Tras haber leído este capítulo y el anterior y haber comprendido los casos prácticos, descubrirás que el código no es tan críptico como podría parecer a primera vista.
¿Y ahora qué?
El siguiente capítulo contiene curiosidades que te permitirán profundizar en los comportamientos. Empezamos investigando la funcionalidad de rastreo y registro incorporada que obtenemos al utilizarlos. También te presentamos las banderas Opts
en las funciones de inicio. Las banderas te permiten ajustar el rendimiento y el uso de memoria, así como iniciar tu comportamiento con las banderas de rastreo activadas. Así que sigue leyendo, porque te esperan cosas interesantes en el próximo capítulo.
1 A riesgo de parecer repetitivo, sé amable con ellos, ya que algún día podrías ser tú.
2 Si ejecutas este ejemplo en la shell, también obtendrás un informe de error de la propia shell terminando como resultado de la señal de salida que se propaga a través del enlace.
3 Soy el autor que en el libro anterior provocó la interrupción nacional de datos en una red de telefonía móvil.
Get Diseñar para la escalabilidad con Erlang/OTP now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.