Capítulo 13 · Concurrencia y memoria
La concurrencia es donde la mayoría de los lenguajes acumulan
deuda. Threads con shared memory llevan a races que aparecen una
vez al mes y se arreglan tres veces antes de irse de verdad;
async/await introduce colores de funciones; los actores
corrigen lo anterior pero históricamente vienen con GC y un
runtime pesado.
Frente a esas tres formas, kaikai apuesta por una combinación poco usual: fibras cooperativas + memoria por fibra + Perceus. Antes de la sintaxis, sus tres consecuencias:
- No hay shared memory entre fibras. Cada fibra tiene su propio heap. Lo que una pasa a otra se copia. Las data races desaparecen por construcción.
- No hay GC ni borrow checker. Perceus libera la memoria
cuando termina el último uso de cada valor, sin un colector
asincrónico y sin que anotes lifetimes. El compilador
descubre dónde poner los
frees. - La concurrencia es un efecto.
spawnes una operación de un efectoSpawn, no una palabra clave. Crear y esperar fibras se compone con el resto del sistema (State,Cancel, unFailque declares tú) usando la maquinaria del cap. 12.
13.1 El modelo: fibras aisladas
Una fibra es una unidad de ejecución parecida a un thread, pero mucho más liviana: cientos de bytes en vez de megabytes. Una aplicación kaikai puede tener miles o cientos de miles de fibras vivas sin reventar.
Las fibras son cooperativas. El runtime las reparte entre varios hilos del sistema (§13.8), y en su hilo cada fibra corre hasta llegar a un punto de yield: una llamada que le pasa el control al scheduler. Nadie la interrumpe a la mitad. Los puntos de yield son explícitos:
spawn.yield(): “ya pude correr un rato, prueba otra”.spawn.await(f): “espera a que termine la fibraf”.- Las operaciones de IO que el scheduler intercepta (lecturas de red, sleep, etc.).
Sin yield, una fibra corre hasta terminar. Otras fibras pueden estar corriendo al mismo tiempo en otros hilos, pero ninguna alcanza tus datos, porque cada fibra tiene su propia memoria (lo ves enseguida). Eso es determinismo local: dentro de una fibra, sabes exactamente qué pasa con tus valores. Frente a threads preemptivas con memoria compartida te ahorras una clase entera de bugs: no hay carrera sobre los datos que tocas.
A cambio, una fibra que nunca cede el control acapara su hilo y
no se entera si la cancelan. Con un solo hilo
(KAI_THREADS=1, §13.8) bloquea a todas las demás. Poner
yields donde tenga sentido te toca a ti; en la
práctica las llamadas a IO ya los traen, y solo piensas en
yields manuales en bucles puros de CPU intenso.
Memoria por fibra
Cada fibra tiene su propio heap. Cuando una fibra crea un record, una lista o un closure, el espacio sale de ese heap, y otra fibra no puede leerlo ni escribirlo. El sistema de tipos lo garantiza.
Dos fibras se comunican pasándose valores. Cuando una envía un
mensaje a otra (vía mailbox de actor o vía el resultado de un
await), el valor se copia al heap de la receptora. Un Int
no cuesta nada; una lista grande cuesta en proporción a su
tamaño. Ese costo es lo que permite que el conteo de
referencias de Perceus (§13.2) no necesite instrucciones
atómicas, aunque emisora y receptora corran en hilos
distintos.
Lo importante es la garantía: no hay manera de que dos fibras
tengan un puntero al mismo objeto. Las data races, los
problemas de visibility de memoria, los bugs de cache coherence:
todo lo que en threads tradicionales necesita lectura/escritura
con Atomic o locks no existe aquí.
13.2 Perceus en una página
Sin GC ni borrow checker, la memoria se libera con un tercer enfoque: reference counting estricto basado en Perceus (Lorenz, Leijen, Reinking, 2021).
El compilador analiza cada función para descubrir en qué punto cada valor deja de usarse, y ahí inserta una instrucción que decrementa su contador de referencias: si llega a cero, se libera; si no, queda para otro uso.
fn ejemplo(xs: [Int]) : Int {
let n = list.length(xs) # primer uso de xs
let s = list.sum(xs) # último uso de xs: aquí se "consume"
s + n # xs ya no existe; n y s sí
}
A diferencia de un GC:
- No hay pausa. El liberado es síncrono, predecible, parte del código generado.
- No hay overhead asincrónico. El compilador conoce el ciclo de vida exacto de cada valor.
- No hay hilo aparte. El scheduler no compite con un colector.
A diferencia de un borrow checker:
- No hay anotaciones de lifetime. El programador no escribe
'ani&nimut. - No hay limitaciones de pattern de uso. Si necesitas dos referencias al mismo valor, el compilador inserta los increments y decrements necesarios.
El costo: cuando un valor se usa varias veces, los contadores se mueven. Para valores muy compartidos esto puede agregar overhead, y Perceus tiene optimizaciones agresivas para minimizarlo (reuse in place: si un valor está por liberarse y se necesita uno del mismo shape inmediatamente después, se reusa la misma memoria sin tocar el contador). En la práctica el costo es bajo y predecible.
Para el caso extremo, un cálculo que arma montañas de
estructura descartable solo para plegarla a un escalar,
existe un escape opt-in: el bloque region, que asigna en
una arena y la libera entera de un golpe, sin contadores de
por medio. Es parte del mismo catálogo de kinds que las
unidades del capítulo 10, y lo conoces en el capítulo 19.
Por qué esto importa para concurrencia: Perceus funciona por fibra. Cada fibra tiene sus propios contadores y sus propios liberados, y no hay sincronización entre fibras para ningún contador, porque nunca dos fibras se pasan punteros al mismo valor con contador compartido. Por eso el modelo de “fibras aisladas” cierra con Perceus: el mismo invariante que protege contra data races simplifica el RC.
13.3 Crear y esperar fibras: las operaciones básicas
La forma más simple de usar fibras es con spawn.spawn y
spawn.await:
import spawn
fn worker(tag: String, n: Int) : Unit / Stdout + Spawn {
if n > 0 {
println(tag)
spawn.yield()
worker(tag, n - 1)
}
}
fn main() {
let f = spawn.spawn(() => worker("B", 3))
worker("A", 3)
spawn.await(f)
}
Una salida posible:
$ kai run ejemplos/cap13/01_dos_fibras.kai
A
B
A
B
A
B
“Una salida posible” es literal. Este programa no promete
ese orden. A y B son fibras independientes: nada en el
código dice que la primera A va antes que la primera B. El
runtime reparte las fibras entre tantos hilos del sistema como
núcleos tenga tu máquina, así que vas a ver órdenes distintos
entre corrida y corrida: A A A B B B es tan válido como el
intercalado de arriba.
Para obtener la salida perfectamente alternada:
$ KAI_THREADS=1 kai run ejemplos/cap13/01_dos_fibras.kai
A
B
A
B
A
B
KAI_THREADS=1 fuerza al scheduler a un solo hilo, y ahí sí
spawn.yield() es lo único que decide de quién es el turno.
Usa ese modo para leer los ejemplos del capítulo cuando el
punto sea ver la alternancia. Lo retomas en §13.8; por ahora
quédate con que la alternancia es una propiedad del scheduler
de un hilo, no una garantía del lenguaje.
Lectura literal:
import spawntrae las operaciones de fibras.spawn.spawn(() => worker("B", 3))crea una fibra nueva que va a correr el lambda cuando el scheduler la elija.worker("A", 3)corre en la fibra actual (la delmain).spawn.yield()dentro deworkercede el control: la fibra declara un punto donde puede perder el turno.spawn.await(f)espera a queftermine antes de quemainretorne.
Spawn aparece en la firma de worker porque la función llama
a spawn.yield(), una operación de Spawn. La fila contagia
hacia arriba como cualquier efecto del cap. 12.
Por qué los yields son explícitos
En lenguajes con threads preemptivas (Java, Go, Rust con
std::thread), el scheduler puede interrumpir un thread en
cualquier instrucción, y eso te obliga a programar como si
cualquier línea pudiera ser interrumpida por otra fibra que
modifica datos compartidos.
En kaikai, una fibra sigue corriendo hasta que llega a un punto de yield. Entre yields tienes determinismo local: ninguna otra fibra corre en tu hilo, y las que corren en otros hilos no tienen cómo alcanzar tus valores. Esto reduce mucho la carga cognitiva.
A cambio, tienes que acordarte de poner los yields. La regla
mental: si tu función tiene un bucle largo de puro cómputo,
agrega un spawn.yield() cada cierto número de iteraciones.
Las funciones de IO ya yieldan por dentro.
13.4 Nurseries: concurrencia estructurada
spawn.spawn + spawn.await funciona, pero si te olvidas del
await, la fibra sigue viva más allá del scope donde la
creaste. Y si esa fibra falla, te enteras tarde o no te
enteras.
Las nurseries atan las fibras a un scope léxico. Una fibra solo puede vivir dentro de un nursery, y el nursery espera a todas sus hijas antes de salir.
# ejemplos/cap13/02_nursery.kai
import spawn
fn worker(tag: String, n: Int) : Unit / Stdout + Spawn {
if n > 0 {
println(tag)
spawn.yield()
worker(tag, n - 1)
}
}
fn main() : Unit / Stdout + Spawn + Cancel {
nursery { n ->
n.spawn(() => worker("A", 3)) |> ignore
n.spawn(() => worker("B", 3)) |> ignore
}
}
Una salida posible:
$ kai run ejemplos/cap13/02_nursery.kai
A
A
A
B
B
B
nursery { n -> ... } abre un scope. Adentro, n es la
capacidad para crear fibras:
n.spawn(f)crea una fibra hija. Devuelve unFiber[T]dondeTes el tipo quefdevuelve. Aquí no lo necesitas, porque el nursery espera a las fibras de todos modos, y|> ignoredice que lo descartas a propósito.n.await(f)espera a esa fibra y devuelve su valor. Solo lo necesitas cuando quieres el resultado.n.select([a, b, ...])espera a que cualquiera termine y cancela las demás.n.cancel(f)cancela una fibra específica.n.cancel_all()cancela todas las hijas.
Lo que el nursery garantiza:
- Al salir del bloque, todas las hijas terminaron. El
nursery hace join automático de cada hija al cerrar la
llave, sin que pidas un
await. No hay fugas: ninguna fibra sobrevive alnurseryque la creó. - Si una hija falla por su cuenta, las demás se cancelan.
Cuando una hija lanza
Cancelsin que nadie se lo haya pedido (un crash), el nursery cancela a las hermanas que siguen vivas y re-lanza la causa fuera del scope. En cambio, una hija que cancelas a pedido conn.canceltermina como resultado esperado y no contagia a las demás. - Si el nursery se cancela desde afuera, propaga la cancelación a todas sus hijas.

Figura 13.1 · Concurrencia estructurada en una imagen. El nursery es un ámbito léxico; las fibras hijas viven adentro; nada se escapa. Si una hija falla, el nursery cancela al resto antes de re-lanzar; si al padre lo cancelan desde afuera, la cascada baja.
Esto se llama concurrencia estructurada. La idea es de
Nathaniel Smith en su ensayo “Notes on structured concurrency,
or: Go statement considered harmful” (2018), y aparece también
en Trio, Kotlin coroutines, Swift y OCaml 5 Eio. kaikai integra
el patrón en el sistema de efectos: la capacidad Spawn solo
está disponible dentro de un nursery, y el sistema de tipos lo
exige.
n.select es la primitiva de carrera: el nursery devuelve el
valor de la primera hija que termina y cancela a la otra sin
que lo pidas.
# ejemplos/cap13/04_race.kai
import spawn
fn rapido() : Int / Stdout + Spawn {
println("rapido: empieza")
spawn.yield()
println("rapido: termina")
1
}
fn lento() : Int / Stdout + Spawn {
println("lento: empieza")
spawn.yield()
spawn.yield()
spawn.yield()
println("lento: termina")
2
}
fn main() : Unit / Stdout + Spawn + Cancel {
let ganador = nursery { n ->
let a = n.spawn(() => rapido())
let b = n.spawn(() => lento())
n.select([a, b])
}
println("ganó: #{ganador}")
}
Una salida posible:
$ kai run ejemplos/cap13/04_race.kai
rapido: empieza
lento: empieza
lento: termina
rapido: termina
ganó: 1
Por qué Cancel aparece en la firma de main
main declara / Stdout + Spawn + Cancel porque cada spawn,
await y select es un punto de yield, y todo punto de yield
puede recibir una Cancel.raise() desde el scheduler (si
alguien cancela el nursery desde afuera, o si una fibra
hermana falla). Toda función que use Spawn carga
implícitamente Cancel.
nursery es azúcar sobre un handle
nursery { n -> ... } parece una palabra clave del lenguaje,
pero no lo es. Las fibras son un efecto llamado Spawn,
declarado así en el stdlib:
effect Spawn {
spawn[T, e](f: () -> T / e) : Fiber[T]
await[T](f: Fiber[T]) : T
select[T](fs: [Fiber[T]]) : T
yield() : Unit
cancel[T](f: Fiber[T]) : Unit
}
Es un efecto ordinario, declarado igual que Log o State[T]
del cap. 12. Y nursery { n -> body } se reescribe en tiempo
de compilación a handle { body } with Spawn as n { ... },
con un handler interno que gestiona el árbol de fibras hijas,
espera a las pendientes al salir y propaga fallos.
El lenguaje core, entonces, no tiene primitivas de
concurrencia: tiene efectos. Las fibras, los nurseries y la
cancelación son una biblioteca sobre dos efectos del stdlib
(Spawn y Cancel). En el cap. 14 los actores repiten el
patrón (efecto Actor[Msg]): lo distintivo de kaikai es que
todas sus construcciones son la misma (efectos
algebraicos) con nombres distintos.
Para quedarte con los resultados en vez de solo esperarlos,
un await por hija:
# ejemplos/cap13/05_worker_pool.kai
import spawn
fn procesar(id: Int, x: Int) : Int / Stdout + Spawn {
println("worker #{id}: procesando #{x}")
spawn.yield()
spawn.yield()
let r = x * x
println("worker #{id}: listo #{x} -> #{r}")
r
}
fn main() : Unit / Stdout + Spawn + Cancel {
let resultados = nursery { n ->
let a = n.spawn(() => procesar(1, 3))
let b = n.spawn(() => procesar(2, 5))
let c = n.spawn(() => procesar(3, 7))
[n.await(a), n.await(b), n.await(c)]
}
println("resultados: #{resultados}")
}
Una salida posible:
$ kai run ejemplos/cap13/05_worker_pool.kai
worker 1: procesando 3
worker 2: procesando 5
worker 2: listo 5 -> 25
worker 1: listo 3 -> 9
worker 3: procesando 7
worker 3: listo 7 -> 49
resultados: [9, 25, 49]
13.5 Cancelación cooperativa
La cancelación en kaikai es cooperativa: el scheduler no
mata a una fibra de golpe. Le entrega una Cancel.raise() en
el próximo punto de yield. La fibra puede:
- Desenrollarse limpiamente. Si no maneja
Cancel, el unwinding la saca de cualquierhandle,nursery, etc., y los handlers de cancelación por encima toman el control. - Manejar
Cancelpara hacer cleanup. La fibra instala un handler deCancelque corre el cleanup (cerrar archivos, soltar conexiones) y NO llama aresume, dejando que el unwinding continúe.
fn trabajador_largo(tag: String) : Unit / Stdout + Spawn + Cancel {
handle {
contar(tag, 0)
} with Cancel {
raise(resume) -> {
println("#{tag}: cancelado, hago cleanup")
# no llamamos a resume: la fibra se desenrolla
}
}
}
fn contar(tag: String, n: Int) : Unit / Stdout + Spawn + Cancel {
println("#{tag}: #{n}")
spawn.yield()
contar(tag, n + 1)
}
trabajador_largo cuenta indefinidamente. Si el nursery la
cancela, el handler de Cancel imprime el mensaje y la fibra
sale, sin quedarse colgada ni abortar el proceso.
La clave conceptual: Cancel.raise() es la operación, y
handle ... with Cancel { ... } es el handler. Mismo patrón
del cap. 12: la cancelación es un efecto más, con un handler
que escribes tú o que instala el runtime.
Spawn.cancel(f) y los handlers de Cancel
Dos nombres parecidos que conviene separar:
- El efecto
Canceles lo que la fibra recibe cuando la cancelación le llega. Su única opraise()la inyecta el scheduler en el próximo punto de yield. Spawn.cancel(f)es lo que una fibra llama para pedirle al scheduler que entregueCancel.raise()a la fibraf.
Cuando llamas n.spawn(...) con un binding n y obtienes una
fibra hija, Spawn.cancel(hija) no la mata: agenda la entrega.
La hija, en su próximo yield, recibe Cancel.raise() y corre
su propio handler de Cancel, el que instaló con
with Cancel { ... }. Si no instaló ninguno, se desenrolla
limpiamente y los handlers más arriba en la pila (típicamente
del nursery) toman el control.
La única excepción es el trap-exit del modelo de actores (cap. 14): una fibra puede marcarse para que los crashes de sus pares le lleguen como mensajes a su mailbox en vez de gatillar cancelación. Es una opción explícita y local; por omisión rige la cancelación cooperativa descrita aquí.
Una hija que se rinde por su cuenta, las hermanas que reciben la cancelación, el cleanup de cada una y la caída que el nursery re-lanza al salir:
# ejemplos/cap13/03_cancel.kai
import spawn
# Una fibra que cuenta mientras nadie la cancela.
fn trabajador_largo(tag: String) : Unit / Stdout + Spawn + Cancel {
handle {
contar(tag, 0)
} with Cancel {
raise(resume) -> {
println("#{tag}: cancelado, hago cleanup")
# no llamamos a resume: la fibra se desenrolla
}
}
}
fn contar(tag: String, n: Int) : Unit / Stdout + Spawn + Cancel {
println("#{tag}: #{n}")
spawn.yield()
contar(tag, n + 1)
}
# Una fibra que se rinde después de unos pasos.
fn renunciar(n: Int) : Unit / Stdout + Spawn + Fail {
if n > 2 {
Fail.fail("renuncio después de 3 pasos")
} else {
println("paso #{n}")
spawn.yield()
renunciar(n + 1)
}
}
effect Fail {
fail(motivo: String) : Nothing
}
fn main() : Unit / Stdout + Spawn + Cancel {
nursery { n ->
n.spawn(() => trabajador_largo("A")) |> ignore
n.spawn(() => trabajador_largo("B")) |> ignore
let r = n.spawn(
() => handle {
renunciar(0)
} with Fail {
fail(motivo, resume) -> {
println("la fibra se rinde: #{motivo}")
Cancel.raise()
}
}
)
n.await(r)
}
}
Una salida posible:
$ kai run ejemplos/cap13/03_cancel.kai
A: 0
A: 1
B: 0
A: 2
A: 3
A: 4
B: 1
B: 2
B: 3
A: 5
paso 0
paso 1
B: 4
paso 2
A: 6
A: 7
A: 8
A: 9
B: 5
A: 10
la fibra se rinde: renuncio después de 3 pasos
A: 11
A: 12
A: cancelado, hago cleanup
B: 6
B: cancelado, hago cleanup
kai: nursery child cancelled; no survivors
13.6 Memoria mutable por fibra
En el cap. 12 §12.7 conociste var (celdas locales, azúcar
sobre State[T]) y el efecto Mutable (que rige a Ref[T] y
Array[T] cuando la mutación es observable). Todo eso funciona
igual que en código secuencial, con una sola adición del modelo
de fibras: la memoria mutable vive en el heap de la fibra que
la creó.
Cuando una fibra crea un Array[T] o un Ref[T], el espacio
sale de su propio heap y ninguna otra fibra tiene cómo
acceder a él: no hay punteros compartidos ni paso por
referencia entre fibras. Para darle un valor mutable a otra
fibra, lo mandas por mailbox (cap. 14) y el runtime copia el
contenido al heap del receptor.
La regla se chequea en el borde del spawn, no cuando el
programa corre. Si el thunk que le pasas a n.spawn arrastra
Mutable en su fila de efectos, el compilador lo rechaza ahí
mismo:
fn tocar(a: Array[Int]) : Unit / Mutable {
a[0] := 42
}
nursery { n ->
n.spawn(() => tocar(a)) |> ignore # ERROR
}
error: a spawned fiber cannot mutate state it did not create: `Mutable` in its effect row
= note: the spawned fiber gets a copy of what it captures, so a write is lost to the parent
= help: let one fiber own the state and send it the updates as messages (`kai info actors`)
La nota es el punto completo: la hija escribiría sobre su propia copia y el padre nunca vería el cambio. El compilador prefiere no dejarte escribir código que parece compartir estado y no lo comparte. El camino es el que dice la ayuda: una fibra dueña del estado, y las demás le mandan mensajes. El cap. 14 lo arma con actores.
Por eso la mutación no introduce data races en kaikai. Con
threads y memoria compartida, un Array[T] mutable necesita
locks o atomics para tocarse desde varios hilos. En kaikai, el
sistema de tipos garantiza que ningún Array[T] está siendo
modificado por dos fibras al mismo tiempo, porque ningún
Array[T] es accesible desde dos fibras al mismo tiempo. La
aislación de memoria del §13.1 cubre también las celdas
mutables.
13.7 Por qué las fibras no pueden escapar de su nursery
Un detalle del sistema de tipos: Fiber[T] es un handle
atado a su nursery. Ninguna función puede devolverlo, ni solo
ni envuelto: el compilador rechaza Fiber[T] en cualquier
parte del tipo de retorno, sea Option[Fiber[Int]],
[Fiber[Int]] o el payload de un tipo suma.
fn no_compila() : Fiber[Int] { # ERROR
nursery { n ->
n.spawn(() => 42) # no se puede devolver
}
}
Una fibra solo tiene sentido dentro del nursery que la creó. Si pudieras devolverla, ¿quién la esperaría? ¿Quién la cancelaría al terminar el nursery? La estructura se rompe.
Dentro del nursery, en cambio, puedes guardar un Fiber[T] en
una lista, un Option o un record, o capturarlo en la clausura
de una fibra hermana que lo espere. Una lista de fibras es el
caso típico:
nursery { n ->
let fibers = [1, 2, 3] | (x) => n.spawn(() => procesar(x))
fibers | (f) => n.await(f)
}
Esa lista vive dentro del nursery y no puede salir.
Esto cierra el modelo: cada fibra tiene un padre conocido y termina antes que él, y el sistema de tipos lo garantiza a nivel sintáctico, no por convención. No hay manera de que una fibra se quede huérfana.
13.8 Caso de estudio: repartir trabajo entre fibras
Cerramos con un patrón que vas a escribir muchas veces: repartir una lista de tareas entre varias fibras trabajadoras y juntar los resultados al final.
Antes del código, una restricción que decide la forma de la
solución: los handlers de efectos son locales a la fibra. Si instalas un handler de
State en el main y una fibra hija ejecuta State.get(),
el programa ni siquiera compila:
error: effect not handled in spawned fiber: State
--> main.kai:6:23
|
6 | let f = n.spawn(() => State.get())
| ^
= note: a fiber does not inherit the parent's handlers
= help: handle the effect inside the spawned body: `handle { ... } with State { ... }`
Es el mismo invariante de aislación que sostiene todo el
capítulo: una capacidad es parte del contexto de una fibra, y
una fibra no hereda el contexto de quien la creó. A través de
un spawn pasas los valores que capturas en el lambda, de ida,
y el valor de retorno que recoge await, de vuelta. Las capacidades
que sí resuelven dentro de una hija son de dos clases, y
ninguna es tuya: las que el runtime provee por fibra (Stdout,
Clock) y las que son de la fibra por construcción (Cancel,
Spawn, Actor).
El compilador lo ataja en la línea del spawn, donde está el
error, en vez de dejarlo reventar en runtime cuando la fibra
llega a la operación.
Así que la “cola compartida” que escribirías en Go con un
canal, o en Java con un BlockingQueue, aquí no se escribe
así. Repartes el trabajo por adelantado y cada fibra devuelve
lo suyo:
import spawn
import core.list
fn procesar_lote(id: Int, lote: [String]) : [String] / Spawn =
lote
| (t => {
spawn.yield()
"worker #{id}: #{t}"
})
procesar_lote no sabe que existen otras fibras: recibe su
lote, lo recorre y arma la lista de salida. El spawn.yield()
está para que las trabajadoras se turnen; no cambia el
resultado, solo lo hace concurrente de verdad.
El main reparte, espera y concatena:
fn main() : Unit / Stdout + Spawn + Cancel {
let resultados = nursery { n ->
let a = n.spawn(() => procesar_lote(1, ["alpha", "bravo"]))
let b = n.spawn(() => procesar_lote(2, ["charlie", "delta"]))
let c = n.spawn(() => procesar_lote(3, ["echo", "foxtrot"]))
n.await(a) ++ n.await(b) ++ n.await(c)
}
resultados |! println(.)
println("(#{list.length(resultados)} tareas procesadas)")
}
Salida:
$ kai run ejemplos/cap13/06_reparto_de_lotes.kai
worker 1: alpha
worker 1: bravo
worker 2: charlie
worker 2: delta
worker 3: echo
worker 3: foxtrot
(6 tareas procesadas)
Esta vez la salida sí está garantizada. Las fibras corren
como quieran y en los núcleos que haya, pero ninguna imprime:
solo devuelven listas, y el main las concatena en el orden
que eligió al escribir n.await(a) ++ n.await(b) ++ n.await(c); recién ahí imprime. El orden de la salida es
consecuencia del código, no del scheduler.
Esa es una regla de diseño que vale más que el ejemplo: si te importa el orden, ordénalo tú en el punto de reunión, no confíes en el de ejecución.
¿Y si el trabajo no se puede repartir por adelantado, si las tareas llegan de a poco, o son de duración muy dispareja y quieres que la fibra que se desocupa tome la siguiente? Ahí sí necesitas algo que posea la cola y responda pedidos. Ese algo es un actor, y es el capítulo 14.
Concurrencia y paralelismo
Dos palabras que se confunden seguido:
Concurrencia es una propiedad de tu programa: tener varias líneas de trabajo vivas a la vez, que progresan de manera independiente. Paralelismo es una propiedad de la máquina: que dos de esas líneas ejecuten instrucciones en el mismo instante, en núcleos distintos.
kaikai te da las dos. Las fibras, cooperativas y atadas a un nursery, son el mecanismo de concurrencia, y el runtime las reparte por omisión sobre tantos hilos del sistema como núcleos tenga la máquina, con un scheduler M:N que roba trabajo entre hilos. No pides ni configuras nada: el programa que escribiste en §13.3 ya usa tus dieciséis núcleos si los tienes.
Lo que no cambia con el número de hilos es la semántica:
- Cada fibra sigue teniendo su propia memoria. Un mensaje que cruza de un hilo a otro se copia, así que el conteo de referencias de Perceus sigue sin necesitar atómicos.
- Las fibras siguen siendo cooperativas: ninguna es interrumpida a media instrucción. Entre dos yields, una fibra tiene determinismo local.
- Nurseries, cancelación y
awaitse comportan igual con uno o con treinta y dos hilos.
Lo que sí cambia es lo que ya viste: el orden en que se
intercalan fibras independientes deja de ser predecible. Con
un hilo, spawn.yield() decide los turnos y la salida es
reproducible; con varios, no. Para eso está KAI_THREADS=1:
fuerza el scheduler cooperativo de un solo hilo. Es la
herramienta para depurar una carrera, para un test que compara
salida literal o para leer un ejemplo pedagógico como los de
este capítulo.
$ ./programa # tantos hilos como núcleos
$ KAI_THREADS=4 ./programa # cuatro, ni más ni menos
$ KAI_THREADS=1 ./programa # cooperativo, reproducible
La pregunta útil es si te va a servir. Si tu programa está limitado por IO (esperar red, leer archivos, recibir mensajes), las fibras te pagan incluso con un hilo, porque mientras una espera bytes las otras corren. Si está limitado por CPU, además de estructura te dan velocidad, siempre que el trabajo se pueda partir en pedazos que no dependan uno del otro. El ejemplo de esta sección tiene exactamente esa forma.
13.9 Filosofía: dos invariantes que vale recordar
Del capítulo, quédate con dos cosas:
-
Cada fibra tiene su propio heap. No hay shared memory entre fibras. La comunicación es por mensajes (mailbox de actor, resultado de
await). Las data races no existen por construcción, no por disciplina. -
Cada fibra tiene una vida atada a un scope léxico. No puede escapar del
nurseryque la creó; el sistema de tipos rechaza cualquier intento. Si el padre termina, todas las hijas terminaron. Si una hija falla, las hermanas se cancelan.
Estos dos invariantes se sostienen mutuamente. La aislación de memoria es lo que permite que Perceus funcione por fibra sin sincronización. La estructura léxica es lo que permite liberar la memoria predeciblemente al final del scope. Cualquier modelo que rompa uno de los dos rompe el otro.
A cambio, dejas de pensar en clases enteras de bugs:
- No hay
volatileniAtomicni memory ordering. - No hay
locknimutexniRwLock. - No hay borrow checker, ni
'alifetimes, niRc<RefCell<T>>. - No hay
async fnniFutureni colores de funciones. - No hay GC pause-the-world.
Es un trade-off: pierdes la libertad de tener punteros arbitrarios entre fibras. Lo que ganas en seguridad y en simplicidad mental justifica el modelo.
Ejercicios
13.1. Modifica el ejemplo §13.3 (dos fibras cooperativas)
para que worker("A") haga 5 iteraciones y worker("B") haga
2. ¿Cómo cambia la salida? ¿Qué pasa si quitas los
spawn.yield() de uno solo de los dos workers?
13.2. Una fibra crea un Array[Int] localmente y lo
modifica con a[i] := v. Después termina sin pasarlo a nadie.
¿Por qué este programa no introduce data races aunque otra fibra
esté corriendo concurrentemente? Da el argumento en dos líneas,
en términos del modelo de memoria por fibra de §13.1.
13.3. Implementa una función with_timeout[T](ms: Int, f: () -> T / Spawn) : Option[T] / Spawn + Cancel + Time. Usa
n.select para correr f contra una fibra que hace
Time.sleep(ms) y devuelve None. Pista: necesitas un tipo
suma local para distinguir “completó” de “timeout”.
13.4. El §13.8 reparte los lotes de antemano y en partes iguales. Modifícalo para que los lotes tengan tamaños muy dispares (uno con diez tareas, dos con una) y mide cuánto tarda el conjunto. Después reparte las once tareas en tres lotes parejos y vuelve a medir. ¿Por qué el reparto estático deja núcleos ociosos, y qué necesitarías para evitarlo?
13.5. Una fibra que entra a un bucle infinito sin
spawn.yield() no cede su turno. Escribe ese código y observa
qué pasa: primero con KAI_THREADS=1, después sin la
variable. ¿Por qué el síntoma cambia? Agrega yields cada N
iteraciones. ¿Cada cuántas? ¿Cómo decides el N?
13.6. En tu lenguaje habitual, busca un programa concurrente que escribiste o que mantienes. Cuenta cuántas líneas son “trabajo real” (la lógica del programa) versus cuántas son “concurrencia plumbing” (mutex, queues, atomics, async/await, callbacks). Estima qué porcentaje del código quedaría con el modelo de kaikai.