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

El buzón: por qué el paso asíncrono disuelve la carrera

Una condición de carrera exige tres ingredientes simultáneos: concurrencia, mutación y estado compartido. El buzón no ataca los dos primeros, elimina el tercero, y con él desaparece toda una clase de defectos sin que nadie tenga que recordar un candado. Esta lección formaliza la atomicidad del turno, enumera con precisión qué promete y qué no promete una cola de mensajes, expone la clase de fallos nueva que aparece a cambio, demuestra por qué un await dentro de un manejador parte el turno y reinstala el interleaving, y cierra con la contrapresión y la ley de Little, porque un buzón ilimitado es una fuga de memoria con buena reputación.

⏱ 21 min

La promesa más citada del modelo de actores es que elimina las condiciones de carrera, y como toda promesa muy citada conviene desarmarla antes de creerla. Una condición de carrera necesita tres ingredientes a la vez: dos flujos concurrentes, una mutación y un dato compartido entre ambos. El buzón no elimina la concurrencia —los actores siguen avanzando a la vez— ni elimina la mutación —el estado del actor cambia en cada turno—. Elimina el tercer ingrediente, el compartir, y con eso basta para que la clase entera de defectos deje de ser expresable. A cambio aparece otra clase, más benigna pero muy real, que vive en el tiempo en vez de en la memoria y que hay que aprender a ver.

🎯 Al terminar esta lección sabrás
  • Formalizar el turno como sección crítica que nadie tiene que adquirir.
  • Enumerar qué garantiza un buzón real sobre orden, llegada y prioridad, y qué no.
  • Reconocer el interleaving de conversaciones y las respuestas rancias que provoca.
  • Diseñar contrapresión con buzones acotados y leer la profundidad de cola como señal.

La atomicidad del turno

El turno es la unidad de ejecución de un actor: sacar un mensaje del buzón, aplicar el comportamiento, producir sus efectos y quedar listo para el siguiente. Mientras dura un turno ningún otro mensaje se procesa para ese mismo actor, así que la transición de estado es atómica respecto a todo lo demás. La propiedad que en el mundo de hilos se paga con un candado —exclusión mutua sobre el dato— aquí sale gratis de la forma del sistema, y la diferencia esencial es que un candado se puede olvidar y un turno no.

// Micro-runtime: la exclusion mutua no se pide, se deduce del bucle.
function correr(comportamiento: Comportamiento) {
  const buzon: Mensaje[] = []
  let ocupado = false

  function planificar() {
    if (ocupado) return
    ocupado = true
    queueMicrotask(() => {
      const mensaje = buzon.shift()
      if (mensaje !== undefined) {
        comportamiento = aplicar(comportamiento, mensaje) // turno completo
      }
      ocupado = false
      if (buzon.length > 0) planificar()
    })
  }

  return (mensaje: Mensaje) => {
    buzon.push(mensaje) // enviar es depositar, nunca ejecutar
    planificar()
  }
}

Lo notable de ese bucle es lo que no contiene: ninguna primitiva de sincronización, ninguna comprobación de propiedad, ningún orden de adquisición que documentar para evitar interbloqueos. La exclusión mutua es una consecuencia de que el estado solo se toque durante un turno y de que los turnos no se solapen. Un sistema de actores no puede sufrir un interbloqueo por candados porque no hay candados que adquirir en orden equivocado; puede sufrir bloqueos lógicos —dos actores esperando respuestas mutuas—, pero eso es un problema de protocolo, visible en el grafo de mensajes, no un problema de memoria invisible en el depurador.

Lo que el buzón no promete

El error caro no es creer que el buzón resuelve las carreras, sino extender esa confianza a garantías que nunca dio. Conviene separar el axioma del modelo, lo que ofrece cada implementación y lo que tú necesitas.

Propiedad Modelo puro Erlang y Akka Runtime en proceso
Llegada del mensaje garantizada, sin cota como mucho una vez, sin garantía ante partición efectiva, salvo actor detenido
Orden entre dos emisores ninguno ninguno el de la cola de tareas
Orden de un mismo emisor ninguno FIFO por pareja FIFO por pareja
Prioridad inexistente por buzón especializado inexistente salvo que la construyas
Respuesta a una petición no es parte del modelo correlación manual o patrón de pregunta correlación manual

La fila crítica es la última, y de ella nace la clase de defectos que sustituye a las carreras. Un actor envía una petición, sigue recibiendo mensajes mientras espera —porque no puede bloquearse, ese es el trato— y cuando la respuesta llega, el estado que la motivó puede haber cambiado. La respuesta es correcta y llega a destiempo: es una respuesta rancia. El resultado observable se parece mucho a una carrera, con la diferencia decisiva de que ocurre entre mensajes y no dentro de la memoria, así que es reproducible, registrable y modelable.

Hay tres remedios y conviene conocer los tres. El primero es la correlación: cada petición lleva un identificador y la respuesta lo devuelve, de modo que el actor descarta las que no corresponden a su petición vigente. El segundo es la recepción selectiva de Erlang, que permite atender del buzón solo los mensajes que encajan con un patrón y dejar el resto para después; es potente y tiene un coste que se olvida, porque el buzón se recorre en busca del patrón y una cola larga convierte cada recepción selectiva en un barrido lineal. El tercero es apartar los mensajes que no tocan ahora en una reserva y devolverlos cuando el actor vuelva a un estado que los acepta.

⚠️
Ignorar un evento no es aparcarlo

Un runtime de máquinas de estados que descarte los eventos no manejados por el estado actual está eligiendo una semántica muy distinta de la reserva: el mensaje no se pospone, se pierde. Esa elección suele ser la correcta en interfaz —un clic durante el envío no debe encolarse para dispararse después— y suele ser catastrófica en un actor de dominio, donde perder una orden es perder trabajo. Antes de apoyarte en que un estado no maneja cierto evento, decide de forma consciente si estás descartando o si necesitabas aparcar, y escribe la reserva explícita si era lo segundo.

El turno partido: por qué un await rompe el aislamiento

Toda la argumentación anterior descansa en que el turno es indivisible, y en un entorno con corrutinas esa premisa se puede violar sin darse cuenta. Si el manejador de un mensaje espera una promesa, el turno termina en ese punto: el planificador queda libre, otros mensajes del mismo actor se procesan, y la continuación se ejecuta después sobre un estado que ya no es el que leyó antes de esperar.

// Turno partido: el aislamiento desaparece en la linea del await.
async function manejarMal(mensaje: Mensaje) {
  const saldo = this.saldo              // lectura en el turno A
  const ok = await this.pasarela.cobrar(mensaje.importe) // el turno A TERMINA aqui
  if (ok) this.saldo = saldo - mensaje.importe // escritura en el turno B, con saldo rancio
}

Esas cuatro líneas reproducen exactamente la carrera de lectura y escritura del mundo de hilos, dentro de un modelo que presumía de haberla abolido. La lección práctica es severa y explica una restricción de diseño que muchos consideran arbitraria: en un runtime de actores serio, el manejador debe ser síncrono. El trabajo asíncrono no se espera dentro del turno, se delega a otro actor cuyo resultado vuelve como un mensaje corriente. Así el turno vuelve a ser atómico, la espera vuelve a ser un estado con nombre y la continuación vuelve a leer el estado en el momento en que actúa.

// Delegar en vez de esperar: la continuacion vuelve como mensaje.
function manejarBien(mensaje: Mensaje, yo: Direccion): Efecto {
  return {
    envios: [[pasarela, { tipo: 'cobrar', importe: mensaje.importe, responder: yo }]],
    creados: [],
    siguiente: cobrando(mensaje.importe), // estado explicito de espera
  }
}
flowchart LR
A[turno A completo] --> B[estado cobrando]
B --> C[llega respuesta de la pasarela]
C --> D[turno B completo]
B -. otro mensaje mientras espera .-> E[se aparca o se descarta]
style A fill:#a6e3a1,color:#11111b
style D fill:#a6e3a1,color:#11111b
style E fill:#f9e2af,color:#11111b

Contrapresión: un buzón ilimitado es una fuga

El buzón sin cota es la comodidad que se cobra tarde. Mientras la tasa de llegada sea menor que la de servicio, la cola vive corta y nadie la mira; en cuanto se invierte la relación, aunque sea durante un pico breve, la cola crece de forma monótona y el sistema no se degrada, se muere de memoria. La ley de Little lo dice sin adjetivos: el número medio de mensajes en el sistema es el producto de la tasa de llegada por el tiempo medio de permanencia, de modo que si el tiempo de servicio empeora un orden de magnitud, la cola crece en la misma proporción sin que llegue ni un mensaje de más.

📮

Acota el buzón

Una cola con capacidad convierte un fallo de memoria diferido en un rechazo inmediato y visible, que es infinitamente más fácil de operar y de explicar.

🚦

Elige la política

Rechazar el nuevo, descartar el más viejo o bloquear al emisor no son equivalentes: la primera protege la historia, la segunda la actualidad, la tercera propaga la presión hacia el origen.

📈

Mide la profundidad

La longitud del buzón es la señal de salud más honesta de un sistema de actores, porque adelanta el colapso mucho antes de que la latencia lo delate.

// Un buzon acotado no es una optimizacion: es la declaracion
// de cuanta desincronia estas dispuesto a tolerar antes de avisar.
type Politica = 'rechazar' | 'descartar-viejo' | 'frenar-emisor'

function buzonAcotado(capacidad: number, politica: Politica) {
  const cola: Mensaje[] = []
  return {
    depositar(mensaje: Mensaje): 'aceptado' | 'rechazado' {
      if (cola.length < capacidad) {
        cola.push(mensaje)
        return 'aceptado'
      }
      if (politica === 'descartar-viejo') {
        cola.shift()
        cola.push(mensaje)
        return 'aceptado'
      }
      return 'rechazado' // frenar-emisor se traduce en reintento del origen
    },
    profundidad: () => cola.length, // la senal que hay que exportar
  }
}

Bloquear al emisor merece un comentario aparte porque es la única política que propaga la información hacia atrás en la cadena, y propagar hacia atrás es la definición misma de contrapresión. El precio es que el envío deja de ser asíncrono puro y el emisor recupera un acoplamiento temporal con el receptor, que es justo lo que el modelo quería evitar. Ese es el trueque de fondo entre actores y modelos de cita síncrona: los primeros compran desacoplamiento y tienen que reconstruir la regulación de flujo; los segundos la traen puesta y pagan con acoplamiento. No existe la tercera opción, y todo sistema de streaming reactivo que hayas usado es una elaboración de esta misma tensión.

El buzon no elimina el no determinismo: lo traslada a un lugar donde se puede mirar

La forma madura de entender el buzón no es como una solución al problema de la concurrencia sino como un cambio de domicilio del no determinismo. En el modelo de memoria compartida, la indeterminación vive dentro de las instrucciones: el punto exacto en que un hilo es desalojado no está en tu código, no aparece en ningún registro, no se puede reproducir a voluntad y su efecto es un dato corrupto cuya causa quedó lejos en el tiempo. En el modelo de actores, la indeterminación vive en el orden de llegada de los mensajes: sigue estando ahí, sigue siendo irreducible, pero ahora es un objeto de primera clase que se puede registrar, reordenar deliberadamente en una prueba, dibujar como diagrama y razonar con la lógica temporal. Ese traslado es el verdadero regalo, porque un sistema concurrente correcto nunca fue uno sin indeterminación —eso sería un sistema secuencial— sino uno en el que toda la indeterminación está declarada y acotada. De ahí que las dos disciplinas que esta lección impone parezcan restricciones y sean garantías: mantener el turno indivisible asegura que cada transición es un punto y no un intervalo, y acotar el buzón asegura que la indeterminación del tiempo no se convierta en indeterminación del espacio de memoria. Cuando ambas se respetan, se puede decir algo muy fuerte sobre un sistema de actores: cualquier comportamiento observable es explicable como una secuencia concreta de entregas, y cualquier fallo es reproducible sin más que reproducir esa secuencia. Ninguna cantidad de candados bien colocados compra esa propiedad, y es la razón por la que este modelo sobrevive medio siglo mientras las modas de concurrencia se suceden encima de él.

⚔️ Interroga tu buzon
  1. Escribe el micro-runtime del turno y demuestra con un contraejemplo que solaparlos reintroduce la carrera que el modelo prohibía.
  2. Provoca una respuesta rancia en un actor tuyo y arréglala con correlación en vez de con una bandera booleana.
  3. Decide, para cada estado de una máquina tuya, si los eventos no manejados deben descartarse o aparcarse, y documenta la elección junto al estado.
  4. Busca en tu código un manejador con un await en medio y conviértelo en una delegación con estado de espera explícito.
  5. Instrumenta la profundidad del buzón de tu actor más cargado y observa qué ocurre con la cola cuando el servicio se ralentiza diez veces.
  6. Elige una política de buzón lleno para tres actores distintos y justifica por qué en uno de ellos descartar el mensaje más antiguo sería una decisión de negocio inaceptable.