wandres.dev
CORRUTINAS I · el modelo asimétrico

Corrutinas frente a hilos: sin paralelismo, pero sin carreras

Una corrutina no usa un segundo núcleo y nunca lo hará. A cambio elimina por construcción las condiciones de carrera, cuesta alrededor de un kilobyte, cambia de contexto por el precio de una llamada a función y admite cientos de miles de instancias. Qué clase de problemas resuelve mejor que un hilo y cuáles no resuelve en absoluto.

⏱ 18 min

La comparación con los hilos es inevitable porque el tipo se llama thread, y es engañosa porque las dos cosas resuelven problemas distintos que la industria lleva cuarenta años confundiendo. Un hilo del sistema operativo existe para usar varios núcleos y para sobrevivir a una llamada bloqueante; una corrutina existe para que dos cómputos escritos como si cada uno mandara puedan turnarse. Que ambos permitan escribir un productor y un consumidor no los hace alternativas: los hace herramientas con un solapamiento parcial y unos costes que difieren en tres órdenes de magnitud. Esta lección pone números donde suele haber intuiciones y termina delimitando el territorio donde la corrutina no tiene rival y aquel donde no sirve para nada.

🎯 Al terminar esta lección sabrás
  • Enunciar con precisión qué garantía de atomicidad ofrece el modelo no preemptivo y dónde termina.
  • Estimar el coste en memoria de una corrutina y el de un cambio de contexto frente a los de un hilo.
  • Reconocer la operación bloqueante como el punto donde el modelo cooperativo se rompe.
  • Clasificar un problema dado como candidato a corrutinas, a hilos reales o a ninguna de las dos cosas.

Lo que no obtienes, y no vas a obtener

Empecemos por lo que no hay, porque descartarlo bien ahorra semanas. En un estado de Lua solo se ejecuta una línea de ejecución en cada instante, y ninguna cantidad de corrutinas cambia eso. Si tu programa tarda diez segundos de cómputo puro, repartirlo entre cien corrutinas lo dejará tardando diez segundos y un poco más. No hay un segundo núcleo esperando y no lo habrá, porque el estado global —la tabla de cadenas internadas, el recolector de basura, la tabla de globales— es compartido y no está protegido por nada. Ese es precisamente el motivo por el que Lua no ofrece hilos con memoria compartida: hacerlo obligaría a sincronizar el intérprete entero y a duplicar su tamaño, que es exactamente lo contrario del proyecto.

El segundo límite es más sutil y produce más sorpresas: una llamada bloqueante bloquea todo el programa. Si una corrutina hace una lectura de socket con la biblioteca estándar y no hay datos, el proceso entero se queda parado, incluidas las otras noventa y nueve corrutinas que tenían trabajo pendiente. La corrutina no convierte en asíncrono lo que era síncrono; solo te da un sitio donde suspender el cómputo si alguien más se encarga de esperar. Ese alguien es un bucle de eventos —libuv en Neovim y en la mayoría de los entornos modernos— y sin él las corrutinas no hacen entrada y salida asíncrona en absoluto.

Cuando lo que necesitas es de verdad paralelismo, la respuesta idiomática en Lua es crear varios estados independientes, uno por hilo del sistema, y comunicarlos copiando mensajes en vez de compartiendo memoria. Bibliotecas como Lanes o effil hacen justo eso. No es un apaño: es la misma decisión que tomaron los lenguajes que se han tomado en serio la concurrencia, y evita todos los problemas que las corrutinas evitan por otra vía.

flowchart TB
P[Necesito que dos cosas avancen a la vez] --> Q[Estan limitadas por CPU]
Q -->|si| H[Varios estados de Lua o procesos separados]
Q -->|no| R[Esperan a entrada y salida o a eventos]
R -->|si| B[Bucle de eventos mas corrutinas]
R -->|no| S[Solo quiero invertir el control]
S --> C[Corrutinas sin nada mas]

Lo que obtienes gratis: la ausencia de carreras

Ahora la contrapartida, que es enorme y suele infravalorarse. Como nada interrumpe a una corrutina, todo tramo de código sin cesiones es atómico respecto a las demás. La consecuencia es que las condiciones de carrera clásicas —dos flujos leyendo y escribiendo la misma estructura, invariantes rotos a mitad de una actualización, lecturas de valores parcialmente escritos— no pueden ocurrir. No es que sean improbables: son imposibles por construcción.

-- Con hilos reales esto exigiria un cerrojo. Con corrutinas es correcto tal cual.
local saldo = { cantidad = 100, movimientos = 0 }

local function transferir(n)
  saldo.cantidad = saldo.cantidad - n     -- nadie puede colarse
  saldo.movimientos = saldo.movimientos + 1
end

El corolario es de método, no de programación: los puntos peligrosos de tu programa son exactamente los yield, y son finitos, están escritos y se localizan con una búsqueda de texto. Revisar la corrección concurrente de un programa con corrutinas es leer las secciones que rodean cada cesión. Revisar la de un programa con hilos es demostrar propiedades sobre todos los entrelazados posibles, que es una tarea de otra naturaleza y que en la práctica nadie completa.

Conviene decir dónde termina la garantía, porque no es absoluta. Si un tramo contiene una cesión, todo lo que hubiera entre manos puede haber cambiado al volver: la tabla que estabas recorriendo, el fichero que tenías abierto, el estado que creías tuyo. La regla profesional es tratar cada yield como lo que es, un punto donde el mundo puede haber cambiado, y no sostener invariantes rotos a través de él. Ceder en mitad de una actualización de dos campos es exactamente el mismo error que soltar un cerrojo a mitad de camino, solo que aquí es visible en el código fuente.

⚠️
La atomicidad no cubre lo que hay fuera del estado de Lua

Un fichero en disco, una base de datos o un dispositivo compartido con otros procesos no saben nada de tus corrutinas. La garantía de no interferencia vale para las estructuras que viven dentro del estado de Lua y para nada más. Si dos corrutinas escriben en el mismo fichero, el sistema operativo entrelaza como le parece.

El coste: cuánto pesa y cuántas caben

Los números importan porque cambian qué diseños son razonables.

Una corrutina recién creada es una estructura de estado más una pila inicial pequeña. En Lua 5.4 sobre una máquina de sesenta y cuatro bits, eso ronda el kilobyte, del que la mayor parte es la pila de valores inicial, que crece bajo demanda si la corrutina hace llamadas profundas. Crearla no toca el núcleo del sistema operativo: es una reserva de memoria y la inicialización de unos cuantos campos, del orden de una fracción de microsegundo.

Un hilo del sistema, en el mismo hardware, reserva por defecto ocho megabytes de espacio de direcciones para su pila —de los que solo se compromete lo que use— más estructuras del núcleo, y su creación cuesta decenas de microsegundos porque implica una llamada al sistema. La diferencia en creación es de dos a tres órdenes de magnitud, y la de espacio de direcciones, de tres a cuatro.

El cambio de contexto es donde la distancia se hace más elocuente. Pasar de una corrutina a otra es guardar unos punteros y saltar: cuesta aproximadamente lo que una llamada a función, decenas de nanosegundos, y no invalida cachés ni tablas de traducción. Un cambio de hilo del sistema pasa por el planificador del núcleo, cuesta del orden del microsegundo y deja las cachés frías.

Magnitud Corrutina de Lua Hilo del sistema
Memoria inicial Alrededor de un kilobyte Megabytes de direcciones reservadas
Coste de creación Fracción de microsegundo Decenas de microsegundos
Cambio de contexto Decenas de nanosegundos Del orden del microsegundo
Cuántas caben Cientos de miles Miles, con esfuerzo
Usa varios núcleos No
Necesita cerrojos No

Los dos primeros números se comprueban en diez líneas, y merece la pena hacerlo en la máquina propia antes de creerse ninguna tabla:

collectgarbage("collect")
local antes = collectgarbage("count")
local viveros = {}
for i = 1, 100000 do
  viveros[i] = coroutine.create(function() coroutine.yield() end)
end
collectgarbage("collect")
print((collectgarbage("count") - antes) / 100000, "KiB por corrutina suspendida")

Obsérvese que esas cien mil corrutinas están sin arrancar, de modo que la cifra mide el suelo del modelo. En cuanto se reanuda una y su cuerpo hace llamadas profundas, su pila crece bajo demanda y el consumo sube; ese crecimiento es proporcional a la profundidad real, no a un máximo reservado por adelantado, y ahí está la otra mitad de la diferencia con el hilo del sistema, que aparta su espacio de direcciones entero desde el primer instante.

La lectura práctica de la última columna de cifras es que la corrutina deja de ser un recurso escaso. Puedes permitirte una por conexión, una por entidad de un videojuego, una por elemento de una animación, una por nodo de un recorrido. Ese cambio de escala es lo que convierte a las corrutinas en una primitiva de diseño y no en una técnica ocasional: cuando algo cuesta mil veces menos, no lo usas mil veces más, lo usas de otra manera.

🪶

Una por entidad

Con un kilobyte por instancia, dar a cada enemigo de un juego su propia corrutina con su bucle de comportamiento es barato y elimina la máquina de estados que habría hecho falta.

🔒

Cero primitivas de sincronización

No hay cerrojos, ni semáforos, ni variables de condición, ni memoria atómica. Tampoco hay interbloqueos ni inversión de prioridad, porque no hay nada que bloquear.

🐌

Un bucle sin ceder lo para todo

La ausencia de preempción no tiene defensa. Una tarea mal escrita congela el sistema entero y no hay forma de desalojarla desde fuera.

🔌

Sin bucle de eventos no hay asincronía

Las corrutinas dan el punto de suspensión; alguien tiene que dar la espera. En Neovim ese alguien es libuv a través de la capa de bucle de eventos.

Qué resuelve mejor que un hilo

La pregunta correcta no es cuál de los dos es mejor sino qué clase de problema tiene cada uno. Un hilo resuelve el problema de la simultaneidad física: dos cómputos que de verdad tienen que avanzar a la vez sobre hardware distinto, o uno que espera al núcleo mientras el otro trabaja. Una corrutina resuelve el problema de la inversión de control: dos fragmentos de código escritos cada uno como si él llevara el hilo de la ejecución, que necesitan turnarse sin que ninguno se reescriba.

Ese segundo problema aparece por todas partes y casi nunca se reconoce con ese nombre. El iterador que tiene que entregar los nodos de un árbol de uno en uno y no puede permitirse recorrerlo entero primero: escrito como corrutina es el recorrido recursivo de siempre con un yield donde iría la impresión, y sin corrutina es un objeto con una pila explícita dentro. El analizador que consume una entrada por trozos según van llegando: con corrutina es la gramática escrita de forma directa, y sin ella es una máquina de estados que nadie sabe leer. El diálogo de un videojuego que dice una frase, espera a que el jugador responda y continúa: con corrutina son diez líneas secuenciales, y sin ella es una cadena de funciones de continuación. La animación que avanza un poco en cada fotograma. El algoritmo con retroceso que quiere entregar cada solución encontrada sin abandonar la búsqueda.

-- Recorrido en profundidad entregado nodo a nodo, escrito como se piensa.
local function nodos(arbol)
  return coroutine.wrap(function()
    local function bajar(n)
      if not n then return end
      bajar(n.izq); coroutine.yield(n.valor); bajar(n.der)
    end
    bajar(arbol)
  end)
end

for valor in nodos(mi_arbol) do print(valor) end

Ninguno de esos casos mejora con un hilo del sistema. En todos ellos, meter un hilo añade un planificador que no necesitas, cerrojos para proteger un estado que solo toca una parte cada vez y un no determinismo que no aporta nada. La corrutina hace el trabajo con una pila de un kilobyte, un cambio de contexto de nanosegundos y la certeza de que nada se entrelaza.

Y al revés: cuando el problema es medir el tiempo real, aprovechar ocho núcleos o sobrevivir a una biblioteca de C que bloquea, la corrutina no ayuda en absoluto y fingir que sí es una fuente inagotable de código lento y raro.

Concurrencia y paralelismo no son grados de lo mismo

El error que estructura casi todas las discusiones sobre este tema es tratar la concurrencia como una versión suave del paralelismo, como si fueran dos puntos de una misma escala y la corrutina fuese un hilo pobre. No lo son. La concurrencia es una propiedad de la estructura de un programa: significa que está compuesto por varias líneas de actividad con su propio hilo argumental, y es una afirmación sobre cómo está escrito, verificable leyéndolo, cierta incluso en una máquina de un solo núcleo. El paralelismo es una propiedad de la ejecución: significa que dos cosas ocurren en el mismo instante físico, y es una afirmación sobre el hardware, indiferente a cómo esté escrito el programa. Un programa concurrente puede ejecutarse sin ningún paralelismo, y un programa paralelo puede no tener ninguna estructura concurrente, como ocurre con un bucle vectorizado. Las corrutinas son un mecanismo de concurrencia puro, en el sentido más literal: dan estructura y no dan ejecución simultánea, y por eso se quedan con todas las ventajas expresivas del modelo y con ninguno de sus costes de sincronización. Los hilos, al confundir las dos cosas en una sola primitiva, obligan a pagar el precio del paralelismo —cerrojos, barreras, no determinismo, entrelazados imposibles de enumerar— incluso cuando lo único que se quería era estructura, que es la inmensa mayoría de las veces. Cuando esta distinción se interioriza, la pregunta de diseño deja de ser cuántos hilos necesito y pasa a ser una mucho más útil: cuántas líneas argumentales tiene mi programa, y cuántas de ellas necesitan de verdad ocurrir en el mismo instante. Casi siempre la primera cifra es grande y la segunda es uno.

📝
Lo esencial

Las corrutinas no dan paralelismo: una sola se ejecuta en cada instante y una llamada bloqueante detiene el programa entero. A cambio eliminan las carreras por construcción, porque todo tramo sin cesiones es atómico y los puntos peligrosos son los yield, finitos y visibles. Cuestan alrededor de un kilobyte, se crean en fracciones de microsegundo y cambian de contexto por el precio de una llamada a función, de modo que caben cientos de miles. Resuelven la inversión de control —iteradores, analizadores, diálogos, animaciones, retroceso— mejor que cualquier alternativa; no resuelven el uso de varios núcleos, para lo que Lua recurre a estados independientes que se comunican copiando.

⚔️ Pon números a la comparación
  1. Crea cien mil corrutinas suspendidas y mide la memoria con collectgarbage("count") antes y después. Divide y compara con la cifra de esta lección.
  2. Mide con os.clock el tiempo de un millón de pares de resume y yield. Calcula el coste medio de un cambio de contexto y contrástalo con el de una llamada a función vacía.
  3. Escribe un contador compartido incrementado por diez corrutinas que ceden en puntos arbitrarios. Comprueba que el resultado es determinista y explica exactamente por qué.
  4. Rompe la garantía a propósito: cede en mitad de la actualización de dos campos que deben cuadrar y haz que otra corrutina lea el estado intermedio.
  5. Elige tres problemas de tu propio código y clasifícalos con el diagrama de la primera sección. Justifica en una frase por qué cada uno cae donde cae.