Capítulo 15: Generadores con Coroutines
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:
- Función normal: ejecuta de inicio a fin
- Coroutine: puede pausar y reanudar múltiples veces
Analogía del Mundo Real
Piensa en una coroutine como leer un libro:
- Función normal: lees el libro completo sin parar
- Coroutine: pones un marcador, cierras el libro, y más tarde continúas desde donde te quedaste
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
success: true si no hubo errores...values: valores retornados por yield o return
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:
"suspended": creada o pausada en yield"running": ejecutándose actualmente"normal": ha resumido otra coroutine"dead": terminó o tuvo error
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
| Aspecto | Coroutines | Threads |
|---|---|---|
| Concurrencia | Cooperativa (explícita) | Preemptiva (automática) |
| Control | El programador decide cuándo ceder | El SO decide cuándo cambiar |
| Paralelismo | No (un solo núcleo) | Sí (múltiples núcleos) |
| Sincronización | No necesaria (determinista) | Locks, mutexes requeridos |
| Overhead | Muy bajo | Alto (context switching) |
| Debugging | Fá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:
- Generadores de datos
- State machines
- Parsers incrementales
- Animaciones/tweening
- I/O asíncrono cooperativo
Cuándo usar threads:
- Aprovechar múltiples núcleos CPU
- Operaciones bloqueantes simultáneas
- Procesamiento paralelo real
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:
table.sort,table.foreachpcall,xpcall(en versiones antiguas de Lua)- Callbacks desde C/C++
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:
- Ambos pausan y reanudan ejecución
- Ambos mantienen estado local
- Ambos evitan “callback hell”
Diferencias clave:
| Aspecto | Coroutines (Lua) | async/await |
|---|---|---|
| Sintaxis | Explícita (yield) | Implícita (await) |
| Tipado | Dinámico | Requiere tipos Promise<T>, Task<T> |
| Flexibilidad | Total (cualquier flujo) | Limitado a operaciones async |
| Overhead | Bajo | Medio (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:
- Pausable: cada
advance()hace yield - Memoria eficiente: no duplica estructuras
- Debuggeable: puedes pausar en cualquier momento
- 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:
- Usa una tabla para almacenar primos ya encontrados
- Para cada candidato, verifica si es divisible por primos previos
- yield cada primo encontrado
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:
- Concurrencia cooperativa sin complejidad de threads
- Generadores lazy que ahorran memoria
- State machines elegantes y mantenibles
- Parsers incrementales para grandes volúmenes de datos
- Sistemas asíncronos sin callback hell
Puntos clave:
coroutine.create()crea,resume()ejecuta,yield()pausawrap()es una versión simplificada sin manejo de errores- Las coroutines mantienen su propio stack independiente
- Son más flexibles que async/await pero requieren disciplina
En el próximo capítulo exploraremos FFI (Foreign Function Interface) para integrar código C/C++ con Lua de forma eficiente.
Lecturas recomendadas:
- Programming in Lua, 4th ed., Chapter 9: Coroutines
- Lua 5.4 Reference Manual: Coroutine Manipulation
- “Revisiting Coroutines” por Ana Lúcia de Moura