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:
- Alta disponibilidad: 99.9999999% uptime (nueve nueves)
- Concurrencia masiva: millones de conexiones simultáneas
- Baja latencia: respuestas en microsegundos
- Tolerancia a fallos: el sistema sigue funcionando aunque partes fallen
- Actualizaciones en caliente: desplegar código sin parar el sistema
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.
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:
- Todo es un proceso (actor)
- Los procesos no comparten memoria
- Los procesos se comunican mediante mensajes
- 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:
- Heap: memoria privada con garbage collector independiente
- Stack: para las llamadas a funciones
- Mailbox: cola de mensajes entrantes
- Process dictionary: almacenamiento key-value (usar con cuidado)
# 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}
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}
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:
- Gráficas de uso de CPU y memoria en tiempo real
- Lista de todos los procesos con su estado
- Árbol de supervisión de aplicaciones
- Tablas ETS y su contenido
- Información de nodos conectados
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:
- Los procesos son baratos de crear y destruir
- Los procesos no comparten estado (el fallo es aislado)
- Los supervisores pueden aplicar estrategias de recuperación
- 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
- Abre IEx y ejecuta
:erlang.processes() |> length()para ver cuántos procesos hay - Crea 50,000 procesos que duerman 10 segundos cada uno
- Verifica el nuevo conteo de procesos
- Usa
:erlang.memory(:processes)antes y después para ver la memoria consumida - Espera 10 segundos y verifica que vuelven a los valores iniciales
Pista: for _ <- 1..50_000, do: spawn(fn -> :timer.sleep(10_000) end)
Escribe una función que mida el tiempo promedio de crear un proceso en microsegundos. Debe:
- Crear 100,000 procesos que simplemente hagan
:ok - Medir el tiempo total con
:timer.tc/1 - Calcular el promedio por proceso
- Repetir 5 veces y mostrar el promedio de los promedios
defmodule SpawnBench do
def run do
# Tu código aquí
end
end
- Inicia Observer con
:observer.start() - Ve a la pestaña "System" y anota los valores de schedulers y memory
- En otra terminal IEx, crea 10,000 procesos que envíen mensajes entre sí
- Observa cómo cambian las métricas en tiempo real
- 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:
- Cada suscriptor será un proceso: si tienes 10,000 clientes conectados, tendrás 10,000 procesos. La BEAM maneja esto trivialmente.
- El GC per-process garantiza baja latencia: un suscriptor con mucho estado no afecta a los demás.
- La distribución nativa permite escalar: añadir nodos al cluster es trivial.
- Los supervisores mantienen el sistema vivo: si un suscriptor falla, se reinicia sin afectar al resto.