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.