La máquina BEAM

Entiende la arquitectura que hace de Elixir el lenguaje ideal para sistemas financieros distribuidos. ⏱️ 2.5 horas

¿Por qué BEAM para finanzas?

La BEAM (Bogdan/Björn's Erlang Abstract Machine) es la máquina virtual que ejecuta código Erlang y Elixir. Fue diseñada en los años 80 por Ericsson para sistemas de telecomunicaciones que necesitaban:

Estos requisitos son exactamente los mismos que tiene un sistema de distribución de datos financieros. Por eso empresas como Goldman Sachs, JP Morgan, y Klarna usan Erlang/Elixir en sus sistemas críticos.

📊 Comparación de contextos

Telecom (1980s): millones de llamadas simultáneas, cada una es un proceso independiente, si una falla las demás continúan.

Finanzas (hoy): millones de actualizaciones de precio, cada suscriptor es un proceso independiente, si uno falla los demás continúan recibiendo datos.

El modelo de actores

La BEAM implementa el modelo de actores, un paradigma de concurrencia donde:

  1. Todo es un proceso (actor)
  2. Los procesos no comparten memoria
  3. Los procesos se comunican mediante mensajes
  4. Cada proceso tiene su propio mailbox (cola de mensajes)

Comparación con otros modelos

Modelo Comunicación Problema principal
Threads + Mutex (C++) Memoria compartida Deadlocks, race conditions
Async/Await (Rust tokio) Canales, futures Complejidad, lifetimes
Goroutines (Go) Canales Sin supervisión, GC global
Actores (BEAM) Mensajes Cambio de mentalidad

Viniendo de Rust, el cambio mental más grande es que no hay ownership ni lifetimes. Los mensajes se copian entre procesos (con excepciones para binarios grandes), lo que elimina toda una categoría de bugs pero requiere pensar diferente sobre el rendimiento.

Anatomía de un proceso BEAM

Un proceso en la BEAM es extremadamente ligero comparado con un thread del sistema operativo:

Thread SO Proceso BEAM
Memoria inicial ~1 MB ~2.5 KB
Tiempo de creación ~100 μs ~1 μs
Context switch ~1-10 μs ~0.1 μs
Máximo práctico ~10,000 ~1,000,000+

Cada proceso tiene su propia:

# Ver información de un proceso
iex> pid = spawn(fn -> :timer.sleep(60_000) end)
#PID<0.123.0>

iex> Process.info(pid, [:memory, :heap_size, :stack_size, :message_queue_len])
[
  memory: 2688,           # bytes
  heap_size: 233,        # words
  stack_size: 3,         # words
  message_queue_len: 0
]

Schedulers y reduciones

La BEAM ejecuta procesos usando schedulers. Hay un scheduler por cada núcleo de CPU (por defecto), y cada scheduler ejecuta procesos de forma cooperativa.

A diferencia del scheduling preemptivo del SO, la BEAM usa reduciones: cada proceso puede ejecutar ~4000 reduciones (aproximadamente llamadas a funciones) antes de ceder el control. Esto garantiza baja latencia porque ningún proceso puede monopolizar un scheduler.

# Ver cuántos schedulers hay
iex> System.schedulers_online()
8

# Ver reduciones de un proceso
iex> self() |> Process.info(:reductions)
{:reductions, 1847}
💡 Implicación para baja latencia

Como ningún proceso puede bloquear un scheduler por más de ~4000 reduciones, la latencia de respuesta está naturalmente acotada. Esto es crítico para sistemas financieros donde un proceso lento no debe afectar a los demás.

Garbage collection per-process

Una de las características más importantes de la BEAM para baja latencia es que el garbage collector opera por proceso, no globalmente como en Java o Go.

Cuando un proceso necesita recolección de basura, solo ese proceso se pausa. Los demás procesos continúan ejecutándose normalmente. Esto significa que no hay "stop-the-world" GC pauses que afecten al sistema completo.

# Forzar GC en un proceso específico
iex> :erlang.garbage_collect(self())
true

# Ver estadísticas de GC
iex> :erlang.statistics(:garbage_collection)
{45, 12389, 0}  # {número de GCs, palabras reclamadas, 0}
⚠️ Procesos de larga vida

Si un proceso acumula mucho estado (como una caché), su GC puede volverse costoso. Veremos estrategias para esto en los capítulos de optimización, incluyendo el uso de ETS para datos compartidos.

Distribución nativa

La BEAM fue diseñada para sistemas distribuidos desde el principio. Conectar nodos BEAM es tan simple como:

# Terminal 1: iniciar nodo con nombre
$ iex --sname nodo1

# Terminal 2: iniciar otro nodo y conectar
$ iex --sname nodo2
iex(nodo2)> Node.connect(:nodo1@hostname)
true

# Ahora puedes enviar mensajes entre nodos
iex(nodo2)> Node.spawn(:nodo1@hostname, fn -> IO.puts("¡Hola desde nodo1!") end)

Enviar un mensaje a un proceso en otro nodo usa exactamente la misma sintaxis que enviar a un proceso local. La BEAM se encarga del serializado, transporte, y deserializado de forma transparente.

Visualizando la BEAM con Observer

La BEAM incluye Observer, una herramienta gráfica para inspeccionar el sistema en tiempo real:

iex> :observer.start()

Observer te muestra:

🔴 Observer es una aplicación Erlang

Observer está escrito en Erlang usando wxWidgets. Si no funciona, puede que necesites instalar dependencias gráficas. En Ubuntu: sudo apt install erlang-observer

El modelo de fallos: "Let it crash"

La filosofía de la BEAM es "let it crash": en lugar de intentar manejar todos los errores posibles con código defensivo, dejas que los procesos fallen y confías en supervisores para reiniciarlos.

Esto parece contraintuitivo viniendo de C++ o Rust donde se valora el manejo explícito de errores, pero tiene sentido cuando:

  1. Los procesos son baratos de crear y destruir
  2. Los procesos no comparten estado (el fallo es aislado)
  3. Los supervisores pueden aplicar estrategias de recuperación
  4. El código es más limpio al separar "happy path" de recuperación
# Este proceso fallará... y está bien
spawn(fn ->
  :este_atomo_no_existe.funcion()  # Error!
end)

# El proceso padre (iex) no se ve afectado
Ejercicio 1.1 Explorar procesos Básico
  1. Abre IEx y ejecuta :erlang.processes() |> length() para ver cuántos procesos hay
  2. Crea 50,000 procesos que duerman 10 segundos cada uno
  3. Verifica el nuevo conteo de procesos
  4. Usa :erlang.memory(:processes) antes y después para ver la memoria consumida
  5. Espera 10 segundos y verifica que vuelven a los valores iniciales

Pista: for _ <- 1..50_000, do: spawn(fn -> :timer.sleep(10_000) end)

Ejercicio 1.2 Medir latencia de spawn Intermedio

Escribe una función que mida el tiempo promedio de crear un proceso en microsegundos. Debe:

  1. Crear 100,000 procesos que simplemente hagan :ok
  2. Medir el tiempo total con :timer.tc/1
  3. Calcular el promedio por proceso
  4. Repetir 5 veces y mostrar el promedio de los promedios
defmodule SpawnBench do
  def run do
    # Tu código aquí
  end
end
Ejercicio 1.3 Observer en acción Básico
  1. Inicia Observer con :observer.start()
  2. Ve a la pestaña "System" y anota los valores de schedulers y memory
  3. En otra terminal IEx, crea 10,000 procesos que envíen mensajes entre sí
  4. Observa cómo cambian las métricas en tiempo real
  5. Ve a la pestaña "Processes" y ordena por "MsgQ" para ver acumulación

Conexión con el proyecto final

En el sistema de distribución de datos financieros que construiremos: