Async sin async: el await que te fabricas
Cómo se construye un modelo de operaciones asíncronas sobre corrutinas: la función que cede esperando un resultado, el callback que reanuda al llegar, y el color que Lua no comprueba.
Los lenguajes que traen async y await de fábrica pagan por ello con un compilador que transforma tu función en una máquina de estados. Lua no tiene esas palabras y no las necesita, porque una corrutina suspendida ya es la máquina de estados que el compilador de los otros lenguajes fabrica a mano. Lo que falta es de una modestia casi cómica: unas veinte líneas que conviertan una función de callback en una función que se pueda esperar. A partir de ahí, código que se lee de arriba abajo ejecuta operaciones que tardan, sin anidar callbacks y sin invertir el control. La factura llega en otra moneda: nadie comprueba que estés dentro de una corrutina cuando esperas, y el error aparece lejos de la causa.
- Explicar qué inversión de control introduce un callback y por qué una corrutina la deshace.
- Implementar la primitiva de espera que cede y el reanudador que la despierta, incluida la carrera con la respuesta síncrona.
- Propagar errores desde el callback hasta el punto de espera sin que escapen al bucle de eventos.
- Reconocer el problema del color de las funciones y cómo se manifiesta en Lua sin ayuda del compilador.
El callback y la inversión que provoca
Una operación asíncrona es cualquiera que no puede darte el resultado en el acto: leer un fichero, hablar con la red, esperar a un proceso hijo. La interfaz habitual pasa una función que se llamará cuando el resultado exista.
leer("a.txt", function(err, a)
if err then return avisar(err) end
leer("b.txt", function(err, b)
if err then return avisar(err) end
escribir("c.txt", a .. b, function(err)
if err then return avisar(err) end
avisar("listo")
end)
end)
end)
El problema de esta forma no es la estética. Es que has perdido el derecho a usar las construcciones del lenguaje: no puedes envolver la secuencia en un if, ni repetirla en un bucle, ni protegerla con pcall, ni devolver su resultado, porque el flujo ya no está en tu función sino repartido entre cierres que otro invocará más tarde. Eso es la inversión de control, y una corrutina la deshace por completo, porque una corrutina puede detenerse en mitad de un bucle y continuar después exactamente donde estaba.
La función que cede y el que reanuda
La pieza central es una función que reciba cómo iniciar la operación, la inicie pasándole un callback propio, y ceda hasta que ese callback llegue.
local function esperar(iniciar)
local co = assert(coroutine.running(), "esperar solo dentro de una corrutina")
local respuesta, cedio = nil, false
iniciar(function(...)
respuesta = table.pack(...)
if cedio then
local ok, err = coroutine.resume(co)
if not ok then error(err, 0) end
end
end)
if respuesta == nil then
cedio = true
coroutine.yield()
end
return table.unpack(respuesta, 1, respuesta.n)
end
Esas dos banderas resuelven la única carrera del diseño. El callback puede dispararse de dos maneras: dentro de la propia llamada a iniciar, si la operación resultó estar ya resuelta y la biblioteca responde en el acto, o mucho después desde el bucle de eventos. En el primer caso respuesta ya existe cuando llegamos al if y no cedemos nunca; en el segundo cedemos y el callback nos reanuda. Que baste con una comprobación tan simple depende de un hecho que en un sistema con hilos sería falso: no hay expropiación. Nada puede ejecutarse entre la asignación de cedio y el yield, porque en Lua solo hay un flujo y en ese tramo lo tenemos nosotros. Toda la corrección de este código descansa en esa frase.
Con esa pieza, el ejemplo anidado de antes se endereza:
local function copiar_concatenado()
local a = esperar(function(k) leer("a.txt", k) end)
local b = esperar(function(k) leer("b.txt", k) end)
esperar(function(k) escribir("c.txt", a .. b, k) end)
return #a + #b
end
coroutine.wrap(function()
local ok, r = pcall(copiar_concatenado)
print(ok, r)
end)()
Tres operaciones que tardan, escritas en tres líneas sucesivas, con variables locales que sobreviven entre ellas, con un pcall que las cubre todas y con un valor de retorno de verdad. No se ha añadido ninguna capacidad al lenguaje: se ha usado que la corrutina conserva su pila mientras espera.
sequenceDiagram participant T as Tarea participant E as esperar participant B as Bucle de eventos T->>E: esperar con la operacion E->>B: inicia y registra el callback E-->>T: yield y la tarea queda suspendida B->>B: sigue atendiendo a otros B->>E: llega el resultado y ejecuta el callback E->>T: resume con el valor y la tarea continua
Falta el error. La convención sensata es la de la biblioteca estándar de Lua invertida al estilo de los callbacks: el primer valor del callback es el fallo o nil. Conviene envolver la espera para que un fallo se convierta en una excepción normal dentro de la corrutina, donde pcall puede verla:
local function esperar_ok(iniciar)
local err, valor = esperar(iniciar)
if err then error(err, 2) end
return valor
end
Y hay un detalle que muerde a todo el mundo la primera vez. El resume que despierta a la tarea ocurre en la pila del bucle de eventos, no en la de quien creó la tarea. Un error que escape de la corrutina en ese momento sube por el bucle de eventos y mata el programa entero en un sitio que no dice nada. Por eso el cuerpo de toda tarea debe arrancarse envuelto en xpcall con un manejador que registre el traceback, y por eso el error(err, 0) del reanudador de arriba es una decisión, no un adorno.
El color que nadie comprueba
En los lenguajes con async y await, esas palabras son un tipo: el compilador sabe que una función asíncrona solo puede esperarse desde otra asíncrona y te lo dice antes de ejecutar. En Lua la misma restricción existe —cualquier función que llame a esperar solo funciona dentro de una corrutina— pero es dinámica y silenciosa. Tu función tiene color; lo que no tienes es a nadie que lo pinte.
Las consecuencias son concretas. Una función que hoy es síncrona y mañana necesita esperar contamina a todos sus llamantes de golpe, y no hay forma automática de encontrarlos: el fallo se manifiesta como un error de yield en tiempo de ejecución, quizá meses después, en la única ruta del programa que no cubría ningún test. La defensa razonable es de disciplina, no de mecanismo: marcar en el nombre o en la documentación qué funciones esperan, envolver los puntos de entrada con assert(coroutine.isyieldable()) para que el fallo ocurra pronto y con un mensaje que se entienda, y no dejar que el color se filtre a través de callbacks de terceros.
Con esa disciplina asumida, el modelo se completa con una función que lance tareas y las deje correr, que es la frontera exacta entre el mundo síncrono y el asíncrono de tu programa:
local function lanzar(f, ...)
local co = coroutine.create(f)
local ok, err = coroutine.resume(co, ...)
if not ok then
io.stderr:write(debug.traceback(co, err), "\n")
end
return co
end
Todo lo que hay dentro de f puede esperar; todo lo que llame a lanzar no puede, y tampoco recibe el resultado, porque en el momento de retornar la tarea probablemente aún no ha terminado. Esa frontera es la que en otros lenguajes marca la palabra async sobre la firma, con la diferencia de que aquí la marcas tú y el compilador no la comprueba.
La pila es la máquina de estados
Lo que async compila, la corrutina ya lo tiene. Esperar es ceder; continuar es reanudar con el valor recibido.
Una carrera, dos ramas
El callback puede llegar antes o después del yield. Sin expropiación, una bandera basta para cubrir ambos casos.
El resume corre en el bucle
Un error que escape de la tarea sube por el bucle de eventos. Envuelve siempre el cuerpo con xpcall y un traceback.
El color es dinámico
Quien espera necesita corrutina, y nadie lo comprueba antes de ejecutar. coroutine.isyieldable es tu única red.
Ese último punto tiene una versión dura que merece lección propia: hay lugares desde los que no se puede ceder aunque estés dentro de una corrutina, porque entre el resume y tu código hay un marco de C que el intérprete no sabe suspender. El comparador de table.sort y la función de reemplazo de string.gsub son los casos más comunes. Escribir esperar dentro de uno de ellos compila, se lee bien y falla en ejecución. Ese es el tema de la lección 16.5.
Lo que ocurre cuando escribes await en otro lenguaje es que el compilador toma el resto de la función —todo lo que queda por hacer después de ese punto— y lo convierte en un objeto que puede guardarse y volver a activarse cuando el resultado llegue. Ese objeto tiene un nombre viejo en la teoría de lenguajes: es la continuación del programa en ese punto, restringida a un solo uso. La transformación es mecánica, se llama conversión a estilo de paso de continuaciones, y es tan tediosa que nadie la escribe a mano; por eso los lenguajes que la necesitan la esconden detrás de una palabra clave y un compilador que la aplica por ti. Una corrutina suspendida es esa continuación, ya reificada por el intérprete, disponible como un valor de primera clase, sin que nadie haya tenido que reescribir tu código. Por eso Lua no necesita la palabra clave: no le falta una característica, es que la característica está construida en un nivel más abajo y es más general, porque una continuación de un solo uso sirve igual para generadores, para tuberías, para planificadores cooperativos y para un modelo de excepciones reanudables que ningún await puede expresar. Lo que Lua no te da es la comprobación estática, que es precisamente la parte que el compilador de los otros lenguajes hace bien. El intercambio es honesto y conviene tenerlo presente al elegir: te dan un mecanismo más potente y menos vigilado.
- Implementa
esperartal cual y pruébala con una operación falsa que responda de forma síncrona y con otra que responda desde un temporizador. Comprueba que las dos rutas delifse ejecutan. - Quita la bandera
cedioy provoca el caso síncrono. Describe con precisión qué error aparece y por qué el orden de las dos ramas era imprescindible. - Añade la convención de error y comprueba que un fallo dentro de una operación esperada puede capturarse con un
pcallcolocado alrededor de las tres esperas seguidas. - Haz que una tarea lance un error sin capturar y observa dónde acaba el traceback. Envuelve después el cuerpo con
xpcallydebug.tracebacky compara ambos mensajes. - Escribe una función
esperar_todasque reciba varias operaciones, las inicie todas a la vez y ceda una sola vez hasta que la última responda. Cuenta cuántas cesiones hace tu versión y justifica que sea una sola.