El paso de valores: cuatro canales y una doble dirección
Los argumentos del primer resume van a los parámetros de la función; los de los siguientes salen por el yield que dejó la corrutina detenida. Lo que cede yield sale por resume y lo que retorna la función también. Cuatro canales distintos, un desfase de uno y la confusión clásica que produce leer yield como si mirara en una sola dirección.
Casi todo el mundo entiende resume y yield a la primera y casi nadie acierta con el paso de valores a la primera. La razón es que yield es la única construcción del lenguaje que mira simultáneamente hacia dentro y hacia fuera: sus argumentos salen del cómputo y sus valores de retorno entran en él, y ambas cosas ocurren en el mismo punto del texto pero en momentos separados por una suspensión que puede durar lo que quiera. Sumado a que el primer resume no se comporta como los demás, tenemos cuatro canales de comunicación distintos y un desfase de uno entre reanudaciones y cesiones. Esta lección desmonta la maraña canal por canal.
- Enumerar los cuatro canales de paso de valores y decir en qué punto del código entra y sale cada uno.
- Distinguir el destino de los argumentos del primer
resumedel de los siguientes. - Leer
yieldcomo una expresión bidireccional y predecir qué evalúa en cada reanudación. - Explicar el desfase entre el número de reanudaciones y el de cesiones, y sus consecuencias prácticas.
Cuatro canales de comunicación
Entre la corrutina y quien la reanuda circulan valores en dos direcciones, y en cada dirección hay dos puertas distintas según sea la primera vez o no. Conviene fijar los cuatro casos con nombre propio antes de mirar ningún código.
| Canal | Origen | Destino | Cuándo |
|---|---|---|---|
| Entrada inicial | Argumentos de resume |
Parámetros de la función | Solo el primer resume |
| Entrada posterior | Argumentos de resume |
Valores de retorno de yield |
Todos los demás resume |
| Salida por cesión | Argumentos de yield |
Valores tras el true de resume |
Cada suspensión |
| Salida final | Valores de return |
Valores tras el true de resume |
Una sola vez, al morir |
Los cuatro admiten un número arbitrario de valores, incluido cero, y ninguno impone ninguna correspondencia con los demás. Puedes reanudar con tres valores y recibir cero; ceder con cero y ser reanudado con siete. No hay firma, no hay comprobación y no hay ayuda del lenguaje: el protocolo entre las dos partes lo estableces tú y no lo vigila nadie.
sequenceDiagram participant P as Principal participant C as Corrutina P->>C: resume con a y b Note over C: a y b llegan como parametros de la funcion C->>P: yield con x Note over P: x aparece detras del true de resume P->>C: resume con y Note over C: y aparece como resultado del yield anterior C->>P: return con z Note over P: z aparece detras del true y la corrutina muere
El primer resume y el último
El primer resume arranca la función, y por tanto sus argumentos van donde van los argumentos de cualquier llamada: a los parámetros declarados. Los siguientes no arrancan nada, porque la función ya está a medias, y por eso sus argumentos tienen que ir a otro sitio: al punto exacto donde la corrutina se detuvo.
local acumulador = coroutine.create(function(inicial)
local total = inicial
while true do
local n = coroutine.yield(total) -- cede total, recibe n
total = total + n
end
end)
print(coroutine.resume(acumulador, 10)) --> true 10
print(coroutine.resume(acumulador, 5)) --> true 15
print(coroutine.resume(acumulador, 3)) --> true 18
Observa el primer 10. Entró como argumento del primer resume, se ligó al parámetro inicial y volvió a salir en el mismo instante como argumento del primer yield. El 5 de la segunda línea, en cambio, no tocó ningún parámetro: apareció dentro de la corrutina como valor de la expresión coroutine.yield(total), que llevaba suspendida desde la línea anterior.
El otro extremo tiene su propia peculiaridad. Cuando la función retorna, sus valores salen por el mismo canal que los de una cesión, así que desde fuera un retorno y una cesión son indistinguibles por su forma. Y hay una asimetría más, fácil de pasar por alto: el yield que suspende la corrutina por última vez, si nadie vuelve a reanudarla nunca, nunca llega a evaluar sus valores de retorno. Los argumentos que le habrías pasado en un resume que no ocurre simplemente no existen.
Como el primer resume es el único que alimenta los parámetros, es el sitio natural para la configuración inicial de la corrutina: el fichero que va a leer, el tamaño del bloque, la tabla de destino. Muchos programadores lo evitan y capturan esos valores por closure en el momento de crear la corrutina, lo cual también funciona; la diferencia es que los parámetros permiten reutilizar la misma función de cuerpo para muchas corrutinas distintas y el closure la ata a unos valores concretos.
La doble dirección de yield
Aquí está el núcleo de la lección. En Lua, coroutine.yield es una llamada a función corriente, con argumentos y con valores de retorno, y lo raro es que sus argumentos se evalúan en un instante y sus valores de retorno llegan en otro completamente distinto.
local peticion = coroutine.yield("necesito", "dos", "cosas")
Esa línea hace dos trabajos separados por una suspensión. Primero empaqueta las tres cadenas y suspende la corrutina; esos tres valores aparecen tras el true del resume que estaba esperando. Después, cuando alguien reanude —dentro de un microsegundo o dentro de media hora—, los argumentos de ese nuevo resume se convierten en los valores de retorno de esta misma llamada, y peticion recibe el primero de ellos.
La confusión clásica tiene dos formas y conviene reconocerlas escritas.
-- Forma 1: creer que yield devuelve lo que cedió
local x = coroutine.yield(42)
-- x NO vale 42. x vale lo que traiga el proximo resume.
-- Forma 2: creer que los argumentos del resume llegan a los parametros
local co = coroutine.create(function(a)
coroutine.yield()
print(a) -- imprime el argumento del PRIMER resume, no del segundo
end)
coroutine.resume(co, "uno")
coroutine.resume(co, "dos") --> imprime uno
La cura es leer yield como lo que es: un punto de intercambio. A su izquierda están los valores que recibes de fuera, a su derecha, entre paréntesis, los que entregas. Escribir la línea completa local respuesta = coroutine.yield(pregunta) y decirla en voz alta como entrego la pregunta y espero aquí hasta que me traigan la respuesta elimina el problema de raíz, porque nombra el hecho de que entre las dos mitades de la frase hay una espera.
Un punto, dos momentos
Los argumentos de yield se evalúan al suspender; sus resultados llegan al reanudar. La misma línea de código se ejecuta en dos instantes separados por todo el tiempo del mundo.
Cualquier aridad, en ambos sentidos
Cero, uno o veinte valores en cada canal, sin correspondencia entre direcciones. Usa table.pack y select si el protocolo necesita contar con exactitud los valores nulos.
El true ocupa el primer sitio
Todo lo que sale de la corrutina llega desplazado una posición, detrás del booleano de éxito. Es el motivo de que coroutine.wrap exista y de que casi todo el código idiomático lo prefiera.
Retorno y cesión salen iguales
Desde fuera, true seguido de valores no dice si la corrutina cedió o terminó. Si te importa la diferencia, acuérdala en el protocolo: un centinela, un valor extra o un contrato de aridad.
El desfase y sus consecuencias
Cuenta las operaciones de una corrutina que cede dos veces y luego retorna: hacen falta tres llamadas a resume y hay dos llamadas a yield. Siempre hay exactamente una reanudación más que cesiones, porque la primera reanudación no responde a ninguna cesión —arranca la función— y la última no es respondida por ninguna cesión, sino por el retorno.
Ese desfase de uno tiene tres consecuencias prácticas que explican bastante código real.
La primera es que un bucle de consumo escrito de forma ingenua ejecuta una reanudación de más, la que recoge el retorno. Por eso el patrón correcto comprueba el resultado en cada vuelta y no supone que cada resume traerá un valor útil.
local function consumir(co, ...)
local vivos = { coroutine.resume(co, ...) }
while vivos[1] and coroutine.status(co) ~= "dead" do
procesar(table.unpack(vivos, 2))
vivos = { coroutine.resume(co) }
end
return table.unpack(vivos, 2) -- lo que devolvio la funcion, si termino bien
end
La segunda es que el valor que entra en el primer resume no puede usarse como respuesta a nada, porque no hay ninguna cesión esperándolo. Si tu protocolo es de tipo pregunta y respuesta, la corrutina tiene que empezar cediendo una pregunta antes de poder recibir su primera respuesta, y eso significa que el primer resume sirve para poner en marcha el diálogo y nada más. Es el motivo por el que tantos productores empiezan con un yield sin argumentos o con un valor de saludo.
La tercera es puramente aritmética y sin embargo es la fuente del fallo más molesto de este nivel: si construyes una máquina que alterna trabajos y llevas la cuenta de los pasos, tu contador de reanudaciones y tu contador de cesiones nunca coincidirán. Igualarlos a la fuerza produce un resume de más que devuelve false con cannot resume dead coroutine, o un yield de más que deja la corrutina viva cuando la creías terminada.
Nada comprueba que reanudes con lo que la corrutina espera. Si el protocolo pide un número y le mandas una cadena, el error aparecerá varias líneas más abajo, dentro de la corrutina, con una traza que apunta a la aritmética y no al resume culpable. En código no trivial, valida los valores recibidos justo después de cada yield: es el equivalente de comprobar los argumentos al entrar en una función.
La forma más fértil de entender esta doble dirección es reconocer que yield no es un mecanismo nuevo sino una llamada a función a la que le han separado en el tiempo la ida y la vuelta. Cuando llamas a una función normal entregas argumentos, esperas y recibes resultados, y el hecho de que la espera sea instantánea y ocupe la misma pila hace que nadie piense en ella como espera. yield conserva exactamente esa estructura —entrego, espero, recibo— y solo altera dos cosas: la espera puede durar lo que sea, y el que responde no es una función identificada por su nombre sino quienquiera que tenga la corrutina y decida reanudarla. Ahí está la inversión de control en su forma más pura, y ahí está también la razón de que las corrutinas sirvan para tantas cosas aparentemente distintas. Un iterador es una corrutina cuyas respuestas se ignoran; un canal de comunicación es una corrutina cuyas preguntas y respuestas alternan; un sistema de entrada y salida asíncrona es una corrutina que pregunta avísame cuando este dato esté listo y recibe el dato como respuesta; una máquina de estados es una corrutina cuya pregunta es qué evento ha ocurrido. En todos los casos la primitiva es la misma y lo único que cambia es qué se pone en cada uno de los cuatro canales. Por eso equivocarse con el paso de valores no es un error de sintaxis que se corrige mirando el manual: es la señal de que todavía se está leyendo yield como una instrucción de salto, cuando es una llamada a función a la que le han estirado el intervalo entre la pregunta y la respuesta hasta hacerlo visible.
Cuatro canales: los argumentos del primer resume van a los parámetros de la función; los de los siguientes salen como valores de retorno del yield que la dejó suspendida; los argumentos de yield aparecen detrás del true de resume; y los valores de return aparecen por ese mismo sitio, una sola vez, al morir. yield es bidireccional en el mismo punto del código pero en instantes distintos. Siempre hay una reanudación más que cesiones, y ese desfase gobierna la forma correcta de escribir un bucle de consumo.
- Escribe el acumulador del ejemplo y añade impresiones que muestren, en cada vuelta, qué entró por el parámetro y qué entró por el
yield. Ejecuta cinco reanudaciones. - Construye una corrutina que ceda tres valores y sea reanudada con dos. Comprueba con
selectcuántos valores recibe cada lado y qué ocurre con los que sobran o faltan. - Provoca deliberadamente la confusión de la forma 1 y explica por escrito, sin ejecutar nada, qué imprimirá cada línea antes de comprobarlo.
- Implementa un diálogo de pregunta y respuesta donde la corrutina pida un número, lo valide y vuelva a pedirlo si no le sirve. Cuenta cuántos
resumey cuántosyieldhan hecho falta. - Reescribe el bucle
consumirsin usarcoroutine.status, apoyándote en un centinela devuelto por la función. Compara la legibilidad de las dos versiones.