Capítulo 14 · Actores
En el capítulo 13 aprendiste a arrancar fibras y coordinarlas con un nursery. Eso resuelve la concurrencia interna: muchas unidades de trabajo dentro de un mismo programa, repartidas sobre los núcleos de la máquina.
Para muchos casos esa estructura alcanza, pero hay un patrón que las fibras puras dejan incómodo.
Una fibra es un cómputo; un actor es un proceso vivo
Imagina dos tareas distintas:
- Tarea A: parsea un archivo grande y devuelve la lista de errores que encontró. Arranca, trabaja, termina, devuelve un valor.
- Tarea B: mantén una cache en memoria que responde
consultas (
get(key)yput(key, value)). Arranca, queda viva, responde mensajes mientras el programa exista, en algún momento termina cuando alguien le pide que se detenga.
Las dos son concurrentes: el programa principal sigue trabajando mientras ocurren. Pero su forma es muy distinta.
La tarea A es un cómputo: tiene una entrada, produce un
valor de salida y termina. Eso es una fibra: la creas con
spawn, esperas su resultado con await, y una vez devuelto
el resultado deja de existir.
let f = spawn.spawn(() => parsear_archivo("entrada.txt"))
# ... otro trabajo concurrente ...
let errores = spawn.await(f) # un solo valor, y se acabó
La tarea B es un proceso vivo: no tiene un único valor de retorno, tiene una secuencia interminable de interacciones. Para esto kaikai trae el modelo de actores, heredado en espíritu de Erlang y BEAM.
let cache = spawn_actor(() => bucle_cache())
Actor.send(cache, Put("usuario:42", "ada"))
Actor.send(cache, Put("usuario:43", "turing"))
match Actor.receive() {
Found(v) -> ...
Missing -> ...
}
Un actor es una fibra con un mailbox tipado encima. La fibra es el sustrato (un hilo cooperativo de ejecución); el mailbox es lo que la hace un actor (un canal donde se acumulan mensajes para que la fibra los procese en orden).
Comparación lado a lado:
| Aspecto | Fibra | Actor |
|---|---|---|
| ¿Qué es? | Unidad de ejecución | Fibra con mailbox tipado |
| Comunicación | Un valor de retorno vía await | Mensajes vía send/receive |
| Ciclo de vida | Arranca, calcula, devuelve, muere | Arranca, queda en bucle procesando, muere cuando decide |
| Cómo se crea | spawn.spawn o n.spawn | spawn_actor |
| Cómo le hablas | await para obtener su T | send cualquier cantidad de veces |
| Cuándo elegirlo | Cálculo discreto concurrente | Servicio de larga vida, estado interno, consultas |
La regla mental:
- ¿La tarea termina con un valor que el padre necesita? Fibra.
- ¿La tarea vive y responde mensajes a varios clientes? Actor.
Ambos modelos son concurrentes y paralelos: el runtime reparte fibras y actores sobre tantos hilos del sistema como núcleos tenga la máquina (cap. 13 §13.8 Concurrencia y paralelismo). Que un actor corra en otro hilo no cambia nada de este capítulo: su mailbox se sigue procesando en serie, un mensaje a la vez, y los mensajes que cruzan de un hilo a otro se copian. Ningún actor observa memoria compartida, corras con uno o con treinta y dos hilos.
Casos donde el actor es lo natural: un servidor de cache, un
controlador de conexiones, un supervisor de procesos, un router
de notificaciones, una cola de tareas, un actor logger. Casos
donde la fibra basta: un cómputo que el padre quiere hacer en
paralelo conceptual mientras hace otra cosa, un with_timeout
que mide cuánto tarda algo, un map concurrente sobre una lista
de IO.
De todo lo que hay en kaikai, esta es la parte que menos me pertenece. El reparto entre cómputo y proceso vivo lo resolvió Erlang hace décadas, y lo tomé casi sin cambios.
Los actores no son primitivos del lenguaje
En kaikai, los actores no son una construcción del core. Son
una capa construida con efectos algebraicos: el efecto Actor[Msg]
declara las operaciones (self, send, receive); el
stdlib provee funciones como with_mailbox y spawn_actor
que instalan el handler de Actor[Msg] sobre una fibra
ordinaria.
Es el mismo principio que con nursery del cap. 13: el
lenguaje core tiene solo efectos; los patrones de uso
(fibras, actores, supervisión) aparecen en bibliotecas que
puedes leer. Si después de este capítulo te
preguntas cómo funciona spawn_actor por dentro, la respuesta
es un spawn.spawn más un handle ... with Actor[Msg].
14.1 Actor[Msg]: el efecto
Un actor es una fibra dentro de un handle ... with Actor[Msg] que le da acceso a cuatro operaciones, declaradas
en el stdlib:
# Declarado en stdlib/effects/concurrent.kai, accesible vía
# `import actor`.
pub effect Actor[Msg] {
self() : Pid[Msg]
send[T](pid: Pid[T], msg: T) : Unit
receive() : Msg
receive_timeout(nanos: Int) : Option[Msg]
}
self()devuelve elPiddel actor actual. ElPides el handle con el que otros le mandan mensajes.send(pid, msg)encolamsgen el mailbox depid. Fíjate en el[T]: el tipo del mensaje es el del destino, elegido llamada por llamada, y no tiene nada que ver con elMsgdel que manda. Un actor con mailbox deReplypuede mandarle unRequesta unPid[Request], como el!de Erlang le manda a cualquier pid. Si el mailbox destino está lleno, el comportamiento depende de la policy (§14.4).receive()saca el siguiente mensaje del mailbox del actor actual. Si no hay nada, la fibra se suspende hasta que llegue uno. Ese bloqueo es un punto de yield: es ahí donde el scheduler le entrega la cancelación a una fibra cancelada.receive_timeout(nanos)esreceive()con plazo. DevuelveNonesi el plazo vence sin que llegue nada. Lo usas con su envoltorio tipado en §14.3.
Msg es el tipo concreto de los mensajes que ese actor
recibe. Un actor recibe un solo tipo de mensaje; mandar es
otra cosa, y de eso se encarga el [T] de send. Si necesitas
mezclar shapes en lo que recibes, los unificas con un sum type:
type ServerMsg
= Ping(Pid[Pong])
| Stop
| Tick
El actor sabe que solo va a recibir uno de esos tres
constructores, y el match exhaustivo en receive() te
garantiza que cubres todos los casos.
Pid[Msg]: handle tipado
Un Pid[Msg] es un identificador de mailbox. Tiene tipo, así
que el compilador no te deja mandarle a un Pid[Tarea] un
mensaje de tipo Notificación. Esta es la diferencia más
fuerte con Erlang: en Erlang los PIDs son no tipados; en
kaikai son específicos al tipo de mensaje.
Como Fiber[T] del cap. 13, Pid[Msg] está atado al scope
que lo creó. No puedes guardarlo en un record que sobreviva
al nursery, devolverlo de una función fuera de la familia
estándar, ni pasarlo entre estructuras de datos no aprobadas.
El compilador lo rechaza. Esto cierra el modelo: cada PID
tiene un padre conocido, y muere con él.
14.2 with_mailbox: dar mailbox a la fibra actual
La forma más simple de empezar es darle mailbox a la fibra
en la que ya estás. with_mailbox instala el handler de
Actor[Msg] y entrega el control a su body:
import actor
fn main() : Unit / Stdout {
with_mailbox {
Actor.send(Actor.self(), "hola")
Actor.send(Actor.self(), "mundo")
Stdout.print(Actor.receive())
Stdout.print(Actor.receive())
}
}
Salida:
$ kai run ejemplos/cap14/01_with_mailbox.kai
hola
mundo
with_mailbox { ... } es una llamada con sintaxis de trailing
lambda: el bloque entre llaves es el cuerpo que se ejecuta con
el mailbox instalado. Como with_mailbox no le pasa argumentos
al body (es una lambda de cero parámetros), el bloque no lleva
flecha ni binder. Adentro, Actor es la capacidad disponible:
Actor.self() devuelve el Pid del mailbox recién creado,
Actor.send(pid, msg) encola, Actor.receive() saca el
siguiente.
Aquí el actor se manda mensajes a sí mismo: es el “hola mundo” del modelo. Lo interesante es comunicar dos actores distintos.
14.3 spawn_actor: crear un actor nuevo
Para arrancar un actor que corre en su propia fibra:
# ejemplos/cap14/02_spawn_actor.kai
import actor
import spawn
fn trabajador() : Unit / Actor[String] + Stdout {
let t1 = Actor.receive()
Stdout.print("trabajando: " ++ t1)
let t2 = Actor.receive()
Stdout.print("trabajando: " ++ t2)
let t3 = Actor.receive()
Stdout.print("trabajando: " ++ t3)
}
fn main() : Unit / Stdout + Spawn + Cancel {
with_mailbox {
let pid = spawn_actor(() => trabajador())
Actor.send(pid, "tarea-1")
Actor.send(pid, "tarea-2")
Actor.send(pid, "tarea-3")
spawn.yield()
spawn.yield()
spawn.yield()
spawn.yield()
}
}
$ kai run ejemplos/cap14/02_spawn_actor.kai
trabajando: tarea-1
trabajando: tarea-2
trabajando: tarea-3
spawn_actor arranca una fibra nueva, le instala un mailbox,
y devuelve el Pid para que el padre pueda mandarle mensajes.
La firma del trabajador declara Actor[String]: necesita el
efecto para llamar a Actor.receive().
main también invoca una operación de Actor[String]
(Actor.send(pid, "tarea-1")), pero su fila no lo lleva: el
with_mailbox que envuelve esas llamadas es el handler que
las atiende, y un efecto manejado no sale del bloque. El pid
ya identifica al mailbox destino; el handler solo despacha la
operación.
Los spawn.yield() del final le dan al scheduler la chance de
correr al trabajador. Sin ellos, main saldría antes de que el
trabajador procesara nada.
receive_timeout: recibir con plazo
Actor.receive() bloquea: si el mailbox está vacío, la fibra se
suspende hasta que llegue un mensaje, sin importar cuánto tarde.
Casi siempre eso es lo que quieres. Pero un supervisor que hace
un health check no debería colgarse si el worker dejó de
responder, y un cliente que pide algo por la red quiere
reintentar si nadie contesta en 50 ms.
Para eso está receive_timeout(d), que recibe del mailbox
rindiéndose tras una Duration y devuelve un Option:
import actor
import time
fn main() : Unit / Stdout + Spawn + Cancel + Clock + Actor[String] {
with_mailbox {
# Mailbox vacío: el deadline expira antes de que llegue nada.
match receive_timeout(time.millis(10)) {
Some(m) -> Stdout.print("inesperado: " ++ m)
None -> Stdout.print("timeout: nadie respondió")
}
# Ahora sí hay un mensaje a la mano: llega antes del deadline.
Actor.send(Actor.self(), "pong")
match receive_timeout(time.millis(10)) {
Some(m) -> Stdout.print("recibido: " ++ m)
None -> Stdout.print("inesperado: timeout")
}
}
}
Salida:
$ kai run ejemplos/cap14/06_receive_timeout.kai
timeout: nadie respondió
recibido: pong
Some(msg) si llegó un mensaje antes del deadline, None si el
plazo expiró primero. El Duration se construye con los
constructores de time (time.millis, time.seconds,
time.minutes); como el plazo se mide contra el reloj, la firma
gana el efecto Clock. La obligación de hacer match sobre el
Option es la gracia: el compilador no te deja olvidar el caso
en que el tiempo se acabó.
Para el repertorio completo del módulo (spawn_actor_policy,
los wrappers tipados, la op cruda en nanosegundos sobre la que se
construye receive_timeout), corre kai doc actor.
14.4 Policies de mailbox: qué pasa cuando se llena
Por omisión, with_mailbox y spawn_actor crean un mailbox
unbounded: nunca se llena, los mensajes se acumulan
mientras no se lean. Es razonable para empezar, pero
peligroso: si un productor manda más rápido de lo que un
consumidor procesa, la memoria crece sin tope.
Para casos reales, eliges una policy. El stdlib (módulo
actor) las expone como dos sum types:
# Definidos en stdlib/actor.kai, accesibles vía `import actor`.
pub type MailboxPolicy = Unbounded | Bounded(Int, Overflow)
pub type Overflow = DropOldest | DropNewest | BlockSender
Bounded(capacity, on_full) da un mailbox de tamaño fijo. El
parámetro on_full decide qué hacer cuando llega un mensaje y
ya no hay espacio:
DropOldest: el mensaje más viejo se evicciona, el nuevo entra. Útil para snapshots: solo importa el estado más reciente (telemetría, tick events, GPS).DropNewest: el nuevo se rechaza, el mailbox queda como estaba. Útil para “el primero gana” (elección de líder, adquisición de lock).BlockSender: el emisor se suspende hasta que se libere espacio. Útil para backpressure: el productor frena cuando el consumidor no da abasto. Es punto de yield, así que un sender bloqueado puede recibirCancel.raise().
import actor
fn main() : Unit / Stdout {
with_mailbox_policy(Bounded(2, DropOldest)) {
Actor.send(Actor.self(), "a")
Actor.send(Actor.self(), "b")
Actor.send(Actor.self(), "c")
Stdout.print(Actor.receive())
Stdout.print(Actor.receive())
}
}
Salida:
$ kai run ejemplos/cap14/04_mailbox_policy.kai
b
c
El mailbox tiene capacidad 2 y el programa manda a, b, c
sin leer nada. Cuando llega c no hay espacio, y DropOldest
saca a. Las dos lecturas recuperan b y c.
DropOldest y DropNewest no notifican al emisor que su
mensaje se descartó. Si necesitas saberlo, usa BlockSender (o
diseña el protocolo con un acuse de recibo). El silencio es
deliberado: la policy expresa una preferencia global del
mailbox, no una negociación por mensaje.
14.5 Patrón request/reply
El patrón más común entre actores es pedirle algo a uno y
esperar respuesta. Cada lado del diálogo tiene su propio tipo
de mensaje: el cliente manda un Request y recibe un Reply;
el servidor recibe Request y manda Reply. El Request
incluye el Pid[Reply] del cliente para que el servidor sepa
dónde responder.
# ejemplos/cap14/03_request_reply.kai
import actor
type Request = Query(String, Pid[Reply])
type Reply = Answer(String)
fn servidor() : Unit / Actor[Request] + Stdout {
match Actor.receive() {
Query(p, cliente) -> {
Stdout.print("servidor: recibí '#{p}'")
Actor.send(cliente, Answer("respuesta a '#{p}'"))
servidor()
}
}
}
fn main() : Unit / Stdout + Spawn + Cancel {
with_mailbox {
let server = spawn_actor(() => servidor())
Actor.send(server, Query("dos+dos", Actor.self()))
match Actor.receive() {
Answer(r) -> Stdout.print("cliente: " ++ r)
}
Actor.send(server, Query("la vida", Actor.self()))
match Actor.receive() {
Answer(r) -> Stdout.print("cliente: " ++ r)
}
}
}
$ kai run ejemplos/cap14/03_request_reply.kai
servidor: recibí 'dos+dos'
cliente: respuesta a 'dos+dos'
servidor: recibí 'la vida'
cliente: respuesta a 'la vida'

Figura 14.1 · Request/reply entre dos actores. El cliente
tiene un mailbox Pid[Reply]; el servidor tiene un mailbox
Pid[Request]. La flecha (1) lleva la Query, que incluye
el PID del cliente, hasta el mailbox del servidor; la
flecha (2) devuelve el Answer al mailbox del cliente. Dos
mailboxes tipados, dos mensajes, una ida y vuelta.
Lo importante de la estructura:
- Dos tipos distintos,
RequestyReply. El mailbox fija qué recibe cada actor, y ahí los dos lados son asimétricos: el servidor declaraActor[Request]porque de ahí saca mensajes; el cliente declaraActor[Reply]por lo mismo. Mandar es otra cosa.Actor.sendtoma el tipo del destino, no el del mailbox propio, así que el servidor contesta unReplysin tener mailbox deReply. Queryincluye elPid[Reply]de retorno. Sin eso, el servidor no sabe a quién contestarle. El tipo delPidgarantiza que solo se le pueden enviar mensajes de tipoReply.- El servidor recursa después de procesar el mensaje. Sin esa recursión, el servidor procesaría un solo mensaje y terminaría. La recursión por cola se compila a un loop (cap. 6), así que el actor puede correr indefinidamente sin reventar el stack.
En la práctica, esto reemplaza las llamadas sincrónicas a una API: pides algo, esperas respuesta, sigues. Solo que aquí el “servidor” puede atender a varios clientes a la vez con su estado interno encapsulado, y el modelo de tipos te garantiza que ningún cliente se queda esperando una respuesta del tipo equivocado.
14.6 Supervisión: links y monitores
En BEAM, los actores se supervisan con links (bidireccional) y monitores (unidireccional). Cuando un actor cae, los actores que lo observan se enteran y deciden qué hacer.
kaikai trae el mismo modelo, expresado como dos efectos que
viven en stdlib/effects/concurrent.kai, junto a Spawn y
Actor[Msg]:
pub effect Link {
link(peer: Pid[Nothing]) : Unit
}
pub effect Monitor {
monitor(target: Pid[Nothing]) : Pid[Nothing]
demonitor(ref: Pid[Nothing]) : Unit
}
Pid[Nothing] es el PID visto sin su tipo de mensaje: Nothing
es el tipo vacío, así que un PID así no puede recibir nada. Es
la forma de decir “aquí solo me interesa la identidad del actor,
no su protocolo”, porque link y monitor no mandan ni
reciben mensajes, solo registran observación sobre su vida.
Links: bidireccionales
Link.link(pid) declara que el actor actual y pid están
ligados: cuando cualquiera de los dos termina, sea porque
retornó, se cayó o lo cancelaron, el otro recibe
Cancel.raise(). Es el patrón para dos actores que dependen
simétricamente uno del otro (un worker y su cola, los dos
lados de un handshake). Para observar a otro actor sin atar
tu vida a la suya están los monitores.
set_trap_exit: recibir la caída en vez de sufrirla
Morir junto al peer no siempre es lo que quieres. Un supervisor ligado a sus workers necesita justamente lo contrario: enterarse de que uno cayó, y seguir vivo para levantarlo.
spawn.set_trap_exit(true) invierte el comportamiento en la
fibra que lo llama. En vez de recibir Cancel.raise() cuando
un peer ligado termina, le llega un mensaje al mailbox:
"Crashed" si el peer se cayó, "Normal" si terminó bien.
with_mailbox {
spawn.set_trap_exit(true)
let p = spawn_actor(cuerpo)
Link.link(p)
spawn.yield()
Stdout.print("padre: recibo -> #{Actor.receive()}")
}
hijo: me caigo
padre: recibo -> Crashed
Sin esa línea, la caída del hijo alcanza al padre y el programa se va al piso. Con ella, el padre queda en posición de decidir. El link deja de ser un pacto simétrico y pasa a ser un canal de avisos en una sola dirección: es la forma de supervisar cuando necesitas saber cómo terminó el hijo, algo que un monitor no te dice.
Dos condiciones que muerden si faltan. La fibra tiene que
estar dentro de un with_mailbox para que el mensaje
tenga dónde caer; si no, el runtime vuelve a la propagación
normal de cancelación. Y el aviso llega solo por peers
ligados: sin Link.link, no hay nada que avisar.
Monitores: unidireccionales
Monitor.monitor(pid) declara que el actor actual quiere
saber cuándo pid termina, sin acoplar la vida del observador
a la del observado. Devuelve una referencia (otro
Pid[Nothing]) que después le pasas a demonitor si quieres
dejar de observar.
Cuando el observado muere, el aviso te llega por el mailbox
de siempre: el runtime empuja ahí el pid del muerto y lo
recibes con el mismo Actor.receive() que cualquier otro
mensaje. Eso es todo lo que dice un monitor: este actor
terminó. No te dice cómo.
Saber cómo murió: Link + trap exit
Para la causa vuelves a set_trap_exit(true), el
process_flag(trap_exit, true) de Erlang. Su aviso sí
distingue: al mailbox llega "Normal" o "Crashed".
# ejemplos/cap14/07_trap_exit.kai (extracto)
fn supervisar() : Unit / Actor[String] + Spawn + Stdout + Link + Cancel {
spawn.set_trap_exit(true)
let yo = Actor.self()
let _ = spawn.spawn(() => with_mailbox { hijo_ok(yo) })
Stdout.print("murió un hijo: " ++ Actor.receive())
let _ = spawn.spawn(() => with_mailbox { hijo_crash(yo) })
Stdout.print("murió un hijo: " ++ Actor.receive())
}
$ kai run ejemplos/cap14/07_trap_exit.kai
murió un hijo: Normal
murió un hijo: Crashed
El hijo se liga con Link.link(padre) y después termina bien o
llama a Cancel.raise(). Sin set_trap_exit, el primer hijo
en terminar cancelaría al supervisor y el segundo aviso no se
vería nunca.
El aviso es un String, así que el mailbox del supervisor
tiene que ser Actor[String]. Las dos rutas se reparten el
trabajo: el monitor te dice quién, el trap exit te dice
cómo.
Cuándo elegir cada uno
- Sin
LinkniMonitor, cuando el flujo natural es que el actor mismo reporte cómo le fue antes de terminar. Manda un mensajeDone(...)oFailed(...)a su supervisor y termina limpio. El supervisor lo ve como cualquier otro mensaje. Es el patrón del caso de estudio en §14.7. Monitor, cuando el supervisor necesita reaccionar a terminaciones que el actor no controla: crashes, cancelación desde afuera, panics. El supervisor sigue vivo y decide.Link, cuando dos actores forman una unidad y no tiene sentido que uno sobreviva sin el otro. Con trap exit, cuando el supervisor necesita además la causa de la terminación.
El patrón de §14.7 usa notificación explícita. Es el más
simple, y además el único donde el supervisor recibe la razón
del fallo en sus propios términos. Un
"Crashed" te dice que la fibra se desenrolló; un
Failed("división por cero en (30, 0)") te dice qué pasó. Para
casi todo lo que vas a escribir, que el actor cuente cómo le
fue antes de terminar sigue siendo mejor diseño que
inspeccionar su cadáver.
14.7 Caso de estudio: supervisor con reintentos
Cerramos con un programa completo: un supervisor que lanza un trabajador con un lote de tareas, observa si el lote fue exitoso, y reintenta con un lote alternativo si falló.
import actor
#[derive(Show)]
type ResultadoLote
= Done(Int) # suma total exitosa
| Failed(String) # razón de la falla
fn procesar(tareas: [(Int, Int)], acc: Int) : ResultadoLote {
match tareas {
[] -> Done(acc)
[(a, 0), ..._] -> Failed("división por cero en (#{a}, 0)")
[(a, b), ...resto] -> procesar(resto, acc + (a / b))
}
}
fn trabajador(supervisor: Pid[ResultadoLote], tareas: [(Int, Int)])
: Unit / Actor[ResultadoLote] + Stdout {
let r = procesar(tareas, 0)
Stdout.print("trabajador: lote terminó como #{r}")
Actor.send(supervisor, r)
}
fn intento(me: Pid[ResultadoLote], lote: [(Int, Int)])
: ResultadoLote / Stdout + Spawn + Cancel + Actor[ResultadoLote] {
let _ = spawn_actor(() => trabajador(me, lote))
Actor.receive()
}
fn supervisor() : Unit / Stdout + Spawn + Cancel {
with_mailbox {
let me = Actor.self()
let primer_lote = [(10, 2), (20, 4), (30, 0)]
let segundo_lote = [(10, 2), (20, 4), (30, 5)]
match intento(me, primer_lote) {
Done(total) -> Stdout.print("supervisor: éxito al primer intento, total=#{total}")
Failed(motivo) -> {
Stdout.print("supervisor: primer intento falló (#{motivo}), reintento")
match intento(me, segundo_lote) {
Done(total) -> Stdout.print("supervisor: éxito al segundo intento, total=#{total}")
Failed(motivo) -> Stdout.print("supervisor: segundo intento falló también (#{motivo}), me rindo")
}
}
}
}
}
fn main() : Unit / Stdout + Spawn + Cancel {
supervisor()
}
Salida:
$ kai run ejemplos/cap14/05_supervisor.kai
trabajador: lote terminó como Failed(división por cero en (30, 0))
supervisor: primer intento falló (división por cero en (30, 0)), reintento
trabajador: lote terminó como Done(16)
supervisor: éxito al segundo intento, total=16
Tres piezas para comentar:
procesarno toca el sistema de actores. Es lógica pura sobre listas, así que la pruebas completa sin arrancar fibras. La capa de actores queda solo entrabajador,intentoysupervisor.intentosepara una preocupación. Sin él, el segundo intento quedaría inline dentro del primermatch; como función aparte, hace explícito el patrón “spawn + receive”.- El supervisor decide la política. El trabajador es ciego a la decisión: solo reporta. Cambiar la política de “dos intentos con datos distintos” a “tres intentos con backoff exponencial” toca una sola función. Esa separación es la ventaja real del modelo.
Para llegar a producción, a este programa le faltan algunas cosas:
- Tiempo máximo por intento. Hoy si el trabajador se
cuelga, el supervisor se cuelga con él, porque
intentotermina en unActor.receive()que bloquea sin plazo. Cambiar esereceiveporreceive_timeout(§14.3) le da al supervisor unNonecon el que reaccionar en vez de quedarse esperando. - Cancelación del trabajador si el supervisor decide
rendirse. Si el supervisor se rinde mientras el trabajador
sigue procesando, el trabajador también debería terminar.
Con
Link.link(worker)después del spawn, el supervisor que retorna cancela al worker automáticamente, porque un link reacciona a cualquier terminación, no solo a las caídas. La alternativa explícita es agregar unStopal protocolo del trabajador y mandárselo antes de rendirse. - Logging persistente. En vez de
Stdout.print, mandar a un actor logger con su propio mailbox bounded (Bounded(1024, DropOldest)).
Cada uno es trabajo de una tarde. Pero la base está: tres actores, dos tipos de mensaje, un sistema de tipos que garantiza que los mailboxes se respetan.
14.8 Filosofía: actores son una biblioteca
Del capítulo, quédate con dos cosas:
-
Los actores no son primitivos del lenguaje. Son una biblioteca construida sobre el efecto
Actor[Msg], que a su vez es una declaración ordinaria de efecto. El stdlib proveewith_mailbox,spawn_actory las policies, y puedes leer su código: por dentro,spawn_actores unhandlecon unspawn.spawn. -
Cada actor tiene un tipo de mensaje fijo. El compilador te garantiza que no mandas el mensaje equivocado al mailbox equivocado. Los
Pid[Msg]son tipados, no strings. Por eso un sistema de actores en kaikai es más verificable estáticamente que su equivalente en Erlang.
La consecuencia más importante de la primera idea: cuando
quieras un patrón de supervisión que el stdlib no provee,
puedes escribirlo. La sintaxis no oculta nada: handle,
receive, send. Difícilmente encontrarás un patrón de
actores de Erlang, Akka o cualquier otro framework que no
puedas expresar con estas piezas como una función ordinaria.
14.9 Probar un actor: reemplaza el mailbox
El capítulo 7 te dejó tres herramientas (test, check y
bench) y una pregunta sin responder: ¿cómo se prueba la parte concurrente?
Si los actores fueran primitivos del lenguaje la respuesta sería
incómoda: levantar actores de verdad, mandarles mensajes de verdad,
y esperar. Tests lentos, y frágiles cuando el scheduler no coopera.
Pero ya sabes que with_mailbox es un handler ordinario del
stdlib, y un handler se reemplaza.
En un test instalas el tuyo, que contesta cada receive desde un
guion que tú escribes. El estado del handler es el guion, y
resume(valor, estado_nuevo) lo hace avanzar:
import actor
import time
type Msg = Tick(Int) | Done
fn recolectar(visto: [String]) : [String] / Actor[Msg] + Clock =
match receive_timeout(time.seconds(5)) {
None -> visto ++ ["timeout"]
Some(Tick(n)) -> recolectar(visto ++ ["tick #{n}"])
Some(Done) -> visto ++ ["done"]
}
fn main() : Unit / Stdout + Clock {
let resultado = handle {
recolectar([])
} with Actor[Msg]([Some(Tick(1)), Some(Tick(2)), None]) {
receive_timeout(ns, resume) -> match state {
[] -> resume(Some(Done), [])
[siguiente, ...resto] -> resume(siguiente, resto)
}
self(resume) -> panic("el guion no cubre self")
send(pid, msg, resume) -> panic("el guion no cubre send")
receive(resume) -> panic("el guion no cubre receive")
return(x) -> x
}
println("#{resultado}")
}
$ kai run ejemplos/cap14/08_probar_mailbox.kai
[tick 1, tick 2, timeout]
Fíjate en lo que no hay: ningún mailbox, ninguna fibra, ninguna
espera. El orden de los mensajes lo elige el test, y también el
instante en que el plazo se vence. recolectar pide hasta cinco
segundos por mensaje; la corrida entera termina en microsegundos.
Eso es lo que más se agradece. Controlar el tiempo en tests de código concurrente es un dolor conocido: en la BEAM se resuelve con sleeps que vuelven la suite lenta y caprichosa, o con bibliotecas que parchan el runtime. Aquí es el mismo mecanismo que todo lo demás, y cuesta un handler.
Las tres cláusulas con panic están porque un handler cubre todas
las ops de su efecto, también las que el código bajo prueba nunca
llama. recolectar solo usa receive_timeout, pero el compilador
exige una respuesta para self, send y receive:
error: handler for `Actor` does not cover every op: missing `self`, `send`, `receive`
--> mailbox.kai:14:19
|
14 | let resultado = handle {
| ^
= note: an op without a clause has no handler inside this `handle`; a clause is needed even when nothing calls it
= help: add a clause for each missing op, e.g. `self(args, resume) -> resume(...)`
La alternativa sería descubrirlo en runtime, el día que alguien
agregue un send a recolectar y el test muera con un efecto sin
handler. Con el panic el guion declara hasta dónde llega: si el
código empieza a pedir algo que el test no previó, la corrida aborta
con ese mensaje.
Clock sigue en la firma de main.
El handler falso intercepta Actor, no el reloj, y la fila es lo
que el cuerpo declara, no lo que termina ocurriendo. La capacidad
se queda aunque el reloj nunca se consulte. Es coherente: el tipo
describe lo que el código puede hacer.
Dos límites, antes de que los descubras a golpes
Un handler no cruza un spawn. Lo primero que intentas es
instalar el falso en el padre y spawnear los hijos debajo, y el
compilador no te deja:
error: effect not handled in spawned fiber: Reloj
--> b.kai:10:23
|
10 | let h = n.spawn(() => hija())
| ^
= note: a fiber does not inherit the parent's handlers
= help: handle the effect inside the spawned body: `handle { ... } with Reloj { ... }`
Es la misma regla del §12.9 sobre capacidades, ahora sobre un
handle cualquiera: los handlers son de la fibra, no del programa.
Si el hijo necesita el falso, el handle va adentro del cuerpo
spawneado.
Un falso no puede emular bloqueo. Una cláusula de handler tiene que retornar, así que no hay forma de decir “todavía nada, espera”. Reemplazar el mailbox funciona porque un mailbox es la cosa que espera: el guion decide cuándo llegó cada mensaje. Reemplazar una tubería no funciona, por la misma razón. Cuando lo que estás probando es justamente el borde con el sistema operativo, el instrumento sigue siendo un subproceso de verdad.
Ejercicios
14.1. Modifica el ejemplo §14.3 para que el trabajador
procese cinco tareas en vez de tres. ¿Qué pasa si reduces los
spawn.yield() del padre? ¿Cuál es el mínimo que hace que
todas las tareas se procesen?
14.2. Toma el Bounded(2, DropOldest) de §14.4 y cámbialo
a Bounded(2, DropNewest). ¿Cuál es la salida esperada? Justifica con
un razonamiento sobre qué mensajes quedan en el mailbox cuando
llega cada send.
14.3. En el patrón request/reply de §14.5, el cliente manda una sola pregunta y se va. Si quisieras un cliente que hace cinco preguntas en serie, ¿qué cambiarías? ¿Y si las quisieras lanzar todas a la vez y recibir las respuestas conforme lleguen, en el orden en que el scheduler las termine? Pista: necesitas abrir varias fibras dentro de un nursery o agrupar respuestas con un correlation id.
14.4. En el caso de estudio §14.7, el supervisor reintenta
dos veces. Generaliza: escribe una función con_reintentos[T, e](n: Int, intento: () -> ResultadoLote / e) : ResultadoLote / e que reintente n veces antes de rendirse.
14.5. Un actor “logger” recibe Info(String) y los
imprime a stdout. Diseña su tipo de mensaje, su firma, y un
with_mailbox_policy apropiado. Justifica la elección de
policy considerando: ¿qué pasa si el productor manda más
rápido de lo que se puede imprimir?