¿Qué es GenServer y por qué debería importarte?

En este artículo, aprenderá los conceptos básicos de la concurrencia en Elixir y verá cómo generar procesos, enviar y recibir mensajes y crear procesos de larga ejecución. También aprenderá sobre GenServer, verá cómo se puede usar en su aplicación y descubra algunas de las cosas que le ofrece..

Como probablemente sepa, Elixir es un lenguaje funcional que se utiliza para crear sistemas concurrentes y tolerantes a fallos que manejan muchas solicitudes simultáneas. BEAM (máquina virtual Erlang) utiliza procesos realizar varias tareas simultáneamente, lo que significa, por ejemplo, que atender una solicitud no bloquea otra. Los procesos son ligeros y aislados, lo que significa que no comparten ninguna memoria e incluso si un proceso falla, otros pueden continuar ejecutándose.

Los procesos BEAM son muy diferentes de los Procesos del sistema operativo. Básicamente, BEAM se ejecuta en un proceso de sistema operativo y utiliza su propio programadores. Cada programador ocupa uno Núcleo de la CPU, se ejecuta en un subproceso independiente y puede manejar miles de procesos simultáneamente (que se turnan para ejecutarse). Puede leer un poco más sobre BEAM y multiproceso en StackOverflow.

Entonces, como veis, los procesos de BEAM (a partir de ahora solo diré "procesos") son muy importantes en Elixir. El lenguaje le proporciona algunas herramientas de bajo nivel para generar procesos manualmente, mantener el estado y manejar las solicitudes. Sin embargo, pocas personas los utilizan; es más común confiar en el Plataforma abierta de telecomunicaciones (OTP) marco para hacer eso. 

La OTP hoy en día no tiene nada que ver con los teléfonos; es un marco de propósito general para construir sistemas concurrentes complejos. Define cómo deben estructurarse sus aplicaciones y proporciona una base de datos, así como un conjunto de herramientas muy útiles para crear procesos de servidor, recuperarse de errores, realizar registros, etc. En este artículo, hablaremos de una comportamiento del servidor llamado GenServer que es proporcionado por OTP.  

Puede pensar en GenServer como una abstracción o un ayudante que simplifica el trabajo con los procesos del servidor. En primer lugar, verá cómo generar procesos utilizando algunas funciones de bajo nivel. Luego cambiaremos a GenServer y veremos cómo simplifica las cosas eliminando la necesidad de escribir código tedioso (y bastante genérico) cada vez. Empecemos!

Todo comienza con engendro

Si me preguntaras cómo crear un proceso en Elixir, te respondería: desovar ¡eso! spawn / 1 es una función definida dentro del Núcleo Módulo que devuelve un nuevo proceso. Esta función acepta un lambda que se ejecutará en el proceso creado. Tan pronto como la ejecución haya finalizado, el proceso también se cierra:

spawn (fn -> IO.puts ("hi") end) |> IO.inspect # => hi # => #PID<0.72.0>

Asi que aqui desovar devolvió un nuevo identificador de proceso. Si agrega un retraso a la lambda, la cadena "hi" se imprimirá después de algún tiempo:

spawn (fn ->: timer.sleep (5000) IO.puts ("hi") end) |> IO.inspect # => #PID<0.82.0> # => (después de 5 segundos) "hi"

Ahora podemos generar tantos procesos como queramos, y se ejecutarán simultáneamente:

spawn_it = fn (num) -> spawn (fn ->: timer.sleep (5000) IO.puts ("hi # num") final) final Enum.each (1… 10, fn (_) -> spawn_it . (: rand.uniform (100)) fin) # => (todo impreso al mismo tiempo, después de 5 segundos) # => hi 5 # => hi 10 etc ... 

Aquí estamos generando diez procesos e imprimiendo una cadena de prueba con un número aleatorio. : rand Es un módulo proporcionado por Erlang, por lo que su nombre es un átomo. Lo bueno es que todos los mensajes se imprimirán al mismo tiempo, después de cinco segundos. Ocurre porque los diez procesos se están ejecutando al mismo tiempo.

Compárelo con el siguiente ejemplo que realiza la misma tarea pero sin usar engendro / 1:

dont_spawn_it = fn (num) ->: timer.sleep (5000) IO.puts ("hi # num") final Enum.each (1… 10, fn (_) -> dont_spawn_it. (: rand.uniform ( 100)) fin) # => (después de 5 segundos) hi 70 # => (después de otros 5 segundos) hi 45 # => etc ... 

