Corrutinas de Lua: convertir callbacks anidados en código lineal
Encadenar tres operaciones de libuv produce tres niveles de anidamiento y un manejo de errores repetido en cada uno. Las corrutinas de Lua deshacen esa pirámide: yield suspende, resume reanuda, y con veinte líneas construyes el async y el await que usan plenary y nio. El mecanismo por dentro, sus límites reales, y vim.wait como puente síncrono con sus peligros.
Una operación asíncrona con callback se lee bien. Tres encadenadas se leen mal. Cinco, con su comprobación de error en cada nivel, producen una pirámide inclinada que nadie quiere mantener y en la que los errores se manejan cinco veces o ninguna. Lua trae desde su origen la herramienta exacta para deshacer esa pirámide, y no es una biblioteca ni un truco: son las corrutinas, funciones que pueden suspenderse en mitad de su ejecución y reanudarse después justo donde lo dejaron. Con ellas, libuv deja de imponerte una forma de escribir y vuelve a ser un detalle de implementación.
- Reconocer la pirámide de callbacks y el coste real que impone al manejo de errores.
- Manejar
coroutine.create,resume,yield,status,wrapyrunningdel Lua 5.1 de Neovim. - Construir un
asyncy unawaitpropios sobre cualquier API de callbacks. - Usar
vim.waitcomo puente síncrono conociendo sus retornos y sus peligros. - Situar el patrón en los plugins reales y conocer los límites de la versión casera.
La pirámide y su coste
Leer un fichero con vim.uv requiere abrir, consultar el tamaño, leer y cerrar. Cuatro operaciones, cada una con su callback, cada callback con la convención de libuv: el primer argumento es el error o nil, y detrás viene el resultado.
local uv = vim.uvuv.fs_open("/tmp/datos.txt", "r", 438, function(err, fd) if err then return end uv.fs_fstat(fd, function(err2, stat) if err2 then return uv.fs_close(fd, function() end) end uv.fs_read(fd, stat.size, 0, function(err3, datos) uv.fs_close(fd, function() end) if err3 then return end vim.schedule(function() vim.notify(#datos .. " bytes") end) end) end)end)El anidamiento es feo, pero lo grave es otra cosa: el cierre del descriptor aparece dos veces y en un tercer camino de error se olvidaría sin que nada avise. La secuencia lógica es lineal —abre, mide, lee, cierra— y el código no lo parece. Esa distancia entre la intención y su forma es precisamente lo que las corrutinas eliminan. Fíjate de paso en que estas cuatro llamadas son requests, no handles —la distinción de la 12.2—: nacen, entregan su resultado y desaparecen; el único recurso que gestionar aquí es el descriptor de fichero.
Suspender y reanudar
Una corrutina es una función con estado de ejecución propio. coroutine.create la envuelve sin arrancarla, coroutine.resume la pone en marcha o la reanuda, y coroutine.yield la suspende devolviendo el control a quien la reanudó. La clave es que al suspenderse conserva todo: variables locales, posición en el código, pila de llamadas.
local co = coroutine.create(function(a) print("empiezo con", a) local b = coroutine.yield(a + 1) -- entrega a+1 y se suspende aquí print("me reanudaron con", b) return a + bend)print(coroutine.resume(co, 10)) -- empiezo con 10 → true 11print(coroutine.resume(co, 32)) -- me reanudaron con 32 → true 42print(coroutine.status(co)) -- deadObserva la doble simetría, que es el corazón del truco. Hacia fuera: lo que pasas a yield lo devuelve resume a quien llamó, detrás de un true que indica que no hubo error. Hacia dentro: lo que pasas a resume lo devuelve yield dentro de la corrutina. Una corrutina puede, por tanto, suspenderse para pedir un dato y recibirlo como si fuera el resultado de una llamada normal. Si quien la reanuda es el callback de libuv, acabas de convertir una operación asíncrona en una línea que parece síncrona.
coroutine.status responde en qué punto de su vida está: suspended tras un yield o antes del primer resume, running si es la que está corriendo, normal si reanudó a otra y espera, y dead cuando terminó o reventó. Una corrutina muerta no se puede reanudar: resume sobre ella devuelve false y el mensaje cannot resume dead coroutine.
Hay una segunda forma de crear corrutinas que verás constantemente en código ajeno: coroutine.wrap devuelve directamente una función que hace el resume por ti, con dos diferencias frente a create: no devuelve el booleano de estado, y si dentro hubo un error, lo relanza en el llamante en lugar de devolvértelo como valor —la distinción error-como-valor frente a error-que-se-propaga que ya trabajaste con pcall en la 7.3—.
local siguiente = coroutine.wrap(function() for i = 1, 3 do coroutine.yield(i) endend)print(siguiente(), siguiente(), siguiente()) --> 1 2 3async y await en veinte líneas
Con esa simetría se construye el patrón completo. esperar recibe una función asíncrona y sus argumentos, le añade como último argumento un callback que reanuda la corrutina actual, y se suspende. async simplemente arranca una función dentro de una corrutina. Es el mismo esqueleto que usan plenary.async y nio por dentro; escribirlo una vez a mano es la mejor forma de que dejen de parecer magia.
local function esperar(fn, ...) local co = assert(coroutine.running(), "esperar solo puede llamarse dentro de async") local args = { ... } args[#args + 1] = function(...) local ok, err = coroutine.resume(co, ...) if not ok then vim.schedule(function() vim.notify(tostring(err), vim.log.levels.ERROR) end) end end fn(unpack(args)) return coroutine.yield() -- lo que reciba el callback sale por aquíendlocal function async(cuerpo) return function(...) local co = coroutine.create(cuerpo) local ok, err = coroutine.resume(co, ...) if not ok then vim.schedule(function() vim.notify(tostring(err), vim.log.levels.ERROR) end) end endendDos detalles de esas veinte líneas pagan el peaje de leerse despacio. El assert del principio existe porque coroutine.running() devuelve nil cuando se llama desde el hilo principal —fuera de toda corrutina—, y sin la comprobación el fallo sería un críptico attempt to yield from outside a coroutine tres líneas después. Y el ok, err dentro del callback tapa el agujero clásico de las implementaciones ingenuas: a partir del segundo resume, quien reanuda ya no es tu async sino el callback de libuv, y si la corrutina revienta ahí, el error le llega a ese resume; ignorarlo es tragarse la excepción sin dejar rastro. Las versiones de tutorial suelen comprobar solo el primer resume, y por eso «funcionan» hasta el primer error real.
Con esas dos funciones, la pirámide del principio se aplana hasta parecer código de toda la vida.
local leer_fichero = async(function(ruta) local err, fd = esperar(uv.fs_open, ruta, "r", 438) if err then error(err) end local _, stat = esperar(uv.fs_fstat, fd) local _, datos = esperar(uv.fs_read, fd, stat.size, 0) esperar(uv.fs_close, fd) vim.schedule(function() vim.notify(#datos .. " bytes") end)end)leer_fichero("/tmp/datos.txt")Cuatro operaciones asíncronas, cero anidamiento, un solo camino de error y el cierre del descriptor escrito una única vez. Y nada de esto bloquea: entre cada línea el bucle de libuv sigue girando con total libertad.
Dentro de la corrutina sigues en fast event
Reanudar desde un callback de libuv significa que el código posterior al yield se ejecuta en fast event context: vim.in_fast_event() devuelve true en la línea siguiente a un esperar. La regla de oro no desaparece por usar corrutinas: sigues necesitando vim.schedule para tocar la API. Las bibliotecas maduras resuelven esto ofreciendo utilidades que reanudan ya en contexto seguro, y es una de las mejores razones para usarlas en lugar de reinventarlas.
vim.wait: el puente síncrono y sus peligros
A veces no quieres reescribir nada en estilo asíncrono: quieres que esta función, aquí y ahora, no retorne hasta que algo termine. Para ese caso el núcleo ofrece vim.wait(tiempo, condicion, intervalo, solo_fast): espera hasta tiempo milisegundos a que la función condicion devuelva verdadero, evaluándola al entrar y luego aproximadamente cada intervalo milisegundos —200 por defecto—. Devuelve true si la condición se cumplió; false, -1 si venció el plazo; false, -2 si el usuario interrumpió con Ctrl-C.
local terminado = falsevim.system({ "sleep", "1" }, {}, function() terminado = true end)local ok = vim.wait(3000, function() return terminado end, 50)print(ok) -- true, en torno a un segundo despuésLo que hace especial —y peligrosa— a vim.wait es la letra pequeña: mientras espera, procesa otros eventos. No congela el editor como lo haría un bucle cerrado; los callbacks de libuv corren, la cola de vim.schedule se vacía, los autocmds disparan. Sin eso sería inútil —la condición que esperas jamás se cumpliría, porque quien la pone a verdadero necesita el bucle—, pero abre la puerta a la reentrada: en mitad de tu función, mientras vim.wait gira, puede ejecutarse otro código tuyo, disparado por un evento cualquiera, que toque el mismo estado que tenías a medias. Es el único punto de todo el nivel donde la garantía de «nada se entrelaza con mi código» se suspende voluntariamente. El cuarto parámetro, solo_fast, reduce la ventana procesando únicamente eventos rápidos, a cambio de que nada de vim.schedule avance mientras esperas.
Los otros dos peligros son más simples. vim.wait no puede llamarse desde un fast event —error inmediato: el que espera necesita ser el hilo principal—. Y una condición que depende de algo que nunca llegará convierte tu tiempo en una congelación efectiva de ese tamaño: usa siempre plazos que estarías dispuesto a explicarle al usuario. vim.wait es excelente en tests y scripts, aceptable como último recurso en plugins, y una señal de diseño dudoso si aparece en el camino caliente de una pulsación.
Lo que usan los plugins reales
Casi ninguna base de código escribe hoy su propio async desde cero. plenary.async popularizó el patrón y sigue siendo el más extendido; nio es la reencarnación moderna, con tareas cancelables, señales de sincronización y envoltorios seguros de la API de Neovim. El propio núcleo empuja en la misma dirección: vim.system admite callback o espera bloqueante, y partes de vim.lsp usan corrutinas por dentro. Distintas envolturas, un solo mecanismo por debajo: coroutine.yield y coroutine.resume sobre el bucle de libuv.
plenary.async
El clásico. Aporta async.void, async.wrap y utilidades para lanzar tareas en paralelo. Muy extendido como dependencia, con una gestión de errores que acusa la edad.
nio
Tareas de primera clase con cancelación, eventos, colas y semáforos, más envoltorios que reanudan en contexto seguro. La opción recomendable en código nuevo.
Lo que una biblioteca madura te da y tus veinte líneas no cubren se resume en cuatro cosas: propagación correcta de errores a través del yield, cancelación de una tarea a medio vuelo, composición de varias tareas en paralelo con espera conjunta, y reanudación garantizada en contexto seguro. Ninguna es difícil por separado; todas juntas son un proyecto. Reimplementarlas es un ejercicio excelente y una decisión de mantenimiento discutible.
Los límites del mecanismo
Una corrutina no puede suspenderse a través de una frontera de C: si tu yield queda dentro de un callback invocado desde código nativo —un nvim_buf_call, un iterador de la API—, obtienes attempt to yield across C-call boundary. Tampoco existe forma de reanudar una corrutina muerta: resume sobre ella devuelve false con su mensaje. Y recuerda el assert sobre coroutine.running(): en el Lua 5.1 de Neovim devuelve nil en el hilo principal, así que tu esperar debe negarse en voz alta a trabajar fuera de una corrutina.
La corrutina no añade concurrencia: le devuelve la forma al código que ya tenías
Merece la pena precisar qué hace y qué no hace este patrón, porque el vocabulario prestado de otros lenguajes confunde. Una corrutina de Lua no es un hilo: no hay paralelismo, no hay preempción, no hay dos cosas ejecutándose a la vez. Sigues en el hilo único de la 12.1 y sigues sujeto a que nada bloquee el bucle. Lo que la corrutina aporta es puramente sintáctico en su efecto y profundo en su naturaleza: separa el momento en que el código continúa del lugar donde está escrito. Con callbacks, la continuación de tu programa tiene que escribirse en otro sitio, dentro de otra función, con otro alcance léxico —y esa fragmentación es la que produce la pirámide, la duplicación del manejo de errores y la pérdida de las variables locales—. Con una corrutina, la continuación es la línea siguiente, aunque se ejecute cien milisegundos después. Por eso el patrón encaja tan bien aquí y no es una importación forzada: un bucle de eventos ya define una noción natural de suspender y reanudar, y una corrutina es exactamente la primitiva del lenguaje que la expresa. Repara además en que esta correspondencia no es casualidad histórica sino estructura: async y await en JavaScript, Python o Rust son, por debajo, máquinas de estados que hacen lo mismo que hace coroutine.yield, solo que generadas por el compilador en lugar de escritas a mano. Lua tenía la primitiva desde los años noventa, antes de que existiera el problema que hoy resuelve. Cuando lees local _, datos = esperar(uv.fs_read, fd, stat.size, 0) y entiendes que esa línea suspende un flujo, deja correr el bucle entero y lo retoma intacto, has cerrado el círculo del nivel: el bucle de eventos, el handle, la frontera del contexto y la corrutina no son cuatro temas, sino cuatro vistas del mismo objeto.
Drills
Imprime a y luego suspended: la corrutina vive, congelada en el yield, con su pila intacta. Reanúdala con :lua coroutine.resume(co) print(coroutine.status(co)) — sale b y el estado pasa a dead.
false y cannot resume dead coroutine: el primer resume agotó el cuerpo y no hay marcha atrás. Fíjate en el estilo pcall del retorno — el error llega como valor, no como explosión. Con coroutine.wrap habría sido al revés: explosión en tu cara.
El primer resume imprime true 42 — el a * 2 que entregó el yield hacia fuera. El segundo inyecta 99, que aparece dentro como valor de b. Ese túnel bidireccional es todo lo que una biblioteca async necesita del lenguaje.
Imprime true al medio segundo, no a los dos: vim.wait retornó en cuanto la condición se cumplió, y el defer_fn pudo cumplirla porque wait deja girar el bucle mientras espera. Cambia el plazo a 200 y verás el otro retorno: false -1.
Aplana la pirámide
- Escribe con callbacks anidados la secuencia abrir, medir, leer y cerrar un fichero, y cuenta cuántas veces aparece el cierre en los caminos de error.
- Experimenta con
coroutine.create,resumeyyieldpasando valores en ambas direcciones, y consultacoroutine.statusen cada fase hasta que los cuatro estados te resulten familiares. - Implementa
esperaryasynctal como aparecen aquí —con elasserty con la comprobación deoken el callback— y reescribe la lectura del fichero en forma lineal. - Provoca los dos fallos de guardia: llama a
esperardesde fuera deasyncy comprueba el mensaje delassert; lanza unerrortras el segundoesperary verifica que la notificación llega en vez de perderse. - Comprueba con
vim.in_fast_event()en qué contexto queda el código posterior a unesperary justifica por qué elvim.notifyfinal del ejemplo viaja dentro de unvim.schedule. - Usa
vim.waitpara esperar a unvim.systemlento con plazo de sobra, y luego con un plazo demasiado corto; captura ambos retornos y distinguefalse, -1de la interrupción conCtrl-C. - Sustituye tu implementación por
niooplenary.asyncy describe qué te da la biblioteca que tus veinte líneas no cubren.