wandres.dev
CORRUTINAS II · construir sobre ellas

Los límites: ceder a través de una frontera de C

Por qué el Lua clásico no puede suspender un marco de C, dónde aparece ese muro en el día a día, y qué cambió con las continuaciones de 5.2 y el cierre explícito de 5.4.

⏱ 18 min

Todo lo construido en este nivel descansa en una suposición que casi siempre se cumple y que, cuando falla, lo hace con un mensaje seco: intento de ceder a través de una frontera de llamada de C. El generador, la tubería, el await casero y el planificador dan por hecho que una corrutina puede suspenderse en cualquier punto de su ejecución. No es cierto. Puede suspenderse en cualquier punto que el intérprete sepa representar por completo, y hay tramos —los que pasan por la pila de C— que no le pertenecen. Entender exactamente dónde está ese muro, por qué existe y cuánto se ha movido desde 5.1 es lo que separa usar corrutinas de saber en qué te apoyas.

🎯 Al terminar esta lección sabrás
  • Explicar en términos de pilas por qué una cesión no puede atravesar un marco de C.
  • Enumerar los lugares concretos donde el muro aparece y reconocer el mensaje de error que produce.
  • Describir el mecanismo de continuaciones de Lua 5.2 y qué hizo yieldables a pcall y a los metamétodos.
  • Decidir en un caso real si conviene rodear el límite, reestructurar el código o cambiar de versión.

Qué es una frontera de C

Ceder es, en la implementación, un salto largo: el intérprete abandona la ejecución en curso y devuelve el control al punto donde se hizo resume, guardando la pila de valores y los marcos de llamada de la corrutina para reconstruirlos después. Eso funciona porque el intérprete conoce y posee esa estructura entera: cada marco de una función Lua es un registro suyo, con su función, su lista de registros y su contador de programa, todo dentro de memoria que él gestiona.

Un marco de una función escrita en C no es nada de eso. Es un marco en la pila nativa del proceso, con sus variables locales en registros del procesador y en memoria de pila que el intérprete no puede inspeccionar, copiar ni reconstruir más tarde. Si una función C llamó a código Lua y ese código intenta ceder, el salto largo tendría que descartar el marco de C sin ninguna posibilidad de devolverlo a la vida al reanudar. El intérprete se niega y lanza el error, que es la única respuesta honesta: la alternativa sería corromper memoria en silencio.

flowchart TD
A[Hilo principal hace resume] --> B[Marco Lua de la tarea]
B --> C[Marco de una funcion en C]
C --> D[Marco Lua invocado por esa funcion]
D -->|yield| E[Intento de salto hasta el resume]
E -->|el marco de C no se puede guardar| F[Error de frontera de llamada de C]

El reproductor más corto del problema cabe en cinco líneas y conviene teclearlo una vez para tener el mensaje grabado:

local co = coroutine.create(function()
  table.sort({ 3, 1, 2 }, function(a, b)
    coroutine.yield()      -- la comparacion la invoca C
    return a < b
  end)
end)
print(coroutine.resume(co))
--> false  attempt to yield across a C-call boundary

El error no lo produce yield por sí mismo sino la comprobación que el intérprete hace antes de saltar: mira si entre el punto actual y el resume hay algún marco que no sea suyo, y si lo hay se detiene. Esa comprobación es barata y ocurre siempre, de modo que no hay forma de desactivarla ni versión compilada con opciones distintas donde el mismo código funcione.

Conviene separar esto de dos cosas parecidas. No es un problema de rendimiento ni una restricción arbitraria de la API, y no tiene nada que ver con hilos del sistema. Y no es lo mismo que ceder desde el hilo principal, que falla por otra razón —no hay nadie a quien volver— y con otro mensaje.

Dónde te lo encuentras

En el Lua clásico, es decir 5.1 y todo lo que hereda su semántica, la lista de sitios prohibidos es larga y desagradablemente cotidiana: dentro de pcall y xpcall, dentro de cualquier metamétodo, en el comparador de table.sort, en la función de reemplazo de string.gsub, en el iterador de un for genérico y en cualquier callback que invoque una biblioteca escrita en C.

Ese último caso es el que más veces aparece en un editor. En Neovim conviven dos muros distintos y confundirlos cuesta horas. El primero es este: si una función de C llama a tu código Lua, no puedes ceder desde ahí. El segundo es el contexto de evento rápido, donde el bucle de libuv ejecuta tu callback fuera del hilo principal del editor y casi toda la API queda prohibida con un mensaje que habla de contexto rápido, no de fronteras de C. La cura de ambos es la misma en espíritu: aplazar. vim.schedule deposita tu función para que se ejecute más tarde, en un contexto donde sí tienes todos los permisos.

-- Prohibido: la comparacion la invoca table.sort, que es C
table.sort(t, function(a, b) return esperar(pedir_peso(a)) < esperar(pedir_peso(b)) end)

-- Correcto: se espera antes, fuera del marco de C
local peso = {}
for _, v in ipairs(t) do peso[v] = esperar(pedir_peso(v)) end
table.sort(t, function(a, b) return peso[a] < peso[b] end)

Ese par de bloques resume la estrategia general y es más útil que cualquier lista de excepciones: saca la cesión del marco ajeno. Precalcula lo que la función C va a necesitar, entrégaselo ya resuelto y deja que su callback sea puramente síncrono. Cuando eso no se puede, la otra salida es invertir el trato: en vez de esperar dentro del callback, que el callback registre lo que hace falta y termine, y que la espera ocurra fuera, en el siguiente giro del planificador.

El mismo giro sirve para string.gsub, donde la tentación de pedir datos desde la función de reemplazo es casi irresistible:

-- Prohibido: el reemplazo lo invoca gsub, que es C
local salida = texto:gsub("@(%w+)", function(nombre)
  return esperar(function(k) buscar_usuario(nombre, k) end)
end)

-- Correcto: dos pasadas, con la espera entre ambas
local nombres = {}
for nombre in texto:gmatch("@(%w+)") do nombres[nombre] = true end
for nombre in pairs(nombres) do
  nombres[nombre] = esperar(function(k) buscar_usuario(nombre, k) end)
end
local salida = texto:gsub("@(%w+)", nombres)

La segunda versión además es mejor por razones que no tienen nada que ver con el muro: agrupa las consultas, elimina las repetidas y deja el reemplazo como una tabla, que gsub acepta directamente y resuelve sin llamar a ninguna función Lua. Es un patrón que se repite tanto que casi puede enunciarse como regla: cuando la frontera de C te obliga a reordenar, el código resultante suele ser el que deberías haber escrito de todas formas.

La versión de esa inversión aplicada al editor tiene una forma muy reconocible: el callback rápido no espera, solo aplaza.

local temporizador = vim.uv.new_timer()
temporizador:start(200, 0, function()
  -- Contexto rapido: aqui no se cede ni se llama a casi nada de la API
  vim.schedule(function()
    -- Contexto normal: aqui si vive la tarea que puede esperar
    lanzar(procesar_resultado)
  end)
end)

Antes de dar nada por sabido, pregunta al propio intérprete. coroutine.isyieldable devuelve si en este punto exacto la cesión es posible, y una aserción con ese valor al principio de una función que espera convierte un fallo lejano y confuso en un error inmediato con el nombre de la función culpable.

La pila de C no es suya

El intérprete solo puede suspender lo que representa por completo. Un marco nativo tiene su estado en la pila del proceso.

🧩

Continuaciones desde 5.2

Una función C es atravesable si su autor escribió la segunda mitad como función de continuación. Ni una más.

🚧

Sort y gsub siguen cerrados

Guardan estado nativo durante la llamada a tu código. Precalcula fuera y entrégales datos ya resueltos.

🧯

Cerrar es de 5.4

coroutine.close libera lo que una corrutina abandonada retiene, en el instante que tú decides y no cuando pase el recolector.

Lo que cambió en las versiones modernas

Lua 5.2 introdujo el mecanismo que mueve el muro: las funciones de continuación. Una función C que quiera ser atravesable por una cesión no llama a Lua con lua_call sino con lua_callk, lua_pcallk o lua_yieldk, pasando además un puntero a una función de continuación y un contexto entero. Si ocurre una cesión, el marco de C se descarta de verdad, pero al reanudar el intérprete no lo reconstruye: invoca la continuación con el contexto guardado, y esa continuación es la encargada de terminar el trabajo que quedaba. En otras palabras, no se salvó el marco, se le pidió al autor de la función C que escribiera a mano la segunda mitad.

Con esa herramienta se reescribió parte de la biblioteca estándar, y por eso desde 5.2 se puede ceder a través de pcall, de xpcall y de los metamétodos, que eran con diferencia los casos más dolorosos. En el mismo cambio, el intérprete dejó de crecer la pila de C en cada llamada de Lua a Lua, lo que eliminó una frontera implícita que existía dentro del propio bucle de ejecución. La regla que queda es clara y hay que aplicarla función por función: una función C es atravesable si y solo si su autor escribió la continuación. Las que guardan estado nativo complicado durante la llamada, como la ordenación de table.sort o el búfer de string.gsub, siguen sin serlo, y ninguna versión promete lo contrario.

Lua 5.4 añade la otra mitad del problema, que no es ceder sino terminar. coroutine.close finaliza una corrutina suspendida ejecutando sus variables de cierre pendientes y devolviendo el error si alguno de esos cierres falla, y coroutine.wrap cierra la corrutina automáticamente al propagar un error. Eso convierte el abandono de un generador o de una tarea en una operación determinista con liberación de recursos, que es justamente lo que faltaba para que las corrutinas fueran utilizables en código que sostiene ficheros, sockets o bloqueos.

Fuera del árbol oficial, LuaJIT merece un párrafo aparte porque su semántica es la de 5.1 con extensiones tomadas de 5.2, entre ellas la cesión a través de pcall y de los metamétodos. La diferencia práctica está en otro sitio: el compilador de trazas no compila las operaciones de corrutina, de modo que un bucle caliente plagado de cesiones se ejecuta interpretado. Si tu código depende del compilador para rendir, mantén las corrutinas fuera de los bucles críticos y comprueba tú mismo con las herramientas de traza de tu versión, porque estas listas cambian entre revisiones.

Decidir cuando aparece el muro

Encontrado el error, hay tres salidas y elegir mal cuesta semanas. La primera es sacar la espera del marco ajeno, precalculando lo que el callback necesita; es la respuesta correcta en la enorme mayoría de los casos y además suele simplificar el código, porque obliga a separar la obtención de datos de su uso. La segunda es partir el trabajo: que el callback registre una petición y termine, y que el resultado se procese en el siguiente giro del planificador, exactamente como hace vim.schedule. La tercera, mucho más rara, es cambiar de mecanismo: si lo que necesitas de verdad es suspender una pila que atraviesa C, lo que buscas no son corrutinas de Lua sino un hilo del sistema, con todo lo que eso implica.

flowchart TD
A[Error de frontera de llamada de C] --> B{Puedes precalcular lo que pide el callback}
B -->|si| C[Espera fuera y pasa datos ya resueltos]
B -->|no| D{El trabajo se puede partir en dos turnos}
D -->|si| E[Registra y aplaza al siguiente giro]
D -->|no| F[Reconsidera el diseno o usa un hilo real]

La pregunta que ordena las tres opciones no es técnica sino de propiedad: de quién es la pila donde quieres detenerte. Si es tuya, todo lo de este nivel funciona. Si es de una biblioteca en C, no hay truco que valga y la única maniobra legítima es reorganizar el programa para que el punto de espera caiga de tu lado.

El límite no es un defecto de la implementación: es la definición del objeto

Da la impresión de que ceder a través de C es una carencia que alguien podría corregir con suficiente ingeniería, y esa impresión es equivocada de una forma instructiva. Una corrutina de Lua no es un hilo con una pila propia del sistema: es una estructura de datos del intérprete que representa una ejecución suspendida. Lo que puede suspenderse es exactamente lo que esa estructura sabe representar, ni un byte más. La pila de C no le pertenece al intérprete, le pertenece al proceso que lo hospeda, y suspenderla exigiría copiar y restaurar memoria nativa con los punteros que contiene, que es la definición de una corrutina de pila completa y cuesta órdenes de magnitud más en memoria y en portabilidad. Lua eligió lo contrario, y esa elección es la misma que explica su tamaño, su velocidad de creación de corrutinas y la facilidad con que se embebe en cualquier programa en C. El muro no es el precio de una decisión mala: es la sombra que proyecta una decisión buena. Reconocerlo cambia lo que haces cuando lo encuentras. Si crees que es un defecto, buscas un truco que lo esquive y acabas escribiendo código frágil. Si entiendes que es la frontera de lo que el intérprete posee, reorganizas el programa para que la parte que espera viva de tu lado de la frontera, que es siempre posible y casi siempre además produce un diseño más claro que el que intentabas.

⚔️ Encuentra el muro y rodéalo
  1. Provoca el error a propósito desde el comparador de table.sort y desde la función de reemplazo de string.gsub. Copia el mensaje exacto de tu versión y guárdalo para reconocerlo después.
  2. Repite el intento desde dentro de un pcall y desde un metamétodo __index en Lua 5.1 y en 5.4, si tienes ambos a mano. Anota qué casos siguen fallando en la versión moderna.
  3. Reescribe el ejemplo de la ordenación con pesos precalculados y mide cuántas esperas hace cada versión. Explica por qué la correcta hace menos, además de funcionar.
  4. Añade a todas tus funciones que esperan una aserción con coroutine.isyieldable y comprueba que un fallo se detecta en la función culpable y no tres marcos más arriba.
  5. Escribe una tarea que abra un fichero, ceda y sea abandonada. Recupérala con coroutine.close y verifica con una variable de cierre que el descriptor se libera en ese instante y no cuando pase el recolector.