Entendiendo procesos y concurrencia

Bienvenidos al segundo capítulo de la serie “Elixir, 7 pasos para iniciar tu viaje”.  En el primer capítulo hablamos sobre la máquina virtual de Erlang, la BEAM, y las características

14 min read

Bienvenidos al segundo capítulo de la serie “Elixir, 7 pasos para iniciar tu viaje”

En el primer capítulo hablamos sobre la máquina virtual de Erlang, la BEAM, y las características que Elixir aprovecha de ella para desarrollar sistemas que son:

  • Concurrentes
  • Tolerantes a fallos
  • Escalables y
  • Distribuidos

En esta nota explicaremos qué significa la concurrencia para Elixir y Erlang y por qué es importante para desarrollar sistemas tolerantes a fallos. Al final encontrarás un pequeño ejemplo de código hecho con Elixir para que puedas observar las ventajas de la concurrencia en acción.

Concurrencia

La concurrencia es la habilidad para llevar a cabo dos o más tareas

aparentemente al mismo tiempo .

Para entender por qué la palabra aparentemente está resaltada, veamos el siguiente caso:

Una persona tiene que completar dos actividades, la tarea A y la tarea B

  • Inicia la tarea A, avanza un poco y la pausa. 
  • Inicia la tarea B, avanza un poco, la pausa y continúa con la tarea A.
  • Avanza un poco con la tarea A, la pausa y continúa con la tarea B.

Y así va avanzando con cada una, hasta terminar ambas actividades.

No es que la tarea A y la tarea B se lleven a cabo exactamente al mismo tiempo, más bien la persona dedica un tiempo a cada una y va intercambiándose entre ellas. Estos tiempos pueden ser tan cortos que el cambio es imperceptible para nosotros, por eso se produce la ilusión de que las actividades están sucediendo simultáneamente.

Paralelismo

Hasta ahora no había mencionado nada sobre paralelismo porque no es un concepto fundamental en la BEAM o para Elixir. Pero recuerdo que cuando estaba aprendiendo a programar se me dificultó comprender la diferencia entre paralelismo y concurrencia, así que aprovecharé esta nota para compartirte una breve explicación.

Sigamos con el ejemplo anterior. Si ahora traemos a otra persona para completar las tareas y ambas trabajan al mismo tiempo, hablamos de paralelismo.

De manera que podríamos tener a dos o más personas trabajando paralelamente, cada una llevando a cabo sus actividades concurrentemente. Es decir, la concurrencia puede ser o no paralela.

En Elixir la concurrencia se logra gracias a los procesos de Erlang, que son creados y administrados por la BEAM.

Procesos

En Elixir todo el código se ejecuta dentro de procesos. Y una aplicación puede tener cientos o miles de ellos ejecutándose de manera concurrente. 

¿Cómo funciona?

Cuando la BEAM se ejecuta en una máquina, se encarga de crear por default un hilo en cada procesador disponible. En ese hilo existe una cola dedicada a tareas específicas, y cada cola tiene a su vez un administrador (scheduler) que es responsable de asignar un tiempo y una prioridad a las tareas. 

Entonces, en una máquina multicore con dos procesadores puedes tener dos hilos y dos schedulers, lo que te permite paralelizar las tareas al máximo. También puedes ajustar la configuración de la BEAM para indicarle qué procesadores utilizar.

En cuanto a las tareas, cada una se ejecuta en un proceso aislado. 

Parece algo simple, pero justamente esta idea es la magia detrás de la escalabilidad, distribución y tolerancia a fallos de un sistema hecho con Elixir. 

Veamos este último concepto para entender por qué.

Tolerancia a fallos

La tolerancia a fallos de un sistema se refiere a la capacidad que tiene para manejar los errores y no morir en el intento. El objetivo es que ninguna falla, sin importar lo crítica que sea, inhabilite o bloquee el sistema. Esto se logra nuevamente gracias a los procesos de Erlang.

Los procesos son elementos aislados, que no comparten memoria y se comunican mediante paso de mensajes.

Lo anterior significa que si algo falla en el proceso A, el proceso B no se ve afectado, es más, es posible que ni siquiera se entere. El sistema seguirá funcionando con normalidad mientras la falla se arregla tras bambalinas. Y si a esto sumamos que la BEAM también nos proporciona por defecto mecanismos para detección y recuperación de errores podemos garantizar que el sistema funcione de manera ininterrumpida.

Si quieres explorar más acerca del funcionamiento de los procesos, puedes consultar esta nota: Understanding Processes for Elixir Developers.

¿Cómo se ve esto en Elixir?

¡Por fin llegamos al código! 

Revisemos un ejemplo de cómo crear procesos que se ejecutan de manera concurrente en Elixir. Lo vamos a contrastar con el mismo ejercicio ejecutándose de manera secuencial. 

¿Listo? No te preocupes si no entiendes algo de la sintaxis, en general el lenguaje es muy intuitivo, pero el objetivo por ahora es que seas testigo de la magia de la concurrencia en acción.

El primer paso consiste en crear los procesos.

Spawn

Hay diferentes formas de crear procesos en Elixir. A medida que vayas avanzando encontrarás maneras más sofisticadas de hacerlo, aquí utilizaremos la básica: la función spawn. ¡Manos a la obra!

Tenemos 10 registros que corresponden a la información de usuarios que vamos a insertar en una base de datos, pero antes queremos validar que el nombre no contenga caracteres raros y que el email tenga un @.

Supongamos que la validación de cada usuario tarda en total 2 segundos.

  1. Abre un editor de texto y copia el siguiente código. Guárdalo en un archivo llamado procesos.ex
defmodule Procesos do


 # Vamos a utilizar expresiones regulares para el formato del nombre y
 # el correo electrónico
 @email_valido ~r/^([a-zA-Z0-9_\-\.\+]+)@([a-zA-Z0-9_\-\.]+)\.([a-zA-Z]{2,5})$/
 @nombre_valido ~r/\b([A-ZÀ-ÿ][-,a-z. ']+[ ]*)+/


 # Se tiene una lista de usuarios con un nombre y correo electrónico. 
 # La función validar_usuarios_X manda a llamar a otra función: 
 # validar_usuario, que revisa el formato del correo e imprime un
 # mensaje de ok o error para cada registro


 # Esta función trabaja SECUENCIALMENTE
 def validar_usuarios_secuencialmente() do
   IO.puts("Validando usuarios secuencialmente...")


   usuarios = crear_usuarios()


   Enum.each(usuarios, fn elem -> 
     validar_usuario(elem) end)
end


 # Esta función trabaja CONCURRENTEMENTE, utilizando spawn
 def validar_usuarios_concurrentemente() do
   IO.puts("Validando usuarios concurrentemente...")


   usuarios = crear_usuarios()


   Enum.each(usuarios, fn elem ->
     spawn(fn -> validar_usuario(elem) end)
   end)
 end


 def validar_usuario(usuario) do
   usuario
   |> validar_email()
   |> validar_nombre()
   |> imprimir_estatus()


# Esto hace una pausa de 2 segundos para simular que el proceso inserta # los registros en base de datos
   Process.sleep(2000)
 end


 # Esta función recibe un usuario, valida el formato del correo y le 
 # agrega la llave email_valido con el resultado.
def validar_email(usuario) do
   if Regex.match?(@email_valido, usuario.email) do
     Map.put(usuario, :email_valido, true)
   else
     Map.put(usuario, :email_valido, false)
   end
 end


 # Esta función recibe un usuario, valida su nombre y le agrega la
 # llave nombre_valido con el resultado.
 def validar_nombre(usuario) do
   if Regex.match?(@nombre_valido, usuario.nombre) do
     Map.put(usuario, :nombre_valido, true)
   else
     Map.put(usuario, :nombre_valido, false)
   end
 end
# Esta función recibe un usuario que ya pasó por la validación
 # de email y nombre y dependiendo de su resultado, imprime el
 # mensaje correspondiente al estatus.
 def imprimir_estatus(%{
       id: id,
       nombre: nombre,
       email: email,
       email_valido: email_valido,
       nombre_valido: nombre_valido
     }) do
   cond do
     email_valido && nombre_valido ->
       IO.puts("Usuario #{id} | #{nombre} | #{email} ... es válido")


     email_valido && !nombre_valido ->
       IO.puts("Usuario #{id} | #{nombre} | #{email} ... tiene un nombre inválido")


     !email_valido && nombre_valido ->
       IO.puts("Usuario #{id} | #{nombre} | #{email} ... tiene un email inválido")


     !email_valido && !nombre_valido ->
       IO.puts("Usuario #{id} | #{nombre} | #{email} ... es inválido")
   end
 end


 defp crear_usuarios do
   [
     %{id: 1, nombre: "Melanie C.", email: "melaniec@test.com"},
     %{id: 2, nombre: "Victoria Beckham", email: "victoriab@testcom"},
     %{id: 3, nombre: "Geri Halliwell", email: "gerih@test.com"},
     %{id: 4, nombre: "123456788", email: "melb@test.com"},
     %{id: 5, nombre: "Emma Bunton", email: "emmab@test.com"},
     %{id: 6, nombre: "Nick Carter", email: "nickc@test.com"},
     %{id: 7, nombre: "Howie Dorough", email: "howie.dorough"},
     %{id: 8, nombre: "", email: "ajmclean@test.com"},
     %{id: 9, nombre: "341AN L1ttr377", email: "Brian-Littrell"},
     %{id: 10, nombre: "Kevin Richardson", email: "kevinr@test.com"}
   ]
 end
end

2. Abre una terminal, escribe iex y compila el archivo que acabamos de crear.

$ iex


Erlang/OTP 25 [erts-13.1.3] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit:ns]


Interactive Elixir (1.14.0) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> c("procesos.ex")
[Procesos]

3. Una vez que hayas hecho esto, manda a llamar la función que valida los registros secuencialmente. Tomará un poco de tiempo, ya que cada registro tarda 2 segundos.


iex(2)>  Procesos.validar_usuarios_secuencialmente

4. Ahora manda a llamar la función que valida los registros concurrentemente y observa la diferencia en tiempos.

iex(3)>  Procesos.validar_usuarios_concurrentemente

Es bastante notoria, ¿no crees? Esto se debe a que en el paso 3, con la evaluación secuencial, cada proceso tiene que esperar a que el anterior termine. En cambio, la ejecución concurrente crea procesos que funcionan aisladamente; por lo tanto, ninguno depende del anterior ni está bloqueado por ninguna otra tarea.

¡Imagina la diferencia cuando se trata de miles o millones de tareas en un sistema!

La concurrencia es la base para las otras características que mencionamos al inicio: distribución, escalabilidad y tolerancia a fallos. Gracias a la BEAM, se vuelve relativamente fácil implementarla en Elixir y aprovechar las ventajas que nos brinda.

Ahora, ya conoces más sobre procesos y concurrencia, especialmente sobre la importancia de este aspecto para crear sistemas altamente confiables y tolerantes a fallas. Recuerda practicar y volver a esta nota cuando lo necesites.

Siguiente capítulo…

En la siguiente nota hablaremos de las bibliotecas, frameworks y todos los recursos que existen alrededor de Elixir. Te sorprenderá lo fácil y rápido que es crear un proyecto desde cero y verlo funcionando.

Documentación y Recursos

Consejero técnico:

Raúl Chouza

Corrección de estilo:

Si tienes dudas acerca de esta nota o te gustaría profundizar en el tema, puedes escribirme por Twitter a @loreniuxmr

¡Nos vemos en el siguiente capítulo!

Keep reading

Here’s why you should consider investing in RabbitMQ during a recession

In times of economic uncertainty, making wise tech investments into systems such as Rabbit MQ can be a valuable asset to your business in the long-term.

Understanding Elixir processes and concurrency

Lorena Mireles is back with her second instalment of the "Elixir, 7 steps to start your journey" series.

MongooseIM 6.1: Handle more traffic, consume less resources

With the introduction of arm64 Docker containers and the new C2S process handling implementation, MongooseIM is now more performant, cost-efficient, extensible and robust.