Coroutines en Lua: concurrencia cooperativa sin threads del sistema operativo

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

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP