StackPractices
advanced Por Mathias Paulenko

Patrón Actor Model

Aislar estado en actores que se comunican solo via mensajes. Cada actor procesa un mensaje a la vez, eliminando bugs de concurrencia por estado compartido por diseno.

Descripción General

El estado mutable compartido es la raiz de la mayoria de los bugs de concurrencia. Locks, mutexes y condition variables son propensos a errores: olvida un lock y obtienes race conditions; bloquea en el orden equivocado y obtienes deadlocks. El Actor Model toma un enfoque diferente. Cada actor posee su estado privado. Los actores se comunican exclusivamente enviando mensajes entre si. Cada actor procesa un mensaje a la vez, por lo que no hay acceso concurrente a su estado. Sin locks.

Cuándo Usar

  • For alternatives, see Distributed Lock Pattern.

  • Tienes estado mutable accedido por multiples threads y los locks son propensos a errores

  • Necesitas tolerancia a fallos: si un actor falla, otros continuan ejecutando

  • Tu sistema se modela naturalmente como entidades independientes que se comunican por mensajes

  • Quieres transparencia de ubicacion: los actores pueden ejecutar en la misma maquina o a traves de una red

Solución

Python (asyncio + message passing)

import asyncio

class Actor:
    def __init__(self, name):
        self.name = name
        self.inbox = asyncio.Queue()
        self.state = {}

    async def run(self):
        while True:
            message = await self.inbox.get()
            if message.get("type") == "stop":
                print(f"[{self.name}] Stopping")
                break
            await self.handle(message)

    async def handle(self, message):
        msg_type = message.get("type")
        if msg_type == "deposit":
            amount = message["amount"]
            self.state["balance"] = self.state.get("balance", 0) + amount
            print(f"[{self.name}] Balance: {self.state['balance']}")
        elif msg_type == "withdraw":
            amount = message["amount"]
            if self.state.get("balance", 0) >= amount:
                self.state["balance"] -= amount
                print(f"[{self.name}] Withdrew {amount}, balance: {self.state['balance']}")
            else:
                print(f"[{self.name}] Insufficient funds")

    def send(self, message):
        self.inbox.put_nowait(message)


async def main():
    account = Actor("account-1")
    task = asyncio.create_task(account.run())

    # Enviar mensajes: sin locks, el actor procesa uno a la vez
    account.send({"type": "deposit", "amount": 100})
    account.send({"type": "deposit", "amount": 50})
    account.send({"type": "withdraw", "amount": 30})
    account.send({"type": "withdraw", "amount": 200})  # Insufficient
    account.send({"type": "stop"})

    await task

asyncio.run(main())

JavaScript (worker threads como actores)

import { Worker, isMainThread, parentPort, workerData } from "worker_threads";
import { fileURLToPath } from "url";
import path from "path";

// actor.js — se ejecuta en un Worker thread con estado aislado
if (!isMainThread) {
  const state = { balance: 0 };

  parentPort.on("message", (message) => {
    if (message.type === "stop") {
      parentPort.close();
      return;
    }

    if (message.type === "deposit") {
      state.balance += message.amount;
      parentPort.postMessage({
        type: "result",
        balance: state.balance,
      });
    } else if (message.type === "withdraw") {
      if (state.balance >= message.amount) {
        state.balance -= message.amount;
        parentPort.postMessage({
          type: "result",
          balance: state.balance,
        });
      } else {
        parentPort.postMessage({
          type: "error",
          message: "Insufficient funds",
        });
      }
    }
  });
}

// main.js
if (isMainThread) {
  const __filename = fileURLToPath(import.meta.url);
  const actor = new Worker(__filename);

  actor.on("message", (msg) => {
    if (msg.type === "result") {
      console.log(`Balance: ${msg.balance}`);
    } else if (msg.type === "error") {
      console.log(`Error: ${msg.message}`);
    }
  });

  // Enviar mensajes: sin locks, el actor procesa uno a la vez
  actor.postMessage({ type: "deposit", amount: 100 });
  actor.postMessage({ type: "deposit", amount: 50 });
  actor.postMessage({ type: "withdraw", amount: 30 });
  actor.postMessage({ type: "withdraw", amount: 200 }); // Insufficient
  actor.postMessage({ type: "stop" });
}

Java (estilo Akka)

import java.util.concurrent.*;

public class ActorModelExample {

    // Base Actor: procesa mensajes desde una cola single-threaded
    static abstract class Actor {
        private final BlockingQueue<Object> mailbox = new LinkedBlockingQueue<>();
        private final String name;
        private volatile boolean running = true;

        public Actor(String name) {
            this.name = name;
        }

        public void start() {
            new Thread(() -> {
                while (running) {
                    try {
                        Object message = mailbox.take();
                        if (message instanceof StopSignal) {
                            running = false;
                            break;
                        }
                        onMessage(message);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        break;
                    }
                }
                System.out.println("[" + name + "] Stopped");
            }, "actor-" + name).start();
        }

        public void tell(Object message) {
            mailbox.offer(message);
        }

        protected abstract void onMessage(Object message);
    }

    // Actor de cuenta bancaria con estado privado
    static class AccountActor extends Actor {
        private int balance = 0;

        public AccountActor(String name) {
            super(name);
        }

        @Override
        protected void onMessage(Object message) {
            if (message instanceof Deposit dep) {
                balance += dep.amount();
                System.out.println("[account] Balance: " + balance);
            } else if (message instanceof Withdraw w) {
                if (balance >= w.amount()) {
                    balance -= w.amount();
                    System.out.println("[account] Withdrew " + w.amount() + ", balance: " + balance);
                } else {
                    System.out.println("[account] Insufficient funds");
                }
            }
        }
    }

    record Deposit(int amount) {}
    record Withdraw(int amount) {}
    record StopSignal() {}

    public static void main(String[] args) throws Exception {
        AccountActor account = new AccountActor("account-1");
        account.start();

        // Enviar mensajes: sin locks, el actor procesa uno a la vez
        account.tell(new Deposit(100));
        account.tell(new Deposit(50));
        account.tell(new Withdraw(30));
        account.tell(new Withdraw(200)); // Insufficient
        Thread.sleep(100);
        account.tell(new StopSignal());
    }
}

Explicación

Un actor es una entidad con tres propiedades:

  1. Estado privado: Solo el actor mismo puede leer o modificar su estado. Ningun codigo externo lo toca.
  2. Buzon: Una cola de mensajes entrantes. Los mensajes se entregan asincronamente.
  3. Procesamiento single-threaded: El actor procesa un mensaje a la vez desde su buzon. Esto garantiza que no hay acceso concurrente a su estado.

Los actores se comunican enviando mensajes a los buzones de otros. El emisor no espera que el receptor procese el mensaje (fire-and-forget). Si se necesita una respuesta, el emisor incluye una direccion de respuesta en el mensaje.

Como cada actor procesa mensajes secuencialmente, no hay race conditions dentro de un actor. Como el estado es privado, no hay bugs de estado compartido. La contrapartida es que todo acceso al estado debe ir a traves de paso de mensajes, lo que anade latencia comparado con llamadas a metodos directas.

Variantes

VarianteImplementacionCaso de UsoCompromiso
AkkaJVM (Scala/Java)Enterprise, sistemas distribuidosFramework pesado, solo JVM
Erlang/OTPBEAM VMTelecom, alta disponibilidadLenguaje Erlang
asyncio actorsPython asyncioConcurrencia single-processSingle-threaded, sin paralelismo real
Worker threadsNode.jsAislamiento CPU-boundTipos de mensaje limitados
Proto ActorGo, .NETActores cross-platformEcosistema mas pequeño

Qué Funciona

  • Manten los actores pequeños y enfocados: una responsabilidad por actor
  • Haz los mensajes inmutables: previene que el emisor modifique un mensaje despues de enviarlo
  • Usa actores supervisores para monitorear y reiniciar actores fallidos (filosofia “let it crash” de Erlang)
  • Disena mensajes como records o clases tipadas para claridad
  • Evita operaciones bloqueantes dentro de un actor: si necesitas llamar una API lenta, spawn un worker temporal
  • Monitorea la profundidad del buzon: un buzon creciente significa que el actor no da abasto
  • Usa jerarquias de actores: los supervisores gestionan actores hijos y manejan sus fallos

Errores Comunes

  • Compartir estado mutable fuera del actor: Si codigo externo modifica el estado del actor, la garantia de aislamiento se rompe. Todo acceso al estado debe ir por mensajes.
  • Bloquear dentro del actor: Una llamada bloqueante (I/O sincrono, computacion larga) detiene al actor de procesar otros mensajes. Descarga a un worker.
  • Request-reply sincrono: Los actores estan disenados para mensajeria async. Forzar llamadas sincronas crea deadlocks y reduce throughput.
  • Demasiados actores de grano fino: Cada actor tiene overhead (buzon, thread). Demasiados actores desperdician memoria y context-switching. Agrupa estado relacionado en un actor.
  • No manejar fallos: Si un actor crasheaa, los mensajes de su buzon se pierden.
  • Mensajes grandes: Enviar objetos grandes entre actores (especialmente a traves de red) es costoso. Envia referencias o IDs.

Lectura Adicional

  • Documentación oficial: consulta la referencia actualizada del framework o herramienta utilizada.
  • Guías relacionadas: explora las guías de pattern y design-pattern para profundizar.
  • Patrones complementarios: revisa los patrones de diseño aplicables a tu stack tecnológico.
  • Postmortems públicos: estudia incidentes reales de equipos que enfrentaron problemas similares en producción.

Notas de Producción

  • Despliega gradualmente usando canary o blue-green para detectar regresiones temprano.
  • Configura alertas para errores, latencia p99 y tasa de fallos antes de habilitar en producción.
  • Documenta el rollback en el runbook; prueba el procedimiento en staging al menos una vez por trimestre.
  • Revisa logs estructurados con correlation IDs para trazar requests end-to-end en incidentes.

Puntos Clave

  • Aplica patrón actor model cuando necesites una solución práctica para tu caso de uso.
  • Monitorea el rendimiento después de implementar; mide latencia, errores y uso de recursos antes y después.
  • Revisa la sección de Troubleshooting ante errores comunes; la mayoría tienen causa raíz documentada con solución.
  • Mantén dependencias actualizadas y ejecuta tests en CI para prevenir regresiones en producción.

Troubleshooting

  • Race conditions appear under load: protect shared state with locks, atomics, or message passing. Reproduce with targeted stress tests.
  • Deadlock between workers: establish a consistent lock acquisition order and keep critical sections short.
  • Thread pool saturation: Increase pool size only if CPU and memory allow.
  • Actor mailbox grows unbounded: apply backpressure, bounded queues, and load shedding.
  • Async task never completes: check for unhandled promise rejections, forgotten awaits, and infinite loops in cooperative scheduling.

Errores Comunes en Producción

  • Aplicar el patrón donde no se necesita abstracción, agregando complejidad accidental.
  • Dejar que el patrón se filtre en módulos no relacionados y confundir los límites de responsabilidad.
  • Sobre-ingeniería en la primera implementación en lugar de comenzar simple y medir el dolor.
  • Saltar los tests de contrato, de modo que las refactorizaciones rompan consumidores en silencio.
  • Ignorar modos de fallo que el patrón no cubre.
  • Usar el patrón como opción por defecto en lugar de elegir la herramienta adecuada para la escala actual.
  • Olvidar documentar cuándo dejar de usar el patrón y qué lo reemplaza.
  • Carecer de observabilidad sobre rendimiento y propagación de errores del patrón.

Preguntas frecuentes

¿En qué se diferencia el actor model de los thread pools?

Los thread pools comparten estado entre threads y requieren locks para seguridad. Los actores aislan estado por actor y usan paso de mensajes en lugar de locks. Los thread pools son mejores para trabajo paralelo sin estado; los actores son mejores para entidades concurrentes con estado.

¿Los actores pueden ejecutar en diferentes máquinas?

Si. Frameworks de actores como Akka y Erlang/OTP soportan transparencia de ubicacion. Envias un mensaje a una direccion de actor; el framework lo rutea localmente o por la red. El emisor no sabe ni le importa donde se ejecuta el actor.

¿Qué pasa si un actor crashea?

En Erlang/OTP y Akka, un actor supervisor detecta el fallo y reinicia al actor hijo. El actor reiniciado arranca con estado fresco. El estado critico debe persistirse externamente (base de datos, event log) antes del procesamiento, para poder recuperarlo.

¿El actor model es más rápido que memoria compartida?

Para escenarios de baja contencion, memoria compartida con locks es mas rapido porque el acceso directo es mas barato que el paso de mensajes. Para escenarios de alta contencion, los actores pueden ser mas rapidos porque evitan contencion de locks y context-switching de threads bloqueados.

¿Debería usar actores para todo?

No. Los actores anaden overhead (serializacion de mensajes, gestion de buzones). Para computacion paralela simple sin estado compartido, thread pools o async/await son mas simples y rapidos. Usa actores cuando tienes entidades concurrentes con estado que necesitan aislamiento y tolerancia a fallos.