Los hilos del sistema operativo son caros: cada uno consume varios megabytes de pila, la planificación del kernel introduce latencia y la sincronización entre ellos requiere mutexes, semáforos y todo un arsenal de primitivas que se vuelven complicadas enseguida. Lua toma un camino distinto: las corrutinas (coroutines). Son funciones que pueden pausarse en mitad de su ejecución, ceder el control a otra corrutina y reanudarse exactamente donde lo dejaron, todo dentro del mismo hilo del sistema operativo.
El modelo de concurrencia cooperativa
La concurrencia cooperativa significa que una tarea solo cede el control cuando lo decide explícitamente, no cuando el sistema operativo lo interrumpe. Esto elimina las condiciones de carrera (no hay ejecución paralela real) y hace que el código sea determinista y fácil de razonar. La desventaja es que una corrutina que no cede bloquea a todas las demás.
En Lua, una corrutina puede estar en cuatro estados:
- suspended: creada pero no iniciada, o en pausa después de un
yield. - running: ejecutándose en este momento.
- dead: ha terminado (retornó o lanzó un error).
- normal: activa pero no corriendo (ha reanudado a otra corrutina).
API básica de corrutinas
-- Crear una corrutina a partir de una función
local co = coroutine.create(function(a, b)
print("Inicio: " .. a .. ", " .. b)
local c = coroutine.yield(a + b) -- pausa y devuelve a+b al llamador
print("Reanudada con: " .. c)
return "fin"
end)
print(coroutine.status(co)) -- suspended
-- Primera reanudación: pasa argumentos iniciales
local ok, valor = coroutine.resume(co, 10, 20)
-- Imprime: "Inicio: 10, 20"
print(ok, valor) -- true 30 (ok=true, valor=lo que yield devolvió)
print(coroutine.status(co)) -- suspended
-- Segunda reanudación: pasa valor al yield
ok, valor = coroutine.resume(co, "hola")
-- Imprime: "Reanudada con: hola"
print(ok, valor) -- true fin (valor=lo que return devolvió)
print(coroutine.status(co)) -- dead
-- Intentar reanudar una corrutina muerta
ok, valor = coroutine.resume(co)
print(ok, valor) -- false cannot resume dead coroutine
coroutine.wrap: la versión simplificada
coroutine.wrap devuelve una función que, cada vez que se llama, reanuda la corrutina. Es más cómodo que create + resume cuando no necesitas comprobar errores manualmente:
local generador = coroutine.wrap(function()
for i = 1, 5 do
coroutine.yield(i * i)
end
end)
for i = 1, 5 do
print(generador()) -- 1, 4, 9, 16, 25
end
Iteradores con corrutinas
Las corrutinas son la forma más limpia de escribir iteradores que mantienen estado interno. En lugar de un closure con variables capturadas, la corrutina simplemente avanza en su ejecución normal:
-- Iterador que produce las permutaciones de una tabla
local function permutaciones(t, n)
n = n or #t
if n == 1 then
coroutine.yield(t)
else
for i = 1, n do
t[i], t[n] = t[n], t[i]
permutaciones(t, n - 1)
t[i], t[n] = t[n], t[i]
end
end
end
local function iterPermutaciones(t)
return coroutine.wrap(function() permutaciones(t) end)
end
local count = 0
for perm in iterPermutaciones({1, 2, 3}) do
count = count + 1
-- Copiar para no imprimir la misma referencia siempre
local copia = {table.unpack(perm)}
print(table.concat(copia, ", "))
end
print("Total:", count) -- 6
Máquinas de estado con corrutinas
Otro patrón muy habitual en videojuegos y sistemas embebidos es usar una corrutina por entidad para modelar su comportamiento como una secuencia lineal de pasos, sin tener que gestionar un estado externo:
-- Comportamiento de un enemigo en un juego
local function comportamientoEnemigo(enemigo)
while true do
-- Fase 1: patrullar durante 5 turnos
for i = 1, 5 do
enemigo.x = enemigo.x + 1
print("Patrullando en x=" .. enemigo.x)
coroutine.yield()
end
-- Fase 2: atacar si el jugador está cerca
print("¡Atacando!")
coroutine.yield()
-- Fase 3: retroceder
for i = 1, 3 do
enemigo.x = enemigo.x - 1
print("Retrocediendo en x=" .. enemigo.x)
coroutine.yield()
end
end
end
local enemigo = {x = 0}
local co = coroutine.create(function() comportamientoEnemigo(enemigo) end)
-- Simular 10 ticks del juego
for tick = 1, 10 do
print("--- Tick " .. tick)
coroutine.resume(co)
end
Scheduler cooperativo mínimo
Con una tabla de corrutinas y un bucle principal se puede construir un scheduler básico que las va rotando:
local Scheduler = {}
Scheduler.cola = {}
function Scheduler.spawn(f)
table.insert(Scheduler.cola, coroutine.create(f))
end
function Scheduler.run()
while #Scheduler.cola > 0 do
local pendientes = {}
for _, co in ipairs(Scheduler.cola) do
local ok, err = coroutine.resume(co)
if not ok then
print("Error en corrutina: " .. tostring(err))
end
if coroutine.status(co) ~= "dead" then
table.insert(pendientes, co)
end
end
Scheduler.cola = pendientes
end
end
-- Ejemplo de uso
Scheduler.spawn(function()
for i = 1, 3 do
print("Tarea A, paso " .. i)
coroutine.yield()
end
end)
Scheduler.spawn(function()
for i = 1, 3 do
print("Tarea B, paso " .. i)
coroutine.yield()
end
end)
Scheduler.run()
-- Salida intercalada: A1, B1, A2, B2, A3, B3
Corrutinas en el ecosistema Lua
Las corrutinas aparecen en casi todas las aplicaciones serias de Lua. En LÖVE 2D se usan para secuencias de animación y cutscenes. En OpenResty, la biblioteca ngx.sleep y las operaciones de red usan corrutinas internamente para dar la ilusión de código síncrono sin bloquear el worker de nginx. El tipo thread de Lua es precisamente una corrutina: el nombre «thread» refleja que conceptualmente representa una tarea independiente, aunque nunca hay paralelismo real.
La limitación principal es que una corrutina que realiza una operación de bloqueo a nivel del sistema operativo (una lectura de disco síncrona, por ejemplo) bloquea todo el proceso. Para evitarlo, las aplicaciones de alto rendimiento combinan corrutinas con I/O asíncrono no bloqueante, delegando el trabajo de espera al sistema operativo mediante select, epoll o similares.
Imagen: Pexels / Myburgh Roux