Mientras se ejecuta este código, puede ir a la cocina y hacer otra taza de café, ya que tardará casi un minuto en completarse. Cada mensaje se muestra secuencialmente, lo que, por supuesto, no es óptimo.!

Podría preguntar: "¿Cuánta memoria consume un proceso?" Bueno, depende, pero inicialmente ocupa un par de kilobytes, que es un número muy pequeño (incluso mi vieja computadora portátil tiene 8 GB de memoria, por no mencionar los servidores modernos).

Hasta ahora tan bueno. Antes de comenzar a trabajar con GenServer, sin embargo, vamos a discutir otra cosa importante: pasar y recibir mensajes.

Trabajando con mensajes

No es sorprendente que los procesos (que son aislados, como recordará) necesiten comunicarse de alguna manera, especialmente cuando se trata de construir sistemas más o menos complejos. Para lograrlo, podemos utilizar mensajes..

Se puede enviar un mensaje usando una función con un nombre bastante obvio: enviar / 2. Acepta un destino (puerto, id de proceso o un nombre de proceso) y el mensaje real. Una vez enviado el mensaje, aparece en el buzón de un proceso y puede ser procesado. Como ve, la idea general es muy similar a nuestra actividad diaria de intercambiar correos electrónicos.

Un buzón es básicamente una cola de "primero en entrar, primero en salir" (FIFO). Una vez que se procesa el mensaje, se elimina de la cola. Para comenzar a recibir mensajes, necesita adivinar qué! -A recibir macro. Esta macro contiene una o más cláusulas, y un mensaje coincide con ellas. Si se encuentra una coincidencia, se procesa el mensaje. De lo contrario, el mensaje se vuelve a colocar en el buzón. Además de eso, puede establecer un opcional después cláusula que se ejecuta si no se recibió un mensaje en el tiempo dado. Puedes leer más sobre enviar / 2 y recibir en los documentos oficiales.

De acuerdo, basta con la teoría, tratemos de trabajar con los mensajes. En primer lugar, enviar algo al proceso actual:

enviar (auto (), "hola!")

La macro self / 0 devuelve un pid del proceso de llamada, que es exactamente lo que necesitamos. No omita los paréntesis tras la función, ya que recibirá una advertencia sobre la coincidencia de ambigüedad.

Ahora reciba el mensaje mientras configura el después cláusula:

recibir do msg -> IO.puts "Yay, un mensaje: # msg" msg después de 1000 -> IO.puts: stderr, "¡Quiero mensajes!" end |> IO.puts # => Yay, un mensaje: hola! # => hola!

Tenga en cuenta que la cláusula devuelve el resultado de evaluar la última línea, por lo que recibimos el mensaje "¡Hola!" cuerda.

Recuerde que puede introducir tantas cláusulas como sea necesario:

send (self (), : ok, "hola!") recibe do : ok, msg -> IO.puts "Yay, un mensaje: # msg" msg : error, msg -> IO .puts: stderr, "Oh no, algo malo ha sucedido: # msg" _ -> IO.puts "No sé qué es este mensaje ..." después de 1000 -> IO.puts: stderr, "¡Quiero mensajes!" fin |> IO.puts

Aquí tenemos cuatro cláusulas: una para manejar un mensaje de éxito, otra para manejar los errores, y luego una cláusula de "retroceso" y un tiempo de espera.

Si el mensaje no coincide con ninguna de las cláusulas, se guarda en el buzón, lo que no siempre es deseable. ¿Por qué? Porque cada vez que llega un nuevo mensaje, los antiguos se procesan en el primer encabezado (porque el buzón es una cola FIFO), lo que ralentiza el programa. Por lo tanto, una cláusula de "reserva" puede ser útil.

Ahora que sabe cómo generar procesos, enviar y recibir mensajes, echemos un vistazo a un ejemplo un poco más complejo que implica crear un servidor simple que responda a varios mensajes..

Trabajando con el proceso del servidor

En el ejemplo anterior, enviamos un solo mensaje, lo recibimos y realizamos algunos trabajos. Eso está bien, pero no es muy funcional. Por lo general, lo que sucede es que tenemos un servidor que puede responder a varios mensajes. Por "servidor" me refiero a un proceso de larga duración construido con una función recurrente. Por ejemplo, vamos a crear un servidor para realizar algunas ecuaciones matemáticas. Recibirá un mensaje que contiene la operación solicitada y algunos argumentos..

Comience por crear el servidor y la función de bucle:

defmodule MathServer do def start do spawn & listen / 0 end defp listen do receive : sqrt, caller, arg -> IO.puts arg _ -> IO.puts: stderr, "No implementado". end escuchar () end end

Entonces generamos un proceso que sigue escuchando los mensajes entrantes. Una vez recibido el mensaje, escuchar / 0 La función es llamada de nuevo, creando así un bucle sin fin. Dentro de escuchar / 0 función, agregamos soporte para el : sqrt mensaje, que calculará la raíz cuadrada de un número. los arg contendrá el número real para realizar la operación en contra. Además, estamos definiendo una cláusula de reserva..

Ahora puede iniciar el servidor y asignar su ID de proceso a una variable:

math_server = MathServer.start IO.inspect math_server # => #PID<0.85.0>

¡Brillante! Ahora vamos a añadir un función de implementación para realizar realmente el cálculo:

defmodule MathServer do #… def sqrt (server, arg) do send (: some_name, : sqrt, self (), arg) end end end

Usa esta función ahora:

MathServer.sqrt (math_server, 3) # => 3

Por ahora, simplemente imprime el argumento pasado, así que modifique su código así para realizar la operación matemática:

defmodule MathServer do # ... defp listen do receive do : sqrt, caller, arg -> send (: some_name, : result, do_sqrt (arg)) _ -> IO.puts: stderr, "No implementado". end listen () end defp do_sqrt (arg) do: math.sqrt (arg) end end

Ahora, se envía otro mensaje al servidor que contiene el resultado del cálculo.. 

Lo interesante es que el sqrt / 2 La función simplemente envía un mensaje al servidor solicitando que se realice una operación sin esperar el resultado. Así que, básicamente, realiza una llamada asíncrona.

Obviamente, queremos capturar el resultado en algún momento, así que codifique otra función pública:

def grab_result do recibir do : result, result -> result after 5000 -> IO.puts: stderr, "Timeout" end end end

Ahora utilízalo:

math_server = MathServer.start MathServer.sqrt (math_server, 3) MathServer.grab_result |> IO.puts # => 1.7320508075688772

¡Funciona! Por supuesto, incluso puede crear un grupo de servidores y distribuir tareas entre ellos, logrando la concurrencia. Es conveniente cuando las solicitudes no se relacionan entre sí..

Conoce a GenServer

De acuerdo, hemos cubierto varias funciones que nos permiten crear procesos de servidor de larga ejecución y enviar y recibir mensajes. Esto es genial, pero tenemos que escribir demasiado código repetitivo que inicie un bucle de servidor (inicio / 0), responde a los mensajes (escuchar / 0 función privada), y devuelve un resultado (grab_result / 0). En situaciones más complejas, es posible que también debamos mantener un estado compartido o manejar los errores.

Como dije al principio del artículo, no hay necesidad de reinventar una bicicleta. En su lugar, podemos utilizar el comportamiento GenServer que ya nos proporciona todo el código de plantilla y tiene un gran soporte para los procesos del servidor (como vimos en la sección anterior).

Comportamiento en Elixir es un código que implementa un patrón común. Para usar GenServer, necesita definir un especial módulo de devolución de llamada Eso satisface el contrato según lo dictado por el comportamiento. Específicamente, debe implementar algunas funciones de devolución de llamada, y la implementación real depende de usted. Después de que se escriben las devoluciones de llamada, módulo de comportamiento puede utilizarlos.

Como lo indican los documentos, GenServer requiere que se implementen seis devoluciones de llamada, aunque también tienen una implementación predeterminada. Esto significa que puede redefinir solo aquellos que requieren alguna lógica personalizada.

Lo primero es lo primero: necesitamos iniciar el servidor antes de hacer cualquier otra cosa, así que continúe con la siguiente sección!

Iniciando el servidor

Para demostrar el uso de GenServer, escribamos un CalcServer Eso permitirá a los usuarios aplicar varias operaciones a un argumento. El resultado de la operación se almacenará en un estado del servidor, y entonces se le puede aplicar otra operación también. O un usuario puede obtener un resultado final de los cálculos.

En primer lugar, emplee la macro de uso para conectar GenServer:

Defmodule CalcServer no utiliza GenServer final

Ahora tendremos que redefinir algunas devoluciones de llamada..

El primero es init / 1, que se invoca cuando se inicia un servidor. El argumento pasado se utiliza para establecer el estado de un servidor inicial. En el caso más simple, esta devolución de llamada debe devolver el : ok, initial_state tupla, aunque hay otros posibles valores de retorno como : detente, razón, lo que hace que el servidor se detenga inmediatamente.

Creo que podemos permitir que los usuarios definan el estado inicial de nuestro servidor. Sin embargo, debemos verificar que el argumento pasado sea un número. Así que usa una cláusula de guardia para eso:

defmodule CalcServer usa GenServer def init (initial_value) cuando is_number (initial_value) do : ok, initial_value end def init (_) do : stop, "El valor debe ser un número entero!" end end

Ahora, simplemente inicie el servidor utilizando la función de inicio / 3 y proporcione su CalcServer como un módulo de devolución de llamada (el primer argumento). El segundo argumento será el estado inicial:

GenServer.start (CalcServer, 5.1) |> IO.inspect # => : ok, #PID<0.85.0>

Si intenta pasar un no-número como segundo argumento, el servidor no se iniciará, que es exactamente lo que necesitamos.

¡Genial! Ahora que nuestro servidor se está ejecutando, podemos comenzar a codificar operaciones matemáticas..

Manejo de solicitudes asíncronas

Se llaman peticiones asincronas moldes en términos de GenServer. Para realizar dicha solicitud, utilice la función cast / 2, que acepta un servidor y la solicitud real. Es similar a la sqrt / 2 Función que codificamos al hablar de procesos del servidor. También utiliza el enfoque "disparar y olvidar", lo que significa que no estamos esperando a que finalice la solicitud.

Para manejar los mensajes asíncronos, se utiliza una devolución de llamada de handle_cast / 2. Acepta una solicitud y un estado y debe responder con una tupla. : noreply, new_state en el caso más simple (o : parada, razón, estado nuevo para detener el bucle del servidor). Por ejemplo, vamos a manejar un asíncrono : sqrt emitir:

def handle_cast (: sqrt, state) do : noreply,: math.sqrt (state) end 

Así es como mantenemos el estado de nuestro servidor. Inicialmente, el número (pasado cuando se inició el servidor) era 5.1. Ahora actualizamos el estado y lo configuramos a : math.sqrt (5.1).

Codifique la función de interfaz que utiliza reparto / 2:

def sqrt (pid) do GenServer.cast (pid,: sqrt) end

Para mí, esto se asemeja a un mago malvado que lanza un hechizo pero no le importa el impacto que causa.

Tenga en cuenta que requerimos un ID de proceso para realizar el lanzamiento. Recuerde que cuando un servidor se inicia con éxito, una tupla : ok, pid es regresado. Por lo tanto, usemos la coincidencia de patrones para extraer el id de proceso:

: ok, pid = GenServer.start (CalcServer, 5.1) CalcServer.sqrt (pid)

¡Bonito! Se puede utilizar el mismo enfoque para implementar, por ejemplo, la multiplicación. El código será un poco más complejo ya que tendremos que pasar el segundo argumento, un multiplicador:

def multiplica (pid, multiplicador) finaliza GenServer.cast (pid, : multiplicar, multiplicador)

los emitir La función solo admite dos argumentos, así que necesito construir una tupla y pasar un argumento adicional allí..

Ahora la devolución de llamada:

def handle_cast (: multiplicar, multiplicador, estado) do : noreply, estado * multiplicador fin

También podemos escribir una sola. handle_cast devolución de llamada que admite la operación, así como la detención del servidor si la operación es desconocida:

def handle_cast (operation, state) do case operation do: sqrt -> : noreply,: math.sqrt (state) : multiplicar, multiplicador -> : noreply, state * multiplicier _ -> : stop, "No implementado", estado fin fin

Ahora usa la nueva función de interfaz:

CalcServer.multiply (pid, 2)

Genial, pero actualmente no hay manera de obtener un resultado de los cálculos. Por lo tanto, es hora de definir otra devolución de llamada.

Manejo de solicitudes sincrónicas

Si las solicitudes asíncronas son conversiones, entonces se nombran las síncronas llamadas. Para ejecutar dichas solicitudes, utilice la función call / 3, que acepta un servidor, una solicitud y un tiempo de espera opcional que equivale a cinco segundos de forma predeterminada.

Las solicitudes sincrónicas se utilizan cuando queremos esperar hasta que la respuesta realmente llegue del servidor. El caso de uso típico es obtener cierta información, como el resultado de cálculos, como en el ejemplo de hoy (recuerde el grab_result / 0 función de una de las secciones anteriores).

Para procesar solicitudes síncronas, un handle_call / 3 se utiliza la devolución de llamada. Acepta una solicitud, una tupla que contiene el pid del servidor y un término que identifica la llamada, así como el estado actual. En el caso más simple, debe responder con una tupla. : responder, responder, new_state

Codifique esta devolución de llamada ahora:

def handle_call (: result, _, state) do : reply, state, state end

Como ves, nada complejo. los respuesta y el nuevo estado es igual al estado actual ya que no quiero cambiar nada después de que se devolvió el resultado.

Ahora la interfaz resultado / 1 función:

def result (pid) do GenServer.call (pid,: result) end

¡Eso es todo! El uso final del CalcServer se muestra a continuación:

: ok, pid = GenServer.start (CalcServer, 5.1) CalcServer.sqrt (pid) CalcServer.multiply (pid, 2) CalcServer.result (pid) |> IO.puts # => 4.516635916254486

Aliasing

Es algo tedioso proporcionar siempre una identificación de proceso al llamar a las funciones de la interfaz. Afortunadamente, es posible darle a su proceso un nombre o un nombre. alias. Esto se hace al inicio del servidor configurando nombre:

GenServer.start (CalcServer, 5.1, nombre:: calc) CalcServer.sqrt CalcServer.multiply (2) CalcServer.result |> IO.puts

Tenga en cuenta que no estoy almacenando PID ahora, aunque es posible que desee hacer una comparación de patrones para asegurarse de que el servidor se haya iniciado realmente..

Ahora las funciones de la interfaz se vuelven un poco más simples:

def sqrt do GenServer.cast (: calc,: sqrt) end def multiplica (multiplicador) do GenServer.cast (: calc, : multiply, multiplier) end def resultado do GenServer.call (: calc,: result) end

No olvide que no puede iniciar dos servidores con el mismo alias..

Alternativamente, puede introducir otra función de interfaz inicio / 1 dentro de su módulo y aproveche la macro __MODULE __ / 0, que devuelve el nombre del módulo actual como un átomo:

defmodule CalcServer no usa GenServer def start (initial_value) do GenServer.start (CalcServer, initial_value, name: __MODULE__) end def sqrt do GenServer.cast (__ MODULE__,: sqrt) end def multiply (multiplier) do GenServer.cast (__ MODULE__,: sqrt) end def multiply (multiplicador) : multiplicar, multiplicador) final def result do GenServer.call (__ MODULE__,: result) end #… end CalcServer.start (6.1) CalcServer.sqrt CalcServer.multiply (2) CalcServer.result |> IO.puts

Terminación

Otra devolución de llamada que se puede redefinir en su módulo se llama terminate / 2. Acepta una razón y el estado actual, y se llama cuando un servidor está a punto de salir. Esto puede suceder cuando, por ejemplo, pasa un argumento incorrecto a la multiplicar / 1 función de interfaz:

#… CalcServer.multiply (2)

La devolución de llamada puede verse algo como esto:

def terminate (_reason, _state) do IO.puts "The server terminated" final

Conclusión

En este artículo hemos cubierto los conceptos básicos de la concurrencia en Elixir y hemos analizado funciones y macros como desovar, recibir, y enviar. Ha aprendido qué son los procesos, cómo crearlos y cómo enviar y recibir mensajes. Además, hemos visto cómo construir un proceso simple de servidor de larga ejecución que responda a los mensajes síncronos y asíncronos..

Además de eso, hemos discutido el comportamiento de GenServer y hemos visto cómo simplifica el código mediante la introducción de varias devoluciones de llamada. Hemos trabajado con el en eso, Terminar, handle_call y handle_cast devoluciones de llamada y creó un servidor de cálculo simple. Si algo no te quedó claro, no dudes en publicar tus preguntas.!

Hay más en GenServer y, por supuesto, es imposible cubrir todo en un artículo. En mi próximo post, explicaré qué. supervisores y cómo puede usarlos para monitorear sus procesos y recuperarlos de los errores. Hasta entonces, feliz codificación.!