Qué es una corrutina: una pila propia que cede el control
Una corrutina no es un hilo del sistema operativo ni una función que se llama: es una línea de ejecución con su propia pila que se suspende donde el programador lo escribe y devuelve el control exactamente a quien la reanudó. Asimétrica y no preemptiva son los dos adjetivos de los que se deduce todo lo demás.
La palabra corrutina arrastra treinta años de malentendidos importados de otros lenguajes, y casi todos se resumen en el mismo error: creer que hay algo corriendo a la vez. No lo hay. Una corrutina de Lua es un valor de tipo thread que representa una línea de ejecución con pila propia, capaz de detenerse en mitad de su trabajo y de reanudarse más tarde justo donde lo dejó, con todas sus variables locales intactas y sin haber consumido ni un ciclo de reloj mientras estaba suspendida. Dos adjetivos la definen por completo —asimétrica y no preemptiva— y de ellos se derivan tanto su enorme utilidad como sus limitaciones estructurales. Esta lección se ocupa solo de entender qué cosa es; las tres siguientes se ocupan de manejarla.
- Distinguir la pila propia de una corrutina de la pila compartida de una llamada ordinaria.
- Explicar por qué Lua tiene corrutinas apiladas y qué permite eso que un generador no permite.
- Enunciar qué significa que el modelo sea asimétrico y qué topología impone entre corrutinas.
- Deducir del carácter no preemptivo las garantías que ofrece y el fallo característico que provoca.
Una función que puede detenerse por dentro
Cuando llamas a una función corriente, Lua apila un marco de activación sobre la pila que ya estabas usando; cuando esa función retorna, el marco se desapila y no hay vuelta atrás. El contrato es total: una llamada empieza, hace su trabajo y termina. La corrutina rompe ese contrato introduciendo un tercer estado, la suspensión, que no es ni haber terminado ni estar corriendo.
local co = coroutine.create(function(a, b)
print("arranco con", a, b)
local recibido = coroutine.yield(a + b) -- me detengo aqui
print("me reanudaron con", recibido)
return "final"
end)
print(coroutine.resume(co, 1, 2)) --> true 3
print(coroutine.resume(co, "hola")) --> true final
Entre las dos llamadas a resume pueden pasar horas de reloj y millones de instrucciones de otro código. La variable a, la variable b, la posición exacta dentro del cuerpo y todo el estado intermedio siguen existiendo mientras tanto, porque no están en la pila del programa principal sino en una pila que pertenece a la corrutina y que nadie más toca.
Esa propiedad tiene un nombre técnico: las corrutinas de Lua son apiladas, o stackful. La consecuencia práctica es que yield no tiene que escribirse en el cuerpo de la función que creaste, sino que puede ejecutarse a cualquier profundidad de llamadas por debajo de ella.
local function hoja() coroutine.yield("cedo desde tres niveles abajo") end
local function media() hoja() end
local function raiz() media() end
local co = coroutine.create(raiz)
print(coroutine.resume(co)) --> true cedo desde tres niveles abajo
Detente en lo que acaba de ocurrir, porque es exactamente lo que separa a las corrutinas de los generadores de la mayoría de los lenguajes. Al suspenderse, la corrutina congeló tres marcos de activación a la vez y devolvió el control al programa principal saltando por encima de ellos; al reanudarla, los tres marcos vuelven a estar donde estaban. En un modelo sin pila propia, la suspensión solo puede ocurrir en el cuerpo sintáctico de la función marcada, y cualquier función intermedia debe declararse también como suspendible y propagar la cesión a mano. Lua no necesita nada de eso: hoja es una función normalísima que ni sabe ni tiene por qué saber que está corriendo dentro de una corrutina.
La forma más rápida de apreciar lo que eso vale es escribir el mismo recorrido de las dos maneras. Con corrutina, un recorrido en profundidad de un árbol que entrega los nodos de uno en uno es el algoritmo de siempre con una línea añadida:
local function recorrer(nodo)
if not nodo then return end
recorrer(nodo.izq)
coroutine.yield(nodo.valor) -- la recursion entera queda congelada aqui
recorrer(nodo.der)
end
Sin corrutina, ese mismo iterador exige sacar la recursión de la pila del lenguaje y reconstruirla a mano en una tabla, llevar la cuenta de por qué rama iba cada nivel y reanudarlo todo desde un estado explícito en cada llamada. El algoritmo deja de parecerse al algoritmo. Lo que la pila propia compra, en una frase, es el derecho a seguir escribiendo el cómputo como se piensa aunque su ejecución tenga que trocearse.
type(co) devuelve la cadena "thread", y ese nombre es un accidente histórico que confunde a todo el mundo. No hay ningún hilo del sistema operativo detrás: en la implementación en C, una corrutina es un lua_State con su propia pila de datos y de llamadas, que comparte con el resto del programa el estado global, la tabla de cadenas internadas y el recolector de basura. Lo único privado es la pila.
Asimétrica: solo se cede a quien te reanudó
El modelo tiene dos operaciones y no son intercambiables. resume es la operación del que manda: toma una corrutina suspendida y la pone a correr. yield es la operación del que obedece: suspende la corrutina actual y devuelve el control al que la reanudó, sin poder elegir a nadie más. Esa falta de simetría es deliberada y es lo que da nombre al modelo.
Un modelo simétrico tendría una sola operación, algo como transferir el control a esta otra corrutina, y produciría un grafo arbitrario de transferencias donde cualquiera puede saltar a cualquiera. Lua eligió lo contrario, y el resultado es que las relaciones entre corrutinas activas forman siempre una pila: la principal reanuda a A, A reanuda a B, y B solo puede volver a A, que solo puede volver a la principal.
flowchart LR M[Hilo principal] -->|resume| A[Corrutina A] A -->|resume| B[Corrutina B] B -->|yield| A A -->|yield| M B -.->|prohibido saltar directo| M
Que la relación sea de reanudador y reanudada, y no de iguales, tiene además una consecuencia que se nota al depurar: en cualquier instante existe una cadena de reanudaciones bien definida que va desde el hilo principal hasta la corrutina que está corriendo, y esa cadena es la respuesta a la pregunta de quién está esperando a quién. En un modelo simétrico esa pregunta puede no tener respuesta.
Merece la pena ver por qué esto es una ventaja y no una amputación. La asimetría hace que una corrutina sea componible como una función: quien la reanuda no necesita saber qué hace por dentro, ni a quién reanuda ella a su vez, porque tiene la certeza de que el control acabará volviendo a su propia llamada a resume y a ningún otro sitio. Con transferencias simétricas esa certeza desaparece, y razonar sobre el flujo exige conocer el grafo completo del programa. Es la misma diferencia que hay entre una llamada a función y un salto incondicional, y la historia de la programación ya emitió su veredicto sobre esa disyuntiva.
Una pila por corrutina
Cada corrutina posee su pila de datos y su pila de llamadas. Suspenderse no copia nada ni deshace marcos: simplemente se deja de ejecutar sobre esa pila y se continúa sobre otra.
Suspender no es retornar
Retornar destruye el marco; ceder lo conserva. Por eso una corrutina puede tener veinte suspensiones y una sola terminación, y por eso yield puede devolver valores hacia dentro, cosa que return jamás hace.
Profundidad libre
La cesión atraviesa cualquier número de marcos de Lua. Las funciones intermedias no se anotan, no se marcan y no se enteran de nada.
El destino no se elige
yield no acepta un destinatario. Va siempre al resume que está en la cima de la cadena, y esa restricción es lo que hace que el flujo sea legible.
No preemptiva: el punto de cesión se escribe a mano
La segunda propiedad es que nada interrumpe a una corrutina. No hay planificador, no hay reparto de tiempo, no hay ninguna señal que la desaloje. Entre dos cesiones consecutivas, la corrutina es lo único que se ejecuta en todo el estado de Lua, y ninguna otra línea de código puede observar ni modificar nada mientras tanto.
De ahí sale la garantía más valiosa del modelo, que la lección 15.5 desarrollará con detalle: cualquier secuencia de operaciones que no contenga un yield es atómica respecto a las demás corrutinas. Si tu código lee una tabla, calcula y vuelve a escribirla sin ceder por el camino, ningún otro flujo puede haberse colado en medio. No hacen falta cerrojos, no hay condiciones de carrera y los invariantes de tus estructuras solo pueden romperse en los puntos donde tú escribiste explícitamente la palabra yield. La lista de puntos peligrosos de tu programa es finita, está escrita en el código fuente y se puede leer con una búsqueda de texto.
-- Este bloque es atomico respecto a cualquier otra corrutina del estado.
cuenta.saldo = cuenta.saldo - importe
registro[#registro + 1] = importe
cuenta.movimientos = cuenta.movimientos + 1
Compárese con lo que exigiría el mismo fragmento bajo hilos reales —un cerrojo, una disciplina de adquisición y la demostración de que nadie lo toma en otro orden— y se entenderá por qué tanta gente que llega a las corrutinas desde la programación concurrente clásica tarda un rato en creerse que no falta nada.
El precio es exactamente el reverso de la garantía. Una corrutina que entra en un bucle largo sin ceder congela el programa entero, y no hay nada que puedas hacer desde fuera: no existe un resume forzoso ni un mecanismo de expropiación. Si una corrutina no coopera, no coopera. En un editor como Neovim eso se traduce en la interfaz bloqueada; en un servidor, en peticiones que no se atienden. La palabra cooperativa, que a veces se usa como sinónimo de no preemptiva, describe muy bien la situación: el modelo funciona porque todos los participantes cumplen, y no tiene defensa contra el que no cumple.
Desde Lua 5.4, el cuerpo principal se ejecuta sobre un hilo que es una corrutina de pleno derecho: coroutine.running devuelve ese hilo y un valor verdadero que indica que es el principal. La diferencia no está en su naturaleza sino en su posición, porque nadie lo reanudó y por tanto no tiene a quién ceder. Intentar un yield desde el hilo principal produce el error attempt to yield from outside a coroutine.
Lo que una corrutina no es
Conviene cerrar por vía negativa, porque casi todos los errores de este nivel vienen de una analogía mal elegida. No es un hilo del sistema, porque no hay dos cosas corriendo a la vez y porque un cambio de corrutina no pasa por el núcleo. No es una tarea asíncrona, porque no hay bucle de eventos ni planificador: si quieres uno, lo escribes tú encima. No es un generador, aunque sirva perfectamente para escribir generadores, porque un generador solo produce valores y una corrutina también los recibe. Y no es un proceso ligero al estilo de otros lenguajes, porque no tiene buzón, ni identidad global, ni supervisión, ni aislamiento de memoria: todas las corrutinas comparten exactamente las mismas variables globales y las mismas tablas.
Lo que sí es, y esto conviene formularlo con precisión, es un mecanismo de inversión de control. Permite que dos fragmentos de código escritos como si cada uno fuera el que lleva el hilo de la ejecución —un productor con su bucle, un consumidor con el suyo— se turnen sin que ninguno tenga que reescribirse como una máquina de estados ni como una cadena de funciones de continuación. Ese es el problema que resuelve, y todo lo demás es consecuencia.
Durante décadas se explicó la programación estructurada como una historia sobre el flujo de control, cuando en realidad era una historia sobre el estado. Una función es una unidad de composición tan cómoda porque su estado vive en una pila que se crea al entrar y se destruye al salir, y esa disciplina de creación y destrucción es lo que garantiza que las funciones se compongan sin interferirse. El problema aparece cuando un cómputo necesita durar más que su propia ejecución continua: leer un fichero por partes, atender una petición que espera a otra, recorrer un árbol y entregar los nodos de uno en uno. En ese momento el programador descubre que no puede detener la función, y la única salida clásica es sacar el estado de la pila y guardarlo a mano en una estructura: nacen así la máquina de estados explícita, el objeto iterador con sus campos de posición y la cadena de funciones de continuación. Las tres son la misma renuncia —abandonar la pila— y las tres pagan el mismo precio: el algoritmo deja de leerse como el algoritmo y pasa a leerse como su transformación mecánica. Lo que hace una corrutina es señalar que ese sacrificio nunca fue necesario. Si el estado del cómputo cabe en una pila, dale una pila propia y déjalo dormir allí. No es una técnica de concurrencia que se coló en el lenguaje; es la corrección de un supuesto que llevaba tanto tiempo asumido que había dejado de verse: el de que un programa solo puede permitirse una pila. Cuando entiendes eso, la asimetría y la ausencia de preempción dejan de parecer limitaciones y se revelan como lo que son —el conjunto mínimo de reglas bajo el cual varias pilas conviven sin que el razonamiento local se venga abajo—.
Una corrutina es una línea de ejecución con pila propia, representada por un valor de tipo thread, que puede suspenderse con yield a cualquier profundidad de llamadas y reanudarse con resume conservando todo su estado local. El modelo es asimétrico: yield devuelve siempre el control a quien reanudó, lo que obliga a que las corrutinas activas formen una pila y no un grafo. Y es no preemptivo: nada la interrumpe, de modo que todo tramo sin cesiones es atómico y una corrutina que no cede bloquea el programa entero. No hay paralelismo, no hay planificador y no hay aislamiento de memoria.
- Crea una corrutina que ceda dentro de una función anidada tres niveles y comprueba con
debug.tracebackaplicado a la corrutina que los tres marcos siguen ahí mientras está suspendida. - Declara una variable local en el cuerpo de la corrutina, cédela, modifica una tabla global desde fuera y reanuda. Razona antes de ejecutarlo qué ve cada una de las dos.
- Intenta llamar a
coroutine.yielddesde el hilo principal y anota el mensaje de error exacto. Explica por qué es coherente con la asimetría del modelo. - Encadena tres corrutinas donde la primera reanuda a la segunda y la segunda a la tercera. Haz que la tercera ceda y averigua experimentalmente en qué llamada a
resumeaparece el valor. - Escribe una corrutina con un bucle de diez millones de iteraciones sin ninguna cesión y comprueba que ningún otro código se ejecuta hasta que termina. Después reparte una cesión cada cien mil iteraciones y compara.