OTP y GenServer en Elixir: la base de la concurrencia que no puedes ignorar

Cuando alguien dice que Elixir es bueno para sistemas concurrentes, en realidad está hablando de OTP. Open Telecom Platform es el conjunto de bibliotecas y convenciones que Ericsson desarrolló en los años 90 para construir sistemas de telecomunicaciones que no podían caerse. Elixir lo hereda de Erlang, y es la razón por la que el lenguaje puede manejar millones de procesos simultáneos sin que el sistema entero se desmorone cuando uno falla.

Procesos en la BEAM: la unidad básica

En la BEAM, un proceso no es un proceso del sistema operativo. Son muchísimo más ligeros: unos 2 KB de memoria por proceso, y el planificador de la VM los gestiona sin llamadas al kernel. Puedes lanzar cientos de miles sin problema.

# Lanzar un proceso simple
pid = spawn(fn ->
  IO.puts("Hola desde proceso #{inspect(self())}")
end)

# Enviar un mensaje
send(pid, {:mensaje, "datos"})

# Recibir mensajes
receive do
  {:mensaje, contenido} -> IO.puts("Recibido: #{contenido}")
  after 1000 -> IO.puts("Timeout")
end

Los procesos se comunican exclusivamente por paso de mensajes. No hay memoria compartida, no hay locks, no hay condiciones de carrera de las habituales. Cada proceso tiene su propio heap, su propio estado, y el único punto de contacto con el exterior es el buzón de mensajes.

GenServer: el patrón que lo cambia todo

Spawn es demasiado primitivo para código de producción. GenServer es la abstracción de OTP que encapsula el ciclo de vida de un proceso servidor: inicialización, manejo de llamadas síncronas, manejo de mensajes asíncronos, y limpieza al terminar.

defmodule Contador do
  use GenServer

  # API pública
  def start_link(valor_inicial) do
    GenServer.start_link(__MODULE__, valor_inicial, name: __MODULE__)
  end

  def incrementar do
    GenServer.cast(__MODULE__, :incrementar)
  end

  def valor_actual do
    GenServer.call(__MODULE__, :valor)
  end

  # Callbacks
  @impl true
  def init(valor_inicial) do
    {:ok, valor_inicial}
  end

  @impl true
  def handle_cast(:incrementar, estado) do
    {:noreply, estado + 1}
  end

  @impl true
  def handle_call(:valor, _from, estado) do
    {:reply, estado, estado}
  end
end

La diferencia entre call y cast es sencilla: call espera una respuesta (síncrono), cast no (asíncrono). El estado del proceso vive en los argumentos de los callbacks, sin variables globales, sin mutación.

Por qué funciona tan bien

El truco está en que cada GenServer es un proceso independiente con su propio estado encapsulado. Si tienes mil conexiones simultáneas, puedes tener mil GenServers, cada uno manejando una conexión. Fallan de forma aislada, sin afectar a los demás. Y ahí es donde entran los supervisores.

Supervisores: la tolerancia a fallos hecha sistema

Un Supervisor es un proceso especial cuyo único trabajo es vigilar otros procesos y reiniciarlos si fallan. La filosofía de OTP es "deja que los procesos fallen" en lugar de intentar manejar todos los errores posibles. Cuando algo va mal, el supervisor lo detecta y reinicia el proceso en un estado limpio.

defmodule MiApp.Supervisor do
  use Supervisor

  def start_link(opts) do
    Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @impl true
  def init(_opts) do
    children = [
      {Contador, 0},
      {MiWorker, []},
      {MiServicioHTTP, puerto: 4000}
    ]

    Supervisor.init(children, strategy: :one_for_one)
  end
end

Las estrategias de reinicio son las que definen el comportamiento ante fallos:

  • :one_for_one: solo reinicia el proceso que falló.
  • :one_for_all: si falla uno, reinicia todos los hijos.
  • :rest_for_one: reinicia el que falló y los que se iniciaron después de él.

La elección depende de si los procesos son independientes entre sí o tienen dependencias de orden.

DynamicSupervisor y Task.Supervisor

El Supervisor básico gestiona procesos conocidos en tiempo de compilación. Para procesos que se crean en tiempo de ejecución (por ejemplo, uno por cada petición de usuario), existe DynamicSupervisor:

# Iniciar el supervisor dinámico
{:ok, _} = DynamicSupervisor.start_link(strategy: :one_for_one, name: MiDynSup)

# Añadir procesos en tiempo de ejecución
{:ok, pid} = DynamicSupervisor.start_child(MiDynSup, {Contador, 0})

# Task.Supervisor para tareas puntuales
Task.Supervisor.start_child(MiTaskSup, fn ->
  procesar_trabajo_pesado()
end)

El árbol de supervisión completo

En una aplicación real, los supervisores forman un árbol. La raíz es el supervisor de la aplicación, que supervisa otros supervisores, que a su vez supervisan procesos de trabajo. Cuando arrancas una app Elixir con mix new --sup, el esqueleto de este árbol ya está generado.

Esta estructura jerárquica tiene una propiedad muy útil: los fallos se contienen. Un error en un proceso de bajo nivel no escala hacia arriba a no ser que el supervisor decida reiniciar su rama completa. Y si el propio supervisor falla demasiadas veces en poco tiempo (superando el umbral de max_restarts y max_seconds), escala al supervisor padre.

Para comparar cómo otros lenguajes gestionan la concurrencia sin este nivel de estructura, el modelo de goroutines de Go es interesante: más simple, pero sin la tolerancia a fallos automática que dan los supervisores OTP.

Registry: nombres para procesos dinámicos

Cuando tienes cientos de procesos GenServer activos, necesitas una forma de encontrarlos sin guardar sus PIDs manualmente. Registry resuelve esto:

# Registrar un proceso con un nombre dinámico
{:ok, _} = Registry.start_link(keys: :unique, name: MiRegistry)

# En el GenServer
def start_link(id) do
  GenServer.start_link(__MODULE__, id, name: via_tuple(id))
end

defp via_tuple(id) do
  {:via, Registry, {MiRegistry, id}}
end

# Buscar un proceso por su id
Registry.lookup(MiRegistry, "usuario_42")

OTP no es solo Elixir

Vale la pena recordar que OTP es parte de Erlang y lleva décadas en producción. Los principios que describes cuando hablas de GenServer o Supervisor no son ideas nuevas: son patrones que han sobrevivido en sistemas de telecomunicaciones con requisitos de disponibilidad del 99,9999%. Elixir los hace accesibles con una sintaxis mucho más agradable, pero la solidez viene de abajo.

Si quieres profundizar en la tolerancia a fallos y los árboles de supervisión, el siguiente artículo de esta serie explora los supervisores con más detalle. Y si te interesa comparar el modelo de concurrencia con sistemas de bajo nivel, el artículo sobre Rust 2024 muestra cómo se gestiona la seguridad de memoria y concurrencia desde un enfoque completamente distinto.

Imagen: Pexels / Digital Buggu

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP