image

Comprendiendo Procesos en la BEAM con Elixir

Este post es para todos los developers que quieren experimentar con Elixir, o bien que están en los primeros pasos con esta tecnología. Este tema requiere que tengas un conocimiento básico de Elixir y Erlang, al menos para comprender por qué es importante dedicarle tiempo de calidad a este tema, porque es sin duda alguna uno de los conceptos centrales de la máquina virtual de Erlang BEAM.

Aunque Elixir es considerado un lenguaje de propósito general, no es necesario que tengas que meterte con este tipo de temas al inicio. Pero si quieres conocer porqué la BEAM y sus lenguajes son tan populares en el mundo de concurrencia y paralelismo, este post es para ayudarte a comprender cómo diseñar software en el ecosistema de Erlang.

¿Por qué entender los procesos?

Seguramente al buscar información sobre Elixir encontrarás grandes características como: sistemas altamente escalables, tolerancia a fallos, sistemas concurrentes y distribuidos, etc. Pues bien, todas estas ventajas vienen directamente de la Máquina Virtual de Erlang (mejor conocida como BEAM Björn’s Erlang Abstract Machine).

Elixir, de nuevo, es un lenguaje de propósito general. Esto quiere decir que puedes aprender a usarlo mediante su paradigma funcional, resolver algunos problemas del día a día, y no tener que meterte con el tema de procesos si así lo deseas. Pero precisamente entender los procesos es una gran forma de entender qué es lo que hace a Elixir un lenguaje tan poderoso y cómo comenzar a aprovechar todas las ventajas de su ecosistema, mismas que lo hacen diferente de otros lenguajes y plataformas.

¿Qué es un proceso en el ecosistema de Erlang?

Un proceso es una entidad aislada donde ocurre la ejecución de código.

Los procesos están en todos lados en cualquier sistema creado en la BEAM, podrías usar cualquier lenguaje como Erlang, Elixir, Gleam o b, y podrás encontrar el mismo concepto. Por ejemplo la shell interactiva iex, o los patrones de OTP son ejemplos de procesos.

Los procesos son parte central para crear programas concurrentes y construir diseños distribuidos y tolerantes a fallos. Aunque, como mencioné anteriormente, podrías solo escribir módulos y funciones, y no tener que preocuparte de cómo funcionan hasta cierto punto.

Quienes están empezando a aprender Elixir seguramente hemos escuchado hablar de OTP “The Open Telecom Platform”, una serie de abstracciones que nos permite explotar las capacidaddes de la BEAM. Entender el tema de procesos es también entender qué pasa “behind the scenes” de estas abstracciones, esto te ayudará a comprender cómo aprovechar estas ventajas al momento de diseñar algún componente de software.

Anatomía de un Proceso

image

Un proceso es una entidad aislada que permite la ejecución de código, es la base para diseñar software y aprovechar las ventajas que brinda la BEAM.

Un proceso, a nivel de memoria, esta compuesto de cuatro bloques donde se guarda toda la información que permite su ejecución:

  1. Stack: almacena las variables locales.
  2. Heap: almacena estructuras de datos largas.
  3. Mailbox: guarda los mensajes recividos.
  4. Process Control Block: Permite hacer el tracking del estado interno del proceso.

Ciclo de Vida

image

Podemos definir de momento el ciclo de vida de un proceso en las siguientes fases:

  1. Creación
  2. Ejecución de código
  3. Finalización (termination)

Creación de un Proceso

La función spawn nos permite crear un proceso, solo necesitaremos pasarle una función que guardará el código que se va a ejecutar dentro del proceso.

Al crear un proceso, obtendremos un process identifier con el que podremos identificar nuestro proceso. Esto será posible con functiones provistar por el módulo Process.

Veamos un ejemplo en la iex:

  1. Se crea una función para imprimir un string. Esta será el código a ejecutar dentro de un proceso.
execute_fun = fn -> IO.puts "Hi! ⭐️  I'm the process #{inspect(self())}." end
  1. Creación de un proceso: Invoca la función spawn con la función execute_fun como parámetro único. Esto creará un nuevo proceso y regresará un PID.
pid = spawn(execute_fun)
  1. Ejecución de código: inmediatamente al crear el proceso, se ejecutará la función que recibió como parámetro.

  2. Terminación: Después de la ejecución de código, el proceso terminará su ejecución. Para verificar esto puedes usar la función Process.alive?/1 que te regresará un valor booleano para indicar si el proceso sigue vivo o no.

Process.alive?(pid)

blogpost1

Interacción entre Procesos: Recepción de Mensajes

Es común entonces diseñar código para ejecutarse dentro de un proceso de forma individual, así es que será común tener que ejecutar diferentes funcionalidades en procesos por serapado que tendrán que interactuar entre sí en algún punto en el tiempo, para ello existen los mensajes.

Un proceso es capaz de ejecutar código de forma aislada así como de recibir mensajes provenientes de otros procesos. Los mensajes son la única forma de comunicarse entre ellos, estos se guardan en el mailbox, uno de los cuatro bloques de memoria que componen a un proceso.

Es importante aclarar que los mensajes recibidos o enviados son independientes del código que se ejecuta.

Para procesar los mensajes recibidos es necesario usar la sentencia receive. Mientras que podrás enviar mensajes con la sentencia send. Para ello nos sirve el Process Identifier, pues es la forma de identificar cada proceso y poder comunicarse con él. Veamos el siguiente ejemplo:

  1. Se creará una función dentro de un módulo MyProcess.awaiting_for_receive_messages/0 que implementará la sentencia receive. Esto nos ayudará a indicarle al proceso que ejecuta ciertas acciones al recibir cierto tipo de mensajes provenientes de otro proceso.
defmodule MyProcess do
  def awaiting_for_receive_messages do
    IO.puts "Process #{inspect(self())}, waiting to process a message!"
    receive do
      "Hi" ->
        IO.puts "Hi from me"
      "Bye" ->
        IO.puts "Bye, bye from me"
      _ ->
        IO.puts "Processing something"
    end
    IO.puts "Process #{inspect(self())}, message processed. Terminating..."
  end
end

Este código puedes copiar y pegar directamente sobre la iEx.

  1. Creación de un proceso: Tomaremos la función anterior para crear un nuevo proceso, esto hará que la función receive se ejecute solo al recibir un mensaje, entonces terminará la ejecución y terminará. De lo contrario el proceso no morirá, mientras la sentencia receive espera a ejecutarse.
pid = spawn(MyProcess, :awaiting_for_receive_messages, [])
  1. Envío de mensajes: Cabe señalar que la iEx es un proceso en sí, por lo que podemos enviar mensajes desde aquí al proceso que acabamos de crear. Usaremos la sentencia send, enviaremos el PID del proceso al que le vamos a enviar el mensaje, y el segundo parámetro será el contenido del mismo, que para este caso será un string pero podría ser otro dato.
send(pid, "Hi")
  1. Ejecución de código: Al recibir el mensaje, inmediatamente se ejecutará nuestra función con el receive.

  2. Terminación: Al recibir el mensaje y procesarlo, nuestro proceso habrá concluido. ¿Pero y qué pasa si queremos mantener ese proceso aún con vida?

blogpost2

Mantener un Proceso Activo

En el ejemplo anterior, nuestro proceso terminará su vida al procesar el mensaje. Es posible que un proceso siga activo incluso después de procesar un mensaje, para ello podemos hacer uso de la recursividad para volver a invocar la función con el receive. Esto nos ayudará también a poder conservar un estado, como lo veremos más adelante.

defmodule MyProcess do
  def awaiting_for_receive_messages do
    IO.puts "Process #{inspect(self())}, waiting to process a message!"
    receive do
      "Hi" ->
        IO.puts "Hi from me"
        awaiting_for_receive_messages()
      "Bye" ->
        IO.puts "Bye, bye from me"
        awaiting_for_receive_messages()
      _ ->
        IO.puts "Processing something"
        awaiting_for_receive_messages()
    end
    IO.puts "Process #{inspect(self())}, message processed. Terminating..."
  end
end

Mantener un Estado

Retomando el ejemplo anterior, es posible conservar un estado que puede ir transformándose. A continuación te muestro un ejemplo de cómo quedaría nuestro módulo de ejemplo:

defmodule MyProcess do
  def awaiting_for_receive_messages(messages_received \\ []) do
    receive do
      "Hi" = msg ->
        IO.puts "Hi from me"
        [msg|messages_received]
        |> IO.inspect(label: "MESSAGES RECEIVED: ")
        |> awaiting_for_receive_messages()

      "Bye" = msg ->
        IO.puts "Bye, bye from me"
        [msg|messages_received]
        |> IO.inspect(label: "MESSAGES RECEIVED: ")
        |> awaiting_for_receive_messages()

      msg ->
        IO.puts "Processing something"
        [msg|messages_received]
        |> IO.inspect(label: "MESSAGES RECEIVED: ")
        |> awaiting_for_receive_messages()
    end
  end
end

En este caso ahora nuestra función awaiting_for_receive_messages reciben una lista vacía donde guardaremos los mensajes recibidos.

  1. Creación del proceso: pid = spawn(MyProcess, :awaiting_for_receive_messages, [])
  2. Recepción de un mensaje: send(pid, "Hi") después de invocar esto, podemos verificar que el proceso siga activo.
  3. Recepción de más mensajes: podremos repetir el paso anterior muchas veces.
iex(3)> pid = spawn(MyProcess, :awaiting_for_receive_messages, [])
#PID<0.132.0>

iex(4)> Process.alive?(pid)
true

iex(5)> send(pid, "Hi")
Hi from me
"Hi"
MESSAGES RECEIVED: : ["Hi"]


iex(6)> send(pid, "Bye")
Bye, bye from me
"Bye"
MESSAGES RECEIVED: : ["Bye", "Hi"]


iex(7)> send(pid, "Heeeey!")
Processing something
"Heeeey!"
MESSAGES RECEIVED: : ["Heeeey!", "Bye", "Hi"]

¿Dónde están los procesos?

Muy bien, si me acompañaste hasta este punto hemos recorrido algunas nociones básicas sobre qué es un proceso y cómo funciona. Si te preguntas dónde puedes ver hasta donde llegar con estas implementaciones te doy un gran spoiler: processes everywhere.

Veamos un ejemplo de Phoenix Live Views, este es un módulo que tiene efecto sobre un template de html (no te preocupes por el nombre de las funciones):

defmodule DemoWeb.ClockLive do
  use DemoWeb, :live_view

  def render(assigns) do
    ~H"""
    <div>
      <h2>It's <%= NimbleStrftime.format(@date, "%H:%M:%S") %></h2>
      <%= live_render(@socket, DemoWeb.ImageLive, id: "image") %>
    </div>
    """
  end

  def mount(_params, _session, socket) do
    if connected?(socket), do: Process.send_after(self(), :tick, 1000)

    {:ok, put_date(socket)}
  end

  def handle_info(:tick, socket) do
    Process.send_after(self(), :tick, 1000)
    {:noreply, put_date(socket)}
  end

  def handle_event("nav", _path, socket) do
    {:noreply, socket}
  end

  defp put_date(socket) do
    assign(socket, date: NaiveDateTime.local_now())
  end
end

image

No me gustaría entrar en mucho detalle con los Live Views, pero si contarte que este módulo sirve para dos cosas, por un lado las funciones render/1 y mount/3 sirven para hacer el setup de la Live View, las funciones handle_info y handle_event permiten mantener activa la conexión del socket y un estado interno ¿te suena esto familiar? ¡Es un proceso! Un Live View es una abstracción de OTP para crear un proceso.

Processes Everywhere

Entender cómo funcionan los procesos te dará mejores conceptos para entender cómo funciona el Ecosistema de Erlang y para diseñar mejores programas. Muchas bibliotecas y proyectos escritos en Elixir usan estos conceptos, entonces la próxima vez que uses algún proyecto, piensa que probablemente estarás usando abstracciones de procesos por debajo.

Si quieres conocer más de este tema te sugiero probar el código de ejemplo en este post, y buscar los apartados de procesos dentro de los libros de Elixir o Erlang. Probablemente también la documentación de Erlang te dará aún más conceptos sobre este gran tema.

Muchas gracias por llegar hasta aquí, si quieres conocer más de este gran mundo puedes ver las opciones de training y las próximas charlas de la Code Beam América.