Cómo crear procesos

Elixir puede aprovecharse del modelo de programación concurrente que ofrece la máquina virtual BEAM sobre la que se ejecuta. En esta lección, te introduzco a la idea de proceso concurrente y te muestro cómo utilizar la primitiva spawn para evaluar una expresión en un proceso separado.

¿Pero qué es la concurrencia?

La concurrencia es una técnica de programación en la que organizamos el código de tal forma que haya partes que se puedan ejecutar de forma paralela o a desorden. Si se aplica de forma práctica, el paralelismo derivado de esta concurrencia nos permitirá tener regiones de código que se ejecutan en paralelo en el ordenador.

Esto significa que es posible tener distintas unidades de ejecución, por ejemplo, distintos núcleos y procesadores dentro de un ordenador ejecutando regiones de código de un mismo programa de forma simultánea, o diferentes ordenadores dentro de una red ejecutando diferentes copias del mismo programa, y que se comuniquen entre sí de forma efectiva para lograr un resultado común.

Un ejemplo de concurrencia podría ser tener varios ordenadores procesando diferentes regiones de una imagen de varios gigapíxeles que haya que procesar, como puede ser una imagen tomada vía satélite. En vez de tener una única máquina procesando por días esa imagen, podrías trocear la imagen en sectores y pedirle a cada nodo de la red (o a cada CPU de un sistema) procesar una región concreta. De este modo, se tardaría menos tiempo porque cada nodo se ocuparía de reportar información sobre una zona concreta. Todo lo que tenemos que hacer es interceptar las salidas de cada nodo y combinarlos en una misma matriz de resultados.

¿Qué hace tan interesante la concurrencia en Elixir?

Elixir es un lenguaje de programación que se ejecuta por encima de la máquina virtual BEAM, originalmente desarrollada para el lenguaje de programación Erlang. Utiliza la plataforma OTP (Open Telecom Platform) para ejecutar cientos de procesos de forma simultánea mediante concurrencia.

El modelo de concurrencia de Erlang y BEAM se basa en la idea del modelo de actores. Cuando programamos con este modelo, cada proceso es un actor independiente de un sistema. Los actores no pueden compartir información a excepción de mediante el uso de una serie de primitivas de paso de mensajes, que permite que dos actores se intercambien un mensaje.

Esto convierte la programación concurrente basada en actores en una de las más simples, seguras y escalables, porque los procesos no comparten información (evitando el riesgo típico en concurrencia de que se corrompa la memoria), a excepción de un sistema de paso de mensajes claro y directo, que facilita también ver cada proceso como una caja negra con sus entradas y sus salidas.

El modelo de concurrencia de Erlang y de BEAM resultó bastante atractivo en las primeras etapas del lenguaje, debido a su novedad. Erlang fue principalmente desarrollado para mantener sistemas de telecomunicaciones. Resultaba impresionante poder utilizar equipos de hardware dedicados (como centralitas telefónicas) equipadas con máquinas virtuales BEAM y dejar que se pudiesen mantener docenas de llamadas de teléfono en simultáneo con un control preciso sobre cuando se inicia y se termina una comunicación y sobre cuándo se produce un error de red sin tirar abajo todo el sistema.

Hoy día, este modelo de concurrencia lo podemos aprovechar para hacer aplicaciones de servidor, un lugar en el que Elixir, Erlang y todo BEAM destacan. Se suele poner como ejemplo a WhatsApp, quien es capaz de enlutar millones de conexiones TCP en una única instancia de la máquina virtual BEAM. Cada año, más empresas se pasan a la OTP para hacer software de alta concurrencia, y hoy en día está en sitios como Amazon o Discord, aunque no muchas empresas hablan de forma pública de ello.

¿Qué características tiene este modelo de concurrencia?

Debido al uso de actores, una de las cosas que hace buena la concurrencia en Elixir es que cuando se trabaja con el sistema de procesos de la plataforma, se crean procesos muy ligeros, que mantienen unas estructuras de datos muy pequeñas porque apenas comparten información. Esto hace que sea muy barato de crear y destruir porque el proceso de inicio y destrucción de los procesos es rápido.

Además, estos procesos se implementan como hilos verdes (green threads). ¿Esto qué quiere decir? Un hilo es el sistema de concurrencia más habitual hoy en día en computación, que representa un contexto de código en ejecución (como puede ser la instrucción que se está evaluando, la memoria local del proceso y el stack de llamadas). En los hilos típicos, se le pide al sistema operativo que subyace al programa que gestione los distintos hilos como si fuesen más procesos del sistema.

Sin embargo, el sistema operativo tiene un límite en cuanto al número de procesos que se pueden ejecutar, por lo que no se le puede pedir más de un número de hilos o colapsará. En cambio, cuando se usan hilos verdes, lo que se hace es re-crear todo un sistema de concurrencia dentro del propio proceso, sin contar con las limitaciones de los hilos del sistema operativo. La máquina virtual BEAM tiene su propio sistema para manejar en paralelo estos hilos y por eso es capaz de organizar cientos o miles de hilos en simultáneo.

Ese mecanismo de paso de mensajes también garantiza que cuando dos procesos comparten información, lo que comparten son copias diferentes de la misma información, así que también evitamos el riesgo de que modificar un dato pueda afectar a otros procesos que también estén referenciando lo mismo.

¿Cómo puedo hacer concurrencia en Elixir?

Entrando ya en programación, imaginemos que tenemos una pieza de código en Elixir, como pueda ser:

defmodule Ejemplo do
  def buenos_dias(nombre) do
    mensaje = "¡Hola, " <> nombre <> "!"
    mensaje
  end
end

En condiciones normales, podríamos ejecutar este código directamente sobre otra función o sobre una REPL:

iex(3)> Ejemplo.buenos_dias("Pepito")
"¡Hola, Pepito!"

En su lugar, si quisiésemos llamar a esta función de forma concurrente, usaríamos la primitiva Kernel.spawn/3. Con esta primitiva podemos pedirle a BEAM que ejecute una función en otro proceso. Sus parámetros son:

  • El módulo en el cual está la función que queremos ejecutar, como puede ser Ejemplo en este caso.
  • Un átomo que indique el nombre de la función a ejecutar, como buenos_dias.
  • Una lista con los distintos parámetros a aplicarle a la función, como ["Pepito"]. Es importante que la lista tenga tantos elementos como aridad tenga la función a invocar, ya que será invocada mediante algo que podríamos comparar a un apply.

El tipo de datos PID

Cuando hacemos esta llamada, obtenemos un tipo de datos nuevo denominado PID:

iex(6)> spawn(Ejemplo, :buenos_dias, ["Pepito"])
#PID<0.154.0>

El PID es un descriptor de proceso. Significa que la máquina virtual ha ejecutado otro proceso para invocar nuestro código, y nos devuelve un descriptor, que podemos ver como el identificador de ese proceso, mediante el cual podríamos obtener información sobre el mismo y, tal como veremos más adelante, intercambiar información con él.

Este proceso lo podemos asignar a una variable, incluso, y en el caso de usar la REPL iex, también podríamos imprimir más información llamando a información:

iex(7)> pid = spawn(Ejemplo, :buenos_dias, ["Pepito"])
#PID<0.156.0>
iex(8)> i(pid)
Term
  #PID<0.156.0>
Data type
  PID
Alive
  false
Description
  Use Process.info/1 to get more info about this process
Reference modules
  Process, Node
Implemented protocols
  IEx.Info, Inspect

Los PIDs también nos permiten llamar a algunas de las primitivas de Process para sacar más información.

Con Process.alive?/1 podemos saber si un proceso está vivo o no. Un proceso está vivo mientras la BEAM esté evaluando código en lo que le hayamos dado. Para el PID de nuestro ejemplo, nos va a decir que no porque la función que le hemos pasado es tan corta que se evalúa al instante.

iex(11)> Process.alive?(pid)
false

Ahora bien, imaginemos una función que no retorne nunca, como un bucle infinito:

defmodule Ejemplo do
  def infinito(), do: infinito()
end

En este caso, si hacemos un spawn de este proceso (atención a la lista vacía para representar la aridad 0 de Ejemplo.infinito/0):

iex(13)> pid = spawn(Ejemplo, :infinito, [])
#PID<0.170.0>

El proceso sí está vivo:

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

En este caso, la función Process.info/1 nos puede devolver más información sobre el proceso. Devuelve una estructura de datos con información sobre la llamada inicial, la llamada en la que actualmente se está haciendo la evaluación, el tamaño de la pila, información sobre el recolector de basura...

iex(15)> Process.info(pid)
[
  current_function: {Ejemplo, :infinito, 0},
  initial_call: {Ejemplo, :infinito, 0},
  status: :running,
  message_queue_len: 0,
  links: [],
  dictionary: [],
  trap_exit: false,
  error_handler: :error_handler,
  priority: :normal,
  group_leader: #PID<0.66.0>,
  total_heap_size: 233,
  heap_size: 233,
  stack_size: 1,
  reductions: 22997096000,
  garbage_collection: [
    max_heap_size: %{error_logger: true, kill: true, size: 0},
    min_bin_vheap_size: 46422,
    min_heap_size: 233,
    fullsweep_after: 65535,
    minor_gcs: 0
  ],
  suspending: []
]

Para finalizar esta macro-introducción, decir que en Elixir un proceso tiene forma de consultarse a sí mismo. La primitiva self() devolverá el PID del proceso actual. Podemos usar esto en nuestro código para obtener una referencia al propio proceso.

iex(19)> self()
#PID<0.107.0>
Lista de reproducción
  1. 1
    Cómo crear procesos
    11 minutos
  2. 2
    Cómo pasar mensajes entre procesos
    13 minutos
  3. 3
    Diccionario de un proceso y mantener un estado
    15 minutos
  4. 4
    Cómo enlazar procesos para detectar fallos
    13 minutos
  5. 5
    Cómo monitorizar procesos
    10 minutos
  6. 6
    ¿Qué es un GenServer?
    15 minutos
  7. 7
    Cómo enviar mensajes con un GenServer
    19 minutos
  8. 8
    Control de errores y gestión de un GenServer
    19 minutos
  9. 9
    Cómo renombrar procesos
    11 minutos
  10. 10
    Cómo crear un Supervisor Tree
    17 minutos
  11. 11
    Estrategias para trabajar con Supervisor
    18 minutos
  12. 12
    Estrategias para crear un Supervisor
    11 minutos
  13. 13
    Resumen sobre procesos OTP
    12 minutos
  14. 14
    Cómo usar Application
    12 minutos
  15. 15
    Ejemplo de Application con hijos
    14 minutos