Un actor nunca comparte memoria con nadie: recibe un mensaje, actualiza su propio estado y responde, todo sin tocar una variable ajena. Esa restricción, que suena limitante, es la razón por la que Erlang sostiene centrales telefónicas y sistemas de mensajería sin caerse desde hace más de tres décadas.
El modelo de actores (actor model) es el paradigma de concurrencia detrás de Elixir/OTP, Akka y Microsoft Orleans. En esta guía vas a entender cómo funciona por dentro, vas a escribir tus primeros actores en Elixir y vas a aprender cuándo conviene elegirlo frente a hilos con locks o a goroutines.
TL;DR
- Entenderás por qué un actor nunca comparte memoria y por qué eso elimina los race conditions de raíz.- Vas a poder escribir un GenServer en Elixir que recibe mensajes y mantiene estado propio.- Sabrás diseñar un árbol de supervisión que reinicia procesos caídos automáticamente ("let it crash").- Podrás comparar el modelo de actores contra hilos con locks y contra goroutines/CSP para elegir el correcto.- Vas a identificar errores comunes: llamadas bloqueantes, mailboxes desbordadas y actores sin supervisor.- Sabrás verificar en vivo cuántos actores corren y si un supervisor reinició alguno.- Conocerás casos reales donde el modelo de actores sostiene sistemas de mensajería a gran escala.
Qué es el modelo de actores y por qué importa
El concepto lo formalizó Carl Hewitt en 1973 como un modelo matemático de cómputo concurrente basado en el envío de mensajes, mucho antes de que existieran los procesadores multinúcleo actuales. La implementación que lo hizo famoso llegó más de una década después: Erlang, creado en 1986 en los laboratorios de Ericsson por Joe Armstrong, Robert Virding y Mike Williams para sostener centrales telefónicas que no podían caerse.
El problema que resuelve el modelo de actores es viejo: cuando dos hilos comparten una misma estructura de datos en memoria, cualquier acceso simultáneo sin coordinación produce un race condition. La solución clásica son los locks (mutex, semáforos), pero los locks traen su propio costo: si dos hilos toman dos locks en orden distinto, el programa se congela en un deadlock. El modelo de actores ataca el problema desde otro ángulo: si nadie comparte memoria, no hace falta ningún lock.
Un actor es la unidad mínima de cómputo del modelo: tiene una identidad propia, un estado privado que nadie más puede leer ni escribir directamente, y una cola de mensajes llamada mailbox. La única forma de interactuar con un actor es enviarle un mensaje; la única forma en que un actor responde es enviando otro mensaje. No hay punteros compartidos, no hay variables globales, no hay memoria que dos actores puedan pisar al mismo tiempo.
Erlang corre cada actor como proceso liviano de la BEAM, no como hilo del sistema operativo.
Cómo funciona por dentro
Cada actor procesa su mailbox de a un mensaje por vez, en orden de llegada. Eso significa que, dentro de un mismo actor, nunca hay concurrencia real: el código de un actor se ejecuta siempre de forma secuencial. La concurrencia aparece entre actores distintos, que sí corren en paralelo unos con otros, cada uno aislado en su propia burbuja de estado.
Cuando un actor recibe un mensaje puede hacer tres cosas, y solo tres: enviar mensajes a otros actores (incluido a sí mismo), crear nuevos actores, o decidir con qué comportamiento va a procesar el siguiente mensaje que le llegue. Esa simplicidad es deliberada: al reducir las operaciones posibles, el modelo elimina categorías enteras de bugs de concurrencia antes de que puedan existir.
Otra pieza clave es la transparencia de ubicación: enviarle un mensaje a un actor que corre en el mismo nodo o a uno que corre en otra máquina de un cluster es, en el código, exactamente la misma operación. Esto es lo que permite que Akka y Erlang escalen de un solo proceso a un cluster distribuido sin reescribir la lógica de negocio.
flowchart LR
A["Actor A"] -->|"envia mensaje"| B["Mailbox de Actor B"]
B --> C["Actor B procesa uno a la vez"]
C --> D["Actor B actualiza su estado privado"]
D -->|"responde"| A
Actores en código: de hola mundo a un actor supervisado
El ejemplo más simple posible es un contador. En Elixir, cada actor se implementa con el comportamiento GenServer, que define de forma estándar cómo un proceso arranca, recibe mensajes y guarda estado:
defmodule Contador do
use GenServer
# API publica
def start_link(valor_inicial \\ 0) 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_actual)
end
# Callbacks del actor
def init(valor_inicial) do
{:ok, valor_inicial}
end
def handle_cast(:incrementar, estado) do
{:noreply, estado + 1}
end
def handle_call(:valor_actual, _from, estado) do
{:reply, estado, estado}
end
end
GenServer.cast es asíncrono: envía el mensaje a la mailbox y sigue sin esperar respuesta. GenServer.call es síncrono: bloquea a quien llama hasta que el actor responde o expira el timeout. El estado (estado) vive solo dentro de este proceso; ningún otro actor puede leerlo ni modificarlo directamente.
Un caso más realista añade supervisión: un pool de workers que procesan tareas y que, si uno falla, se reinicia solo sin tirar abajo a los demás.
defmodule PoolWorkers.Application do
use Application
def start(_type, _args) do
children = [
{DynamicSupervisor, name: PoolWorkers.Supervisor, strategy: :one_for_one}
]
Supervisor.start_link(children, strategy: :one_for_one, name: PoolWorkers.Root)
end
end
defmodule PoolWorkers.Procesador do
use GenServer, restart: :transient
def start_link(tarea) do
GenServer.start_link(__MODULE__, tarea)
end
def init(tarea) do
send(self(), :procesar)
{:ok, tarea}
end
def handle_info(:procesar, tarea) do
resultado = Jason.decode!(tarea.payload)
{:noreply, %{tarea | resultado: resultado}}
end
end
# Arrancar un worker bajo el supervisor dinamico
DynamicSupervisor.start_child(
PoolWorkers.Supervisor,
{PoolWorkers.Procesador, %{payload: "{\"id\": 42}", resultado: nil}}
)
Si el payload llega malformado, Jason.decode! lanza una excepción y ese worker muere. Con restart: :transient, el DynamicSupervisor solo lo reinicia si la salida fue anormal, no si terminó a propósito, y ningún otro worker del pool se entera del fallo.
sequenceDiagram
participant S as Supervisor
participant W as Worker
S->>W: inicia proceso enlazado
W-->>S: confirma inicio
Note over W: recibe un payload invalido y falla
W-->>S: senal de salida (exit)
S->>W: reinicia con estado limpio
W-->>S: nuevo proceso activo
Cómo empezar
Para probar esto hoy solo hace falta Erlang y Elixir instalados. En Debian/Ubuntu:
sudo apt update
sudo apt install -y erlang elixir
elixir -v
Después, creá un proyecto nuevo, pegá el módulo Contador en lib/demo_actores.ex y arrancá una consola interactiva conectada a tu aplicación:
mix new demo_actores
cd demo_actores
iex -S mix
Dentro de iex, probá el ciclo completo de vida del actor:
iex> Contador.start_link()
iex> Contador.incrementar()
iex> Contador.valor_actual()
#=> 1
💡 Tip: Si necesitás decidir rápido entre paradigmas, preguntate si tu problema es "muchos estados independientes que pueden fallar por separado" (modelo de actores) o "un pipeline de datos que fluye en una dirección" (CSP/channels).
Casos de uso reales
Akka lleva el modelo de actores a la JVM (Scala y Java) y lo usan sistemas financieros y de trading que necesitan tolerancia a fallos sin perder rendimiento. Microsoft Orleans implementa actores distribuidos en .NET y es el framework que sostiene el backend de Xbox Live y de Halo, donde cada jugador o sesión de partida se modela como un actor (un "grain", en la terminología de Orleans) independiente.
En Elixir, el mismo modelo se usa en sistemas de mensajería en tiempo real: cada conexión de usuario, cada sala de chat o cada canal de notificaciones puede vivir como un actor propio, aislado del resto, lo que hace que la caída de una conexión individual no afecte a las demás.
Orleans, el framework de actores de Microsoft, sostiene el backend de Xbox Live.
Errores comunes y buenas prácticas
El error más frecuente es encadenar llamadas síncronas entre actores: si el actor A hace GenServer.call al actor B, y B a su vez hace call a A antes de responderle, ambos quedan esperándose mutuamente hasta el timeout. La regla práctica es evitar que un actor llame de vuelta, de forma síncrona, a quien lo está llamando.
El segundo error es no medir el tamaño de la mailbox. Si un actor recibe mensajes más rápido de lo que los procesa, la cola crece sin límite por defecto y puede agotar la memoria del nodo entero, no solo la de ese actor. Distribuir la carga entre varios actores (un pool) o aplicar backpressure explícito resuelve esto.
El tercer error es meter trabajo pesado de CPU dentro de un único actor crítico: como cada actor procesa su mailbox en serie, un cómputo largo bloquea a todos los mensajes que esperan detrás en esa misma cola, aunque el resto del sistema siga corriendo en paralelo.
⚠️ Ojo:
GenServer.call/2es sincrónico y bloquea al actor que llama: si dos actores se llaman entre sí concall, podés terminar en un deadlock real, no solo en teoría.
Comparativa con alternativas
OpciónCuándo usarlaVentajaLimitaciónModelo de actores (Erlang/OTP, Akka, Orleans)Sistemas distribuidos con miles de procesos independientes y necesidad de tolerancia a fallosAislamiento total: un actor no puede corromper el estado de otroCurva de aprendizaje del paradigma y del "let it crash"Hilos + locks (mutex, semáforos)Código que ya comparte estructuras de datos y no se puede rediseñarControl fino sobre el hardware y buen rendimiento dentro de un solo procesoDeadlocks y race conditions si un lock se olvida o se toma en el orden equivocadoCSP / goroutines y channels (Go)Pipelines de datos donde el flujo importa más que la identidad de cada unidadSintaxis liviana, channels tipados en el propio lenguajeNo trae supervisión ni reinicio automático incorporadoAsync/await (JavaScript, Python asyncio)I/O concurrente en un solo hilo, como servidores webModelo mental simple para código secuencial con esperasUn bloque síncrono pesado congela todo el event loop
Profundizando
La idea que distingue a Erlang/OTP del resto no es el actor en sí, sino el árbol de supervisión: cada actor nace bajo un supervisor, y ese supervisor decide qué hacer si el actor falla (reiniciarlo solo, reiniciar a todos sus hermanos, o propagar el fallo hacia arriba). Esta filosofía se conoce como "let it crash": en vez de defender cada línea de código con validaciones defensivas, se deja que el proceso muera limpio y se confía en que el supervisor lo traiga de vuelta con estado fresco.
flowchart TD
A["Supervisor raiz"] --> B["Supervisor de conexiones"]
A --> C["Supervisor de workers"]
B --> D["Actor: conexion 1"]
B --> E["Actor: conexion 2"]
C --> F["Actor: worker 1"]
C --> G["Actor: worker 2"]
Para confirmar en vivo que un supervisor efectivamente reinició un proceso, se puede abrir la interfaz gráfica de observación que trae la BEAM o consultar el conteo de hijos del supervisor:
iex> :observer.start()
iex> Supervisor.count_children(PoolWorkers.Supervisor)
#=> %{active: 4, specs: 4, supervisors: 0, workers: 4}
Otro concepto avanzado es la transparencia de ubicación mencionada antes: en un cluster de varios nodos, un actor puede migrar de máquina o replicarse, y el código que le envía mensajes no necesita saber dónde vive físicamente. Esto es lo que permite que Orleans reubique un "grain" a otro servidor sin que el resto del sistema note la diferencia.
💭 Clave: "Let it crash" no significa ignorar errores: significa que el supervisor, no el propio actor, es responsable de decidir qué hacer cuando algo falla.
📖 Resumen en Telegram: Ver resumen
Tu próximo paso: instalá Erlang y Elixir, copiá el módulo Contador de esta guía en un proyecto con mix new y hacelo fallar a propósito enviándole un mensaje inválido para ver cómo reacciona sin supervisor antes de agregarle uno.
Preguntas frecuentes
¿El modelo de actores es lo mismo que los hilos de un sistema operativo?
No. Un actor en Erlang/OTP es un proceso liviano de la máquina virtual BEAM, no un hilo del sistema operativo: se pueden correr cientos de miles de actores en una sola máquina porque cada uno ocupa muy poca memoria.
¿Qué diferencia hay entre GenServer.cast y GenServer.call?
cast envía un mensaje asíncrono y no espera respuesta; call envía un mensaje y bloquea a quien llama hasta recibir una respuesta o hasta que expire el timeout.
¿Se puede usar el modelo de actores fuera de Erlang y Elixir?
Sí: Akka lo implementa en Scala y Java sobre la JVM, y Microsoft Orleans lo implementa en .NET, usado en el backend de Xbox Live.
¿Qué pasa si un actor recibe mensajes más rápido de lo que puede procesarlos?
Su mailbox crece sin límite por defecto y puede agotar la memoria del sistema; hay que medir el largo de la cola y aplicar backpressure o repartir el trabajo entre más actores.
¿El modelo de actores elimina todos los bugs de concurrencia?
No elimina bugs de lógica ni deadlocks si se abusa de llamadas síncronas entre actores, pero sí elimina por diseño los race conditions sobre memoria compartida, porque esa memoria compartida no existe.
¿Cómo se depura un sistema de actores en producción?
Con :observer.start() en desarrollo para ver procesos y mailboxes en vivo, y con logs estructurados por identificador de proceso en producción, ya que no hay un stack trace único compartido entre actores.
Referencias
- Wikipedia: Actor model: historia y definición formal del modelo, formulado por Carl Hewitt en 1973.- Wikipedia: Erlang (programming language): origen del lenguaje en Ericsson y su relación con el modelo de actores.- Erlang.org: documentación oficial de Erlang/OTP, la implementación más influyente del modelo.- HexDocs: GenServer: referencia del módulo usado en los ejemplos de código de esta guía.- GitHub: dotnet/orleans: repositorio del framework de actores distribuidos usado en el backend de Xbox Live.
📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.
Top comments (0)