← Volver al listado de tecnologías

Capítulo 15: Generadores con Coroutines

Por: Artiko
luacoroutinesgeneratorsconcurrency

Capítulo 15: Generadores con Coroutines

Las coroutines son uno de los mecanismos más poderosos y elegantes de Lua para manejar flujos de ejecución no lineales. A diferencia de los threads tradicionales, las coroutines implementan concurrencia cooperativa: tu código decide explícitamente cuándo ceder el control.

En este capítulo aprenderás a dominar las coroutines para crear generadores, parsers incrementales, state machines y sistemas asíncronos elegantes.

1. ¿Qué son las Coroutines?

Una coroutine es una función que puede suspender su ejecución y reanudarla más tarde, manteniendo todo su estado (variables locales, punto de ejecución).

Conceptos Clave

-- Una coroutine es como una función con "pause"
function contador()
    for i = 1, 5 do
        print("Contador:", i)
        coroutine.yield()  -- Pausa aquí
    end
end

-- Crear la coroutine
co = coroutine.create(contador)

-- Ejecutar paso a paso
print("Primera llamada:")
coroutine.resume(co)  -- Contador: 1

print("Segunda llamada:")
coroutine.resume(co)  -- Contador: 2

print("Tercera llamada:")
coroutine.resume(co)  -- Contador: 3

Diferencia con funciones normales:

Analogía del Mundo Real

Piensa en una coroutine como leer un libro:

2. API de Coroutines

Lua proporciona 6 funciones principales en el módulo coroutine:

coroutine.create(f)

Crea una nueva coroutine a partir de una función:

function tarea()
    print("Inicio")
    coroutine.yield()
    print("Continuación")
    coroutine.yield()
    print("Fin")
end

co = coroutine.create(tarea)
print(type(co))  -- "thread" (nombre histórico, es una coroutine)

coroutine.resume(co, …)

Inicia o reanuda la ejecución de una coroutine:

-- Primer resume: ejecuta hasta el primer yield
status, result = coroutine.resume(co)
-- Imprime: "Inicio"
-- status = true (éxito)

-- Segundo resume: continúa desde el primer yield
coroutine.resume(co)
-- Imprime: "Continuación"

-- Tercer resume: continúa hasta el fin
coroutine.resume(co)
-- Imprime: "Fin"

Retorna: success, ...values

coroutine.yield(…)

Suspende la ejecución de la coroutine actual:

function productor()
    for i = 1, 3 do
        print("Produciendo", i)
        coroutine.yield(i)  -- Devuelve i al resume
    end
    return "completado"
end

co = coroutine.create(productor)

success, value = coroutine.resume(co)
print(value)  -- 1

success, value = coroutine.resume(co)
print(value)  -- 2

success, value = coroutine.resume(co)
print(value)  -- 3

success, value = coroutine.resume(co)
print(value)  -- "completado"

coroutine.status(co)

Devuelve el estado actual de una coroutine:

co = coroutine.create(function()
    coroutine.yield()
end)

print(coroutine.status(co))  -- "suspended"
coroutine.resume(co)
print(coroutine.status(co))  -- "suspended"
coroutine.resume(co)
print(coroutine.status(co))  -- "dead"

Estados posibles:

coroutine.wrap(f)

Versión simplificada que devuelve una función en lugar de una coroutine:

-- Con create (verbose):
co = coroutine.create(function(x)
    return x * 2
end)
success, result = coroutine.resume(co, 5)
print(result)  -- 10

-- Con wrap (conciso):
f = coroutine.wrap(function(x)
    return x * 2
end)
print(f(5))  -- 10 (sin necesidad de chequear success)

Diferencia clave: wrap lanza errores en lugar de retornarlos.

coroutine.running()

Devuelve la coroutine actualmente ejecutándose:

function info()
    local co, is_main = coroutine.running()
    print("Coroutine:", co)
    print("¿Es main?:", is_main)
end

info()
-- Coroutine: thread: 0x...
-- ¿Es main?: true

co = coroutine.create(info)
coroutine.resume(co)
-- Coroutine: thread: 0x...
-- ¿Es main?: false

3. Diferencia entre Coroutines y Threads

AspectoCoroutinesThreads
ConcurrenciaCooperativa (explícita)Preemptiva (automática)
ControlEl programador decide cuándo cederEl SO decide cuándo cambiar
ParalelismoNo (un solo núcleo)Sí (múltiples núcleos)
SincronizaciónNo necesaria (determinista)Locks, mutexes requeridos
OverheadMuy bajoAlto (context switching)
DebuggingFácil (predecible)Difícil (race conditions)
-- Coroutines: cambio explícito
function tarea_cooperativa()
    print("Trabajo 1")
    coroutine.yield()  -- Cedo el control AQUÍ
    print("Trabajo 2")
end

-- Threads: cambio impredecible
function tarea_preemptiva()
    print("Trabajo 1")
    -- El SO puede interrumpir AQUÍ, o AQUÍ, o AQUÍ...
    print("Trabajo 2")
end

Cuándo usar coroutines:

Cuándo usar threads:

4. Implementar Generadores Estilo Python

Las coroutines son perfectas para crear generadores lazy (evaluación perezosa):

Generador Básico: Range

function range(from, to, step)
    step = step or 1
    return coroutine.wrap(function()
        for i = from, to, step do
            coroutine.yield(i)
        end
    end)
end

-- Uso:
for n in range(1, 10, 2) do
    print(n)  -- 1, 3, 5, 7, 9
end

Generador de Fibonacci Infinito

function fibonacci()
    return coroutine.wrap(function()
        local a, b = 0, 1
        while true do
            coroutine.yield(a)
            a, b = b, a + b
        end
    end)
end

-- Tomar solo los primeros 10
fib = fibonacci()
for i = 1, 10 do
    print(fib())
end
-- 0, 1, 1, 2, 3, 5, 8, 13, 21, 34

Generador de Permutaciones

function permutations(arr, n)
    n = n or #arr
    return coroutine.wrap(function()
        local function permute(arr, n, used, current)
            if #current == n then
                coroutine.yield(table.concat(current, " "))
                return
            end

            for i = 1, #arr do
                if not used[i] then
                    used[i] = true
                    table.insert(current, arr[i])
                    permute(arr, n, used, current)
                    table.remove(current)
                    used[i] = false
                end
            end
        end

        permute(arr, n, {}, {})
    end)
end

-- Generar permutaciones de 2 elementos
for perm in permutations({"A", "B", "C"}, 2) do
    print(perm)
end
-- A B
-- A C
-- B A
-- B C
-- C A
-- C B

Generador de Archivos por Líneas

function lines_from_file(filename)
    return coroutine.wrap(function()
        local file = io.open(filename, "r")
        if not file then return end

        for line in file:lines() do
            coroutine.yield(line)
        end

        file:close()
    end)
end

-- Procesar archivo línea por línea (lazy)
for line in lines_from_file("config.txt") do
    print("Procesando:", line)
    -- Solo carga una línea en memoria a la vez
end

5. Comunicación Bidireccional con Coroutines

Las coroutines pueden recibir valores en cada resume, no solo enviarlos:

function filtro()
    while true do
        local valor = coroutine.yield()  -- Recibe valor
        if valor then
            print("Procesando:", valor)
        end
    end
end

co = coroutine.create(filtro)
coroutine.resume(co)  -- Inicializar

-- Enviar valores al filtro
coroutine.resume(co, 10)   -- Procesando: 10
coroutine.resume(co, 20)   -- Procesando: 20
coroutine.resume(co, nil)  -- (no imprime nada)

Pipeline de Productores y Consumidores

function productor()
    return coroutine.wrap(function()
        for i = 1, 5 do
            coroutine.yield(i * 2)
        end
    end)
end

function filtro(source)
    return coroutine.wrap(function()
        for value in source do
            if value % 4 == 0 then
                coroutine.yield(value)
            end
        end
    end)
end

function consumidor(source)
    for value in source do
        print("Consumido:", value)
    end
end

-- Pipeline: productor -> filtro -> consumidor
consumidor(filtro(productor()))
-- Consumido: 4
-- Consumido: 8

State Machine con Comunicación

function state_machine()
    local state = "idle"

    return coroutine.wrap(function()
        while true do
            local event = coroutine.yield(state)  -- Devuelve estado actual

            if state == "idle" then
                if event == "start" then
                    state = "running"
                end
            elseif state == "running" then
                if event == "pause" then
                    state = "paused"
                elseif event == "stop" then
                    state = "idle"
                end
            elseif state == "paused" then
                if event == "resume" then
                    state = "running"
                elseif event == "stop" then
                    state = "idle"
                end
            end
        end
    end)
end

sm = state_machine()
print(sm())            -- idle
print(sm("start"))     -- running
print(sm("pause"))     -- paused
print(sm("resume"))    -- running
print(sm("stop"))      -- idle

6. Manejo de Errores en Coroutines

Propagación de Errores

function tarea_con_error()
    print("Inicio")
    coroutine.yield()
    error("Algo salió mal!")
    coroutine.yield()
    print("Nunca llega aquí")
end

co = coroutine.create(tarea_con_error)

success, msg = coroutine.resume(co)
print(success, msg)  -- true, nil

success, msg = coroutine.resume(co)
print(success, msg)  -- false, [error message]

-- La coroutine ahora está "dead"
print(coroutine.status(co))  -- dead

Manejo Robusto con pcall

function safe_coroutine(f)
    return coroutine.wrap(function(...)
        local success, result = pcall(f, ...)
        if not success then
            print("Error capturado:", result)
        end
        return result
    end)
end

local co = safe_coroutine(function()
    print("Trabajando...")
    error("Fallo")
    print("Nunca se ejecuta")
end)

co()
-- Trabajando...
-- Error capturado: [error message]

Reintentos con Coroutines

function retry_generator(max_attempts)
    return coroutine.wrap(function()
        for attempt = 1, max_attempts do
            local success = coroutine.yield(attempt)
            if success then
                return "¡Éxito!"
            end
        end
        return "Falló después de " .. max_attempts .. " intentos"
    end)
end

retry = retry_generator(3)

print(retry())  -- 1 (intento 1)
print(retry(false))  -- 2 (intento 2)
print(retry(false))  -- 3 (intento 3)
print(retry(false))  -- "Falló después de 3 intentos"

7. DEEP DIVE: El Stack de Coroutines

Cómo Funciona Internamente

Cuando creas una coroutine, Lua asigna un stack independiente:

function nivel1()
    print("Nivel 1")
    nivel2()
end

function nivel2()
    print("Nivel 2")
    coroutine.yield()
    print("Nivel 2 continúa")
end

co = coroutine.create(nivel1)

Estado del stack al hacer yield:

Stack de la coroutine:
┌─────────────────┐
│ nivel2()        │ <- yield aquí
├─────────────────┤
│ nivel1()        │
├─────────────────┤
│ coroutine body  │
└─────────────────┘

Al hacer resume, la ejecución continúa exactamente desde ese punto del stack.

Limitaciones del Stack

-- NO puedes yield a través de una C boundary
function ejemplo()
    table.sort({3, 1, 2}, function(a, b)
        coroutine.yield()  -- ¡ERROR! sort es C code
        return a < b
    end)
end

co = coroutine.create(ejemplo)
coroutine.resume(co)
-- Error: attempt to yield across a C-call boundary

Funciones que NO permiten yield:

Solución: Lua 5.2+ permite yield en pcall:

function safe_yield()
    pcall(function()
        coroutine.yield()  -- OK en Lua 5.2+
    end)
end

Introspección del Stack

function mostrar_stack()
    local nivel = 1
    while true do
        local info = debug.getinfo(nivel, "Snl")
        if not info then break end

        print(string.format(
            "Nivel %d: %s:%d en %s",
            nivel,
            info.source,
            info.currentline,
            info.name or "?"
        ))

        nivel = nivel + 1
    end
end

function profundo()
    coroutine.yield()
end

function medio()
    profundo()
end

function arriba()
    medio()
end

co = coroutine.create(arriba)
coroutine.resume(co)

-- Dentro de profundo, inspeccionar:
-- (necesitarías inyectar mostrar_stack en profundo)

8. SOAPBOX: Coroutines vs async/await

La Perspectiva de Lua

Muchos lenguajes modernos (JavaScript, Python 3.5+, C#) han adoptado async/await para código asíncrono. Lua tuvo coroutines desde 1993.

Similitudes:

Diferencias clave:

AspectoCoroutines (Lua)async/await
SintaxisExplícita (yield)Implícita (await)
TipadoDinámicoRequiere tipos Promise<T>, Task<T>
FlexibilidadTotal (cualquier flujo)Limitado a operaciones async
OverheadBajoMedio (state machines generados)

Ejemplo Comparativo

Lua con Coroutines:

function fetch_user(id)
    local user = coroutine.yield("db:get_user", id)
    local posts = coroutine.yield("db:get_posts", user.id)
    return {user = user, posts = posts}
end

JavaScript con async/await:

async function fetchUser(id) {
    const user = await db.getUserById(id);
    const posts = await db.getPostsByUser(user.id);
    return {user, posts};
}

Mi opinión: async/await es más seguro (el compilador obliga a manejar Promises), pero coroutines son más poderosas (puedes implementar async/await sobre coroutines, pero no al revés).

Implementando async/await en Lua

-- Sistema async básico
local pending = {}

function async(f)
    return coroutine.wrap(f)
end

function await(promise)
    table.insert(pending, coroutine.running())
    return coroutine.yield()
end

function resolve(value)
    local co = table.remove(pending, 1)
    if co then
        coroutine.resume(co, value)
    end
end

-- Uso:
local task = async(function()
    print("Iniciando...")
    local result = await("operación")
    print("Resultado:", result)
end)

task()  -- "Iniciando..."
resolve(42)  -- "Resultado: 42"

9. Caso Práctico: Parser Incremental de JSON

Vamos a crear un parser de JSON que procesa grandes archivos sin cargarlos completos en memoria:

-- Parser de JSON streaming con coroutines
JSONParser = {}
JSONParser.__index = JSONParser

function JSONParser.new(source)
    local self = setmetatable({}, JSONParser)
    self.source = source
    self.pos = 1
    return self
end

function JSONParser:peek()
    if self.pos > #self.source then
        return nil
    end
    return self.source:sub(self.pos, self.pos)
end

function JSONParser:advance()
    self.pos = self.pos + 1
    coroutine.yield()  -- Permite procesamiento incremental
end

function JSONParser:skip_whitespace()
    while self:peek() and self:peek():match("%s") do
        self:advance()
    end
end

function JSONParser:parse_string()
    local result = ""
    self:advance()  -- Saltar "

    while self:peek() ~= '"' do
        local char = self:peek()
        if not char then
            error("String sin terminar")
        end

        if char == "\\" then
            self:advance()
            char = self:peek()
            -- Escapado simplificado
            if char == "n" then char = "\n"
            elseif char == "t" then char = "\t"
            end
        end

        result = result .. char
        self:advance()
    end

    self:advance()  -- Saltar "
    return result
end

function JSONParser:parse_number()
    local num_str = ""

    while self:peek() and self:peek():match("[%d.eE+%-]") do
        num_str = num_str .. self:peek()
        self:advance()
    end

    return tonumber(num_str)
end

function JSONParser:parse_value()
    self:skip_whitespace()
    local char = self:peek()

    if char == '"' then
        return self:parse_string()
    elseif char == 't' or char == 'f' then
        return self:parse_boolean()
    elseif char == 'n' then
        return self:parse_null()
    elseif char == '{' then
        return self:parse_object()
    elseif char == '[' then
        return self:parse_array()
    else
        return self:parse_number()
    end
end

function JSONParser:parse_array()
    local array = {}
    self:advance()  -- Saltar [
    self:skip_whitespace()

    if self:peek() == ']' then
        self:advance()
        return array
    end

    while true do
        table.insert(array, self:parse_value())
        self:skip_whitespace()

        if self:peek() == ']' then
            self:advance()
            break
        elseif self:peek() == ',' then
            self:advance()
        else
            error("Array mal formado")
        end
    end

    return array
end

function JSONParser:parse_object()
    local obj = {}
    self:advance()  -- Saltar {
    self:skip_whitespace()

    if self:peek() == '}' then
        self:advance()
        return obj
    end

    while true do
        self:skip_whitespace()
        local key = self:parse_string()
        self:skip_whitespace()

        if self:peek() ~= ':' then
            error("Se esperaba :")
        end
        self:advance()

        obj[key] = self:parse_value()
        self:skip_whitespace()

        if self:peek() == '}' then
            self:advance()
            break
        elseif self:peek() == ',' then
            self:advance()
        else
            error("Objeto mal formado")
        end
    end

    return obj
end

function JSONParser:parse_boolean()
    if self.source:sub(self.pos, self.pos + 3) == "true" then
        self.pos = self.pos + 4
        return true
    elseif self.source:sub(self.pos, self.pos + 4) == "false" then
        self.pos = self.pos + 5
        return false
    end
    error("Booleano inválido")
end

function JSONParser:parse_null()
    if self.source:sub(self.pos, self.pos + 3) == "null" then
        self.pos = self.pos + 4
        return nil
    end
    error("Null inválido")
end

-- Función wrap para uso con coroutines
function parse_json_incremental(json_string)
    return coroutine.wrap(function()
        local parser = JSONParser.new(json_string)
        local result = parser:parse_value()
        return result
    end)
end

-- Uso:
local json = [[
{
    "nombre": "Ana",
    "edad": 30,
    "hobbies": ["leer", "programar"],
    "activo": true
}
]]

local parser = parse_json_incremental(json)
local data = parser()

print(data.nombre)  -- Ana
print(data.edad)    -- 30
print(data.hobbies[1])  -- leer

Ventajas de este enfoque:

  1. Pausable: cada advance() hace yield
  2. Memoria eficiente: no duplica estructuras
  3. Debuggeable: puedes pausar en cualquier momento
  4. Extensible: fácil añadir validación o transformaciones

10. Ejercicios

Ejercicio 1: Generador de Números Primos

Implementa un generador infinito de números primos usando la criba de Eratóstenes:

function primes()
    -- Tu código aquí
    -- Debe devolver coroutine que genera primos: 2, 3, 5, 7, 11...
end

-- Prueba:
local gen = primes()
for i = 1, 10 do
    print(gen())
end
-- Debe imprimir: 2, 3, 5, 7, 11, 13, 17, 19, 23, 29

Pistas:

Solución
function primes()
    return coroutine.wrap(function()
        local found = {}
        local candidate = 2

        while true do
            local is_prime = true

            for _, prime in ipairs(found) do
                if candidate % prime == 0 then
                    is_prime = false
                    break
                end

                -- Optimización: solo verificar hasta sqrt(candidate)
                if prime * prime > candidate then
                    break
                end
            end

            if is_prime then
                table.insert(found, candidate)
                coroutine.yield(candidate)
            end

            candidate = candidate + 1
        end
    end)
end

Ejercicio 2: Sistema de Tareas Asíncronas

Implementa un scheduler simple que ejecute múltiples coroutines concurrentemente:

Scheduler = {}

function Scheduler:new()
    -- Tu código aquí
end

function Scheduler:spawn(f)
    -- Añade una nueva tarea
end

function Scheduler:run()
    -- Ejecuta todas las tareas hasta que terminen
end

-- Prueba:
sched = Scheduler:new()

sched:spawn(function()
    for i = 1, 3 do
        print("Tarea A:", i)
        coroutine.yield()
    end
end)

sched:spawn(function()
    for i = 1, 3 do
        print("Tarea B:", i)
        coroutine.yield()
    end
end)

sched:run()
-- Debe intercalar: A:1, B:1, A:2, B:2, A:3, B:3
Solución
Scheduler = {}
Scheduler.__index = Scheduler

function Scheduler:new()
    local self = setmetatable({}, Scheduler)
    self.tasks = {}
    return self
end

function Scheduler:spawn(f)
    table.insert(self.tasks, coroutine.create(f))
end

function Scheduler:run()
    while #self.tasks > 0 do
        for i = #self.tasks, 1, -1 do
            local task = self.tasks[i]
            local status = coroutine.status(task)

            if status == "dead" then
                table.remove(self.tasks, i)
            else
                local success, err = coroutine.resume(task)
                if not success then
                    print("Error en tarea:", err)
                    table.remove(self.tasks, i)
                end
            end
        end
    end
end

Ejercicio 3: Pipeline de Transformación de Datos

Crea un sistema de pipeline donde cada etapa es una coroutine que transforma datos:

-- Implementa estas funciones generadoras:

function map(source, fn)
    -- Aplica fn a cada elemento de source
end

function filter(source, predicate)
    -- Solo deja pasar elementos que cumplan predicate
end

function take(source, n)
    -- Toma solo los primeros n elementos
end

-- Prueba:
local numbers = range(1, 100)
local evens = filter(numbers, function(x) return x % 2 == 0 end)
local squares = map(evens, function(x) return x * x end)
local first_five = take(squares, 5)

for value in first_five do
    print(value)
end
-- Debe imprimir: 4, 16, 36, 64, 100
Solución
function map(source, fn)
    return coroutine.wrap(function()
        for value in source do
            coroutine.yield(fn(value))
        end
    end)
end

function filter(source, predicate)
    return coroutine.wrap(function()
        for value in source do
            if predicate(value) then
                coroutine.yield(value)
            end
        end
    end)
end

function take(source, n)
    return coroutine.wrap(function()
        local count = 0
        for value in source do
            if count >= n then break end
            coroutine.yield(value)
            count = count + 1
        end
    end)
end

-- Bonus: función reduce
function reduce(source, init, fn)
    local accumulator = init
    for value in source do
        accumulator = fn(accumulator, value)
    end
    return accumulator
end

-- Ejemplo de uso con reduce:
local sum = reduce(
    take(range(1, 100), 10),
    0,
    function(acc, x) return acc + x end
)
print(sum)  -- 55 (suma de 1 a 10)

Resumen del Capítulo

Las coroutines son una herramienta fundamental en Lua que permite:

  1. Concurrencia cooperativa sin complejidad de threads
  2. Generadores lazy que ahorran memoria
  3. State machines elegantes y mantenibles
  4. Parsers incrementales para grandes volúmenes de datos
  5. Sistemas asíncronos sin callback hell

Puntos clave:

En el próximo capítulo exploraremos FFI (Foreign Function Interface) para integrar código C/C++ con Lua de forma eficiente.

Lecturas recomendadas: