wandres.dev
CORRUTINAS II · construir sobre ellas

Productor y consumidor: quién manda en cada paso

El patrón clásico de Lua, las tuberías de corrutinas encadenadas, la asimetría entre reanudar y ceder, y por qué el control de flujo sale gratis cuando no hay cola intermedia.

⏱ 17 min

El generador de la lección anterior escondía una decisión que ahora hay que mirar de frente: en toda pareja de corrutinas hay una que reanuda y otra que cede, y esa elección no es simétrica ni intercambiable. El patrón productor y consumidor es el ejemplo canónico de Lua desde hace veinte años, y su interés no está en el código, que cabe en quince líneas, sino en lo que revela: que una tubería de corrutinas transporta valores sin cola intermedia, sin copia y sin hilos, y que el control de flujo que en un sistema concurrente cuesta un semáforo aquí es una consecuencia geométrica de quién llama a quién.

🎯 Al terminar esta lección sabrás
  • Escribir el par productor y consumidor con coroutine.resume y explicar por qué la dirección del control no es arbitraria.
  • Encadenar filtros en una tubería y razonar sobre el coste en transferencias de control por elemento.
  • Distinguir corrutinas asimétricas de simétricas y saber qué se necesita para construir las segundas sobre las primeras.
  • Propagar errores a través de una tubería sin perder el traceback de la etapa que falló.

El patrón clásico

Un productor es una corrutina que cede valores. Un consumidor es un bucle que la reanuda. Escrito así, el consumidor es el que manda.

local function productor(ruta)
  return coroutine.create(function()
    for linea in io.lines(ruta) do
      coroutine.yield(linea)
    end
  end)
end

local function consumidor(prod)
  while true do
    local ok, linea = coroutine.resume(prod)
    if not ok then error(linea, 0) end
    if linea == nil then break end
    io.write(linea, "\n")
  end
end

Fíjate en lo que no hay. No hay una cola de líneas ni un búfer donde el productor deje su trabajo mientras el consumidor se pone al día. La línea viaja del yield al resume como un valor de retorno normal, sin copia y sin estructura intermedia. Y no hay control de flujo explícito porque no puede haber desajuste: el productor solo avanza cuando alguien lo reanuda, de modo que produce exactamente tantos elementos como se le piden y ni uno más. La contrapresión, que entre hilos reales exige una cola acotada y un mecanismo de bloqueo, aquí es la forma misma del mecanismo.

La dirección se puede invertir sin cambiar de mecanismo, solo de papeles:

local function consumidor_pasivo()
  return coroutine.create(function(linea)
    while linea ~= nil do
      io.write(linea, "\n")
      linea = coroutine.yield()
    end
  end)
end

local function productor_activo(ruta, cons)
  for linea in io.lines(ruta) do
    local ok, err = coroutine.resume(cons, linea)
    if not ok then error(err, 0) end
  end
  coroutine.resume(cons, nil)
end

Repara en el detalle sintáctico de la primera línea del cuerpo: los argumentos del primer resume llegan como parámetros de la función de la corrutina, y los de los siguientes como valores de retorno del yield. Esa asimetría entre la primera reanudación y las demás es la fuente de la mitad de los errores de principiante con corrutinas, y el motivo por el que muchos diseños arrancan la corrutina con una reanudación en vacío para dejarla parada en un punto conocido antes de empezar a alimentarla.

Si es el productor quien reanuda al consumidor y le pasa el valor como argumento de resume, tienes una tubería de empuje en vez de una de tracción, y el que se detiene a esperar es el consumidor. El código es igual de corto y el comportamiento observable es muy distinto: en la versión de tracción, cortar el consumo detiene la producción; en la de empuje, el productor sigue empujando hasta agotarse aunque nadie quiera más. Elegir es una decisión de diseño sobre quién tiene derecho a parar.

Filtros y tuberías

Una etapa intermedia es a la vez consumidora de la anterior y productora para la siguiente. Con eso basta para encadenar cuantas quieras.

local function tomar(fuente)
  local ok, v = coroutine.resume(fuente)
  if not ok then error(v, 0) end
  return v
end

local function numerar(fuente)
  return coroutine.create(function()
    local n = 0
    while true do
      local linea = tomar(fuente)
      if linea == nil then break end
      n = n + 1
      coroutine.yield(string.format("%5d  %s", n, linea))
    end
  end)
end

local function sin_vacias(fuente)
  return coroutine.create(function()
    while true do
      local linea = tomar(fuente)
      if linea == nil then break end
      if linea:match("%S") then coroutine.yield(linea) end
    end
  end)
end

consumidor(numerar(sin_vacias(productor("registro.txt"))))

La composición se lee de dentro afuera y el dato viaja de dentro afuera también: cada etapa tira de la anterior. Lo notable es que sin_vacias puede consumir tres líneas antes de ceder una, y ninguna otra parte del programa se entera. Cada etapa mantiene su propio estado en su propia pila —el contador n de numerar es una variable local corriente— sin que exista un objeto compartido que coordine nada.

flowchart RL
C[Consumidor principal] -->|resume| N[Etapa numerar]
N -->|resume| S[Etapa sin vacias]
S -->|resume| P[Productor de lineas]
P -->|yield linea| S
S -->|yield linea| N
N -->|yield linea numerada| C

El diagrama pone el precio a la vista: un elemento que atraviesa k etapas paga 2k transferencias de control. Con tres etapas y un fichero de un millón de líneas eso son seis millones de cambios de pila. Sigue siendo mucho más barato que seis millones de operaciones de entrada y salida, pero es lo bastante caro como para que fundir dos etapas triviales en una sea una optimización real cuando el perfil lo pide. La tubería de corrutinas es una herramienta de claridad estructural; su rendimiento es aceptable, no excelente.

La asimetría que Lua eligió

Las corrutinas de Lua son asimétricas: resume y yield son operaciones distintas y no intercambiables. Reanudar es descender, ceder es volver siempre a quien te reanudó, sin poder elegir destino. Eso impone una disciplina de pila entre corrutinas: en cualquier instante existe una cadena de reanudaciones que va del hilo principal hasta la corrutina que está ejecutándose, y esa cadena es exactamente lo que hace que consumidor(numerar(sin_vacias(...))) se comporte como una expresión anidada y no como un grafo de procesos sueltos.

La alternativa teórica son las corrutinas simétricas, con una única operación de transferencia que salta de cualquiera a cualquiera. Lua no las ofrece, y no las necesita: se construyen encima añadiendo un intermediario que reciba las cesiones y decida a quién reanudar a continuación. Ese intermediario es el planificador de la lección 16.4, y el hecho de que baste con él no es folclore sino un resultado publicado: de Moura e Ierusalimschy demostraron que las corrutinas asimétricas completas tienen la misma potencia expresiva que las simétricas y que las continuaciones de un solo uso. Elegir asimetría no recorta lo que se puede escribir, solo decide qué es directo y qué exige una capa.

Queda el asunto de los errores. Cuando una etapa falla, coroutine.resume no lanza: devuelve false y el mensaje, y la corrutina queda muerta. Volver a lanzarlo con error(mensaje, 0) es obligatorio si quieres que la tubería se rompa entera, y el cero importa: sin él, Lua antepone la posición del error en el consumidor, que es el único sitio del programa que no tiene nada que ver con el fallo. Y el traceback de la etapa culpable no viaja en el mensaje: para conservarlo hay que pedirlo con debug.traceback sobre la corrutina muerta antes de descartarla.

local ok, err = coroutine.resume(etapa)
if not ok then
  error(debug.traceback(etapa, err), 0)
end

Sin cola no hay desbordamiento

El valor viaja del yield al resume como un retorno normal. Nadie produce de más porque producir requiere que alguien reanude.

🔁

Tracción o empuje

Quien reanuda es quien manda. En tracción, cortar el consumo detiene la producción; en empuje, el productor sigue hasta agotarse.

🧱

Cada etapa es secuencial

Una etapa se lee entera como un programa con su bucle y su fin. La concurrencia está en el encaje, no dentro de ninguna de ellas.

🧾

El error hay que relanzarlo

coroutine.resume devuelve el fallo en vez de lanzarlo. Sin error(msg, 0) la tubería sigue como si nada y el mensaje señala al sitio equivocado.

El control no es un recurso que se posee, es una relación entre dos pilas

Al programar con hilos aprendemos a pensar en el control como algo que cada uno tiene por su cuenta: cada hilo posee su flujo y el sistema decide cuándo lo suspende. Una tubería de corrutinas rompe esa intuición sin sustituirla por otra cómoda. Aquí solo hay un flujo de ejecución en todo el programa y varias pilas que se lo pasan, de modo que preguntar cuál de las corrutinas está corriendo es preguntar por el extremo de una cadena que ellas mismas construyeron reanudándose. La consecuencia interesante no es de rendimiento sino epistemológica: la etapa numerar está escrita como si fuera un programa completo, con su bucle, su contador y su fin natural, y sin embargo su tiempo transcurre a trozos que otro decide. Cada una de esas etapas es un programa secuencial legible en aislamiento, y el sistema entero es concurrente sin que ninguna de ellas contenga una sola línea de código concurrente. Eso es exactamente lo que las bibliotecas de hilos prometen y no cumplen, y el motivo por el que lo cumplen las corrutinas es humilde: como la transferencia de control es explícita y sintácticamente visible, sabes con precisión en qué puntos tu estado puede quedar a merced de otro. Un resume es una frontera declarada. Un cambio de contexto preventivo no lo es en ninguna parte.

⚔️ Encadena, invierte y rompe una tubería
  1. Escribe el par productor y consumidor sobre un fichero real, y luego reescríbelo en la dirección de empuje, con el productor reanudando al consumidor. Corta el consumo a la mitad en las dos versiones y explica la diferencia.
  2. Añade una tercera etapa que agrupe líneas de tres en tres y las ceda concatenadas. Comprueba que la etapa consume varias veces antes de ceder una y que ninguna otra etapa lo nota.
  3. Mide el tiempo de la tubería completa con una etapa, con tres y con seis sobre el mismo fichero. Ajusta una recta y comprueba si el coste por elemento crece de forma lineal en el número de etapas.
  4. Provoca un error en la etapa intermedia y compara tres formas de propagarlo: devolver el false tal cual, relanzar con error(err) y relanzar con error(debug.traceback(co, err), 0). Anota qué información sobrevive en cada caso.
  5. Escribe una función transferir que reciba dos corrutinas y simule una transferencia simétrica usando el hilo principal como intermediario. Explica qué información necesita mantener ese intermediario que la versión asimétrica no necesitaba.