wandres.dev
ACTORES DISTRIBUIDOS · más allá de una máquina

El modelo de Hewitt en serio: tres verbos y ninguna trampa

El modelo de actores se suele resumir como una lista de tres capacidades y se debería leer como una lista de prohibiciones. Esta lección formaliza el comportamiento de un actor como función total hacia un triple de efectos, deriva la ley de localidad y la reconoce como el origen de la seguridad por capacidades, separa con cuidado la entrega garantizada del orden garantizado para llegar al no determinismo no acotado que hizo falta una semántica denotacional nueva para modelar, y sitúa el modelo frente a CSP y al cálculo pi para exhibir qué está eligiendo cada uno cuando decide nombrar al proceso o nombrar al canal.

⏱ 22 min

El modelo de actores cabe en una frase que casi todo el mundo repite mal: un actor puede crear actores, enviar mensajes y designar el comportamiento con el que atenderá el siguiente. Parece una lista de funcionalidades y es una lista de prohibiciones, porque lo que no aparece en ella está vetado. No hay lectura síncrona. No hay memoria común. No hay forma de dirigirse a alguien cuya dirección no te hayan entregado. No hay cota superior al tiempo que un mensaje puede tardar en llegar. Tomar esa parquedad en serio —formalizarla en vez de parafrasearla— revela que de tres verbos y un axioma de entrega se deducen propiedades que ningún modelo basado en memoria compartida puede siquiera enunciar.

🎯 Al terminar esta lección sabrás
  • Formalizar el comportamiento de un actor como función total hacia un triple de efectos.
  • Derivar la ley de localidad y reconocerla como el origen de la seguridad por capacidades.
  • Distinguir la entrega garantizada del orden garantizado y deducir el no determinismo no acotado.
  • Situar el modelo frente a CSP y al cálculo pi para ver qué elige cada uno al nombrar.

El comportamiento como función total

En el modelo puro un actor no tiene estado: tiene un comportamiento. Y un comportamiento no es un objeto con campos, sino una función total que recibe un mensaje y devuelve exactamente tres cosas: un conjunto finito de mensajes a enviar, un conjunto finito de actores a crear y el comportamiento con el que se atenderá el siguiente mensaje. La firma lo dice todo mejor que cualquier párrafo.

type Direccion = symbol

type Mensaje =
  | { readonly tipo: 'incrementar' }
  | { readonly tipo: 'leer'; readonly responder: Direccion }
  | { readonly tipo: 'valor'; readonly valor: number }

type Efecto = {
  readonly envios: ReadonlyArray<readonly [Direccion, Mensaje]>
  readonly creados: ReadonlyArray<Comportamiento>
  readonly siguiente: Comportamiento
}

type Comportamiento = (mensaje: Mensaje, yo: Direccion) => Efecto

Tres adjetivos de esa firma cargan con todo el peso. Es total: el actor debe poder responder a cualquier mensaje de su protocolo, de modo que ignorar uno es una decisión declarada y no un accidente del control de flujo. Es finita: cada turno produce un número acotado de envíos y de creaciones, lo cual garantiza que un turno termina y hace analizable el sistema turno a turno. Y es pura: no ejecuta efectos, los describe. El runtime es quien los interpreta, y por eso el mismo comportamiento puede correr en un bucle de eventos, en un hilo o en otra máquina sin que su definición cambie una línea.

const contador = (valor: number): Comportamiento => (mensaje) => {
  if (mensaje.tipo === 'incrementar') {
    return { envios: [], creados: [], siguiente: contador(valor + 1) }
  }
  if (mensaje.tipo === 'leer') {
    return {
      envios: [[mensaje.responder, { tipo: 'valor', valor }]],
      creados: [],
      siguiente: contador(valor),
    }
  }
  return { envios: [], creados: [], siguiente: contador(valor) }
}

Fíjate en que valor no vive en ningún campo mutable: vive en el cierre del comportamiento, y cambiar de valor es devolver otro comportamiento. Esa reformulación no es un rodeo estilístico, es la clausura del sistema: si mutar significase asignar, habría que preguntarse quién más puede asignar; si mutar significa designar, la pregunta desaparece por construcción.

ℹ️
Un estado de la maquina ES un comportamiento

La correspondencia con lo que ya sabes es exacta y conviene enunciarla sin metáforas. Un estado nombrado de una máquina es un comportamiento: define cómo se responde al próximo evento. El mapa de estados es una familia de comportamientos indexada por nombre, y el contexto es el parámetro que los cierra, igual que valor cierra a contador. Una máquina de estados no es un caso especial de actor por analogía: es literalmente un comportamiento escrito de forma tabulada en vez de funcional.

La ley de localidad: la dirección es una capacidad

Los tres verbos dicen qué puede hacer un actor. La ley de localidad dice con quién. Durante un turno, un actor solo puede enviar mensajes a direcciones que cumplan una de tres condiciones: venían dentro del mensaje recibido, las creó él mismo en ese turno, o ya las conocía porque estaban en su comportamiento. No existe un directorio global, no se puede adivinar una dirección, no hay aritmética de direcciones. El grafo de comunicación solo crece por transmisión explícita.

type Conocidas = ReadonlySet<Direccion>

function conocidasTrasElTurno(
  previas: Conocidas,
  llegadas: ReadonlyArray<Direccion>,
  creadas: ReadonlyArray<Direccion>,
): Conocidas {
  return new Set([...previas, ...llegadas, ...creadas])
}
🔑

La dirección es autoridad

Tener una dirección no es saber dónde está alguien: es poder actuar sobre él. Referencia y permiso son la misma cosa, que es justo la definición de capacidad.

🧭

Sin ambiente global

Nadie puede alcanzar a nadie por el mero hecho de existir en el mismo proceso. La autoridad no se hereda del entorno, se recibe de un remitente concreto.

🔒

Confinamiento demostrable

Si nunca das la dirección de un actor a un tercero, se puede probar que ese tercero jamás lo alcanzará. Es una propiedad del modelo, no una convención del equipo.

De aquí se sigue una lectura incómoda de las comodidades modernas. Un registro global que permita localizar a cualquier actor por un identificador textual —el systemId de XState, el registro de nombres de Erlang, el directorio de granos de Orleans— reintroduce exactamente la autoridad ambiental que la ley de localidad prohíbe: cualquiera que conozca la cadena de texto adquiere la capacidad. Es una traición pragmática y muchas veces correcta, porque el arranque de un sistema real necesita algún punto fijo, pero conviene tratarla como lo que es. Cada nombre global es una puerta que ya no puedes cerrar con un argumento estructural, solo con disciplina.

Entrega garantizada, orden no garantizado

El modelo postula que todo mensaje enviado acaba llegando. Y postula, con igual firmeza, que no hay cota alguna sobre cuánto tarda ni ordenación alguna entre mensajes de emisores distintos. Confundir ambas cláusulas es el error conceptual más caro del área, porque de su combinación sale una propiedad que la mayoría de los modelos de concurrencia no admite: el no determinismo no acotado.

// La entrega esta garantizada, luego el mensaje leer llega SIEMPRE
// y el actor se detiene: el programa termina en todas las ejecuciones.
// Pero el retardo no tiene cota, luego el total devuelto no tiene cota.
const detenido: Comportamiento = () => ({ envios: [], creados: [], siguiente: detenido })

const contando = (n: number): Comportamiento => (mensaje, yo) => {
  if (mensaje.tipo === 'leer') {
    return {
      envios: [[mensaje.responder, { tipo: 'valor', valor: n }]],
      creados: [],
      siguiente: detenido,
    }
  }
  return { envios: [[yo, { tipo: 'incrementar' }]], creados: [], siguiente: contando(n + 1) }
}

Este programa siempre termina y su resultado no está acotado por ninguna función computable de la entrada. La semántica de comandos guardados de Dijkstra, construida sobre precondiciones más débiles, asume no determinismo acotado: si un programa termina siempre, existe una cota sobre sus desenlaces. El actor de arriba refuta esa asunción, y por eso Clinger tuvo que levantar en 1981 una semántica denotacional sobre dominios de potencia para darle sentido. La consecuencia práctica llega intacta hasta tu banco de pruebas: cualquier test que afirme que tras N turnos el sistema ya se estabilizó está asumiendo una cota que el modelo no concede. Los actores se prueban observando quiescencia y no contando turnos.

flowchart LR
R1[llega m1 a A] --> S1[A envia m2 a B]
R1 --> R2[llega m3 a A]
S1 --> R3[llega m2 a B]
R3 --> S2[B envia m4 a C]
R2 --> S3[A envia m5 a C]
S2 --> X[orden de llegada a C indefinido]
S3 --> X
style R1 fill:#89b4fa,color:#11111b
style X fill:#f38ba8,color:#11111b
⚠️
El axioma no es la implementacion

La entrega garantizada es aquello con lo que puedes razonar, no aquello que tu transporte hace. Erlang ofrece orden por pareja de procesos y entrega no garantizada ante particiones; Akka ofrece como mucho una entrega y orden por pareja emisor-receptor; un runtime en proceso como el de XState ofrece de hecho entrega segura y orden FIFO porque todo ocurre en la misma cola de tareas. Esa generosidad local es una trampa: escribirás sin querer lógica que depende del orden global y solo lo descubrirás el día que un actor se mude a un worker o a otra máquina.

El actor frente a CSP y al cálculo pi

La familia de modelos de concurrencia por paso de mensajes tiene tres miembros mayores y se distinguen por una decisión temprana: qué entidad recibe nombre.

Dimensión Actores CSP Cálculo pi
Qué se nombra el actor, por su dirección el canal el canal, y viaja como dato
Sincronía del envío asíncrono, sin espera cita entre ambas partes cita entre ambas partes
Topología crece al pasar direcciones fija en la formulación clásica móvil por paso de canales
Entrega garantizada, sin cota temporal simultánea por definición simultánea por definición
No determinismo no acotado acotado acotado
Acoplamiento del emisor ninguno total durante la cita total durante la cita

Nombrar el canal en lugar del proceso parece un detalle de notación y decide el resto del edificio. Si el nombre es del canal, la identidad de quien lee no forma parte del modelo, y entonces no puedes decir reinicia lo que hay detrás de este canal: la supervisión deja de ser expresable y hay que reconstruirla fuera. Si el nombre es del actor, la supervisión es natural pero pierdes la contrapresión gratuita, porque un envío que no espera no puede frenar a nadie. La cita síncrona de CSP regula el flujo por construcción; el modelo de actores tiene que reconquistar esa regulación con buzones acotados, que es precisamente el tema de la lección del buzón.

La parquedad como metodo: lo que un modelo prohibe es lo que un modelo demuestra

Hay una manera perezosa de leer a Hewitt que consiste en ver el modelo de actores como una API mínima y una manera fértil que consiste en verlo como un sistema axiomático. La diferencia no es de tono, es de rendimiento intelectual. Una API mínima se juzga por lo que permite y siempre sale perdiendo frente a una más rica; un sistema axiomático se juzga por lo que permite demostrar, y ahí la pobreza es una virtud, porque cada capacidad que se retira convierte una esperanza en un teorema. Retirar la memoria compartida no es incomodarte: es transformar la ausencia de condiciones de carrera de buena práctica en propiedad estructural. Retirar el directorio global no es burocracia: es convertir el confinamiento de un actor en algo que se puede probar leyendo qué direcciones se le entregaron, sin auditar el resto del programa. Retirar la cota temporal no es dejar el modelo impreciso: es admitir con honestidad que el tiempo de llegada no está bajo tu jurisdicción, y esa admisión es lo que permite que la misma semántica sirva para dos actores en el mismo hilo y para dos continentes. Retirar el retorno síncrono no es amputar la pregunta: es reconocer que preguntar también es un acto que ocurre en el tiempo, y obligarte a modelar la espera como un estado en vez de esconderla en una pila de llamadas. Cuando escribas tu próximo sistema, la pregunta útil no será qué me deja hacer esta abstracción, sino qué me impide hacer y qué me regala a cambio de esa renuncia. Un modelo que no te prohíbe nada tampoco te garantiza nada, y en concurrencia lo único que separa un sistema que funciona de uno que parece funcionar es la lista de cosas que, por construcción, no pueden pasar.

⚔️ Formaliza los tres verbos
  1. Escribe el tipo Comportamiento completo para un actor de sesión con tres mensajes y comprueba que tu función es total sin usar un caso comodín silencioso.
  2. Implementa contador en estilo de designación pura y demuestra por inspección que ningún llamante puede leer valor sin enviar un mensaje con dirección de respuesta.
  3. Traza a mano el conjunto de direcciones conocidas de un actor a lo largo de cuatro turnos y señala en qué turno adquirió cada capacidad y de quién.
  4. Construye un ejemplo propio de no determinismo no acotado y explica por qué un test que espera cien turnos no lo captura.
  5. Reescribe una interacción tuya de petición y respuesta en CSP y en actores, y enumera qué gana y qué pierde cada versión respecto a supervisión y contrapresión.
  6. Argumenta si un registro global de nombres viola la ley de localidad en tu proyecto y qué frontera de confianza estás aceptando al usarlo.