La máquina de estados
El compilador no se limita a añadir un parámetro: parte el cuerpo de la función suspendida por cada punto de suspensión y lo reconstruye como un switch sobre una etiqueta de estado. Esta lección estudia esa reescritura pieza a pieza: qué cuenta como punto de suspensión, qué forma tiene la clase generada y su método invokeSuspend, por qué las variables locales que sobreviven a una espera dejan de vivir en la pila para vivir en campos, y cómo el objeto continuación acaba siendo a la vez el estado y el código de la corrutina.
Añadir un parámetro no basta. Si una función suspendida se limitara a recibir su continuación, seguiría teniendo el problema original: al llegar a una espera tendría que retornar, su marco de pila se destruiría, y con él desaparecerían las variables locales, la posición dentro del bucle y el punto exacto al que habría que volver. La segunda mitad de la transformación resuelve justamente eso, y lo hace mediante una idea que cualquiera que haya escrito un intérprete reconocerá de inmediato: si el estado de una computación no puede vivir en la pila, tendrá que vivir en un objeto, y si la ejecución debe poder reanudarse por la mitad, el cuerpo no puede ser una secuencia de instrucciones sino un conjunto de fragmentos indexados. Lo que el compilador de Kotlin fabrica a partir de una función suspendida no es una función asíncrona: es un autómata.
- Identificar con precisión qué construcciones del código son puntos de suspensión y cuáles no.
- Describir la forma de la clase generada, su campo de etiqueta y el papel del método
invokeSuspend. - Determinar qué variables locales se promocionan a campos del objeto continuación y cuáles pueden quedarse en la pila.
- Explicar cómo la reanudación reentra en el mismo método y por qué eso preserva bucles y condicionales sin reescribirlos.
Dónde se corta el cuerpo
Un punto de suspensión es un lugar del código donde la ejecución podría ceder el control y volver más tarde. En Kotlin coincide exactamente con una llamada a una función suspendida, y no hay ningún otro. No lo es abrir un bloque, no lo es un bucle, no lo es una función ordinaria por muy lenta que sea, y no lo es una operación de entrada y salida bloqueante escrita a la vieja usanza.
suspend fun perfilCompleto(id: String): Perfil {
log("empezando") // no es punto de suspension
val usuario = cargarUsuario(id) // punto de suspension 1
val avatar = cargarAvatar(usuario.foto) // punto de suspension 2
return Perfil(usuario, avatar)
}
Esas dos marcas dividen el cuerpo en tres fragmentos: lo que hay antes de la primera llamada, lo que hay entre las dos, y lo que hay después de la segunda. La observación decisiva es que cada fragmento sí es código ordinario y secuencial que se ejecuta entero sin interrupción; lo único que hay que resolver es cómo empezar por el fragmento correcto cuando alguien reanuda la función. Esa es la pregunta que la etiqueta de estado responde.
Que algo sea un punto de suspensión significa que la ejecución podría ceder ahí, no que vaya a hacerlo. El compilador no tiene forma de saber si cargarUsuario se suspenderá de verdad en esta llamada concreta, así que genera el estado para ambos casos. Esa distinción entre lo potencial y lo efectivo es la que la lección siguiente convierte en el mecanismo del valor centinela, y es la razón de que las corrutinas resulten baratas cuando el resultado ya está disponible.
Un switch sobre una etiqueta
La reescritura consiste en generar una clase que representa esta invocación concreta de la función, con un campo entero que indica por dónde va, y en trasladar el cuerpo a un método de esa clase organizado como una selección múltiple. La aproximación legible siguiente omite detalles del bytecode real pero conserva la estructura íntegra.
// Clase generada por el compilador para perfilCompleto
class PerfilEstado(completion: Continuation<Any?>) : ContinuationImpl(completion) {
var label: Int = 0 // por donde va la ejecucion
var resultado: Any? = null // lo que devolvio la ultima espera
var id: String? = null // locales que sobreviven a una espera
var usuario: Usuario? = null
override fun invokeSuspend(res: Any?): Any? {
resultado = res
return perfilCompleto(null, this) // reentra en el mismo metodo
}
}
Y el cuerpo transformado toma la forma de un despachador. Cada rama corresponde a un fragmento, y cada fragmento termina o bien saltando a la siguiente etiqueta o bien devolviendo el resultado final.
fun perfilCompleto(id: String?, cont: Continuation<Any?>): Any? {
val estado = if (cont is PerfilEstado) cont else PerfilEstado(cont)
when (estado.label) {
0 -> {
estado.id = id
estado.label = 1 // marca donde volver
val r = cargarUsuario(id!!, estado)
if (r == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
estado.resultado = r
}
1 -> { /* se llega aqui al reanudar tras la primera espera */ }
2 -> { /* se llega aqui al reanudar tras la segunda espera */ }
}
// ... fragmentos siguientes con la misma estructura
return Perfil(estado.usuario!!, estado.resultado as Avatar)
}
Conviene fijar tres rasgos de esa estructura. El primero es que la etiqueta se actualiza antes de la llamada, no después: quien reanude debe encontrar ya escrito el punto de retorno, porque para entonces el fragmento actual ya no existirá. El segundo es que la comprobación contra el valor centinela aparece después de cada llamada suspendida, y es lo que distingue el caso en que la operación terminó de inmediato del caso en que cedió el control. El tercero es que la función se llama a sí misma en el arranque de cada reanudación, con el objeto de estado como argumento, de modo que el mismo método sirve para la primera entrada y para todas las reentradas.
flowchart TD
A[Entrada en la funcion] --> B{Valor de la etiqueta}
B -- cero --> C[Fragmento uno]
B -- uno --> D[Fragmento dos]
B -- dos --> E[Fragmento tres]
C --> F[Guardar locales y fijar etiqueta]
F --> G[Llamar a la funcion suspendida]
G --> H{Devolvio el centinela}
H -- si --> I[Retornar y liberar el hilo]
H -- no --> D
I --> J[resumeWith reentra en invokeSuspend]
J --> BLas variables locales cambian de casa
Aquí está la parte de la transformación que más consecuencias tiene y que menos se nombra. Una variable local vive en el marco de pila, y el marco de pila se destruye cuando la función retorna para ceder el hilo. Por tanto, toda variable cuyo valor se necesite después de una espera tiene que dejar de ser local y convertirse en un campo del objeto de estado. El compilador hace ese análisis por nosotros: recorre el cuerpo, calcula qué variables están vivas al cruzar cada punto de suspensión, y promociona exactamente esas.
suspend fun ejemplo(n: Int): Int {
val temporal = n * 2 // muere antes de la espera: sigue en la pila
val leido = leer(temporal) // punto de suspension
val posterior = leido + 1 // nace despues: sigue en la pila
val otro = leer(posterior) // punto de suspension
return leido + otro // leido cruza dos esperas: sera un campo
}
Vive en la pila
Variables cuyo rango de uso está contenido entre dos puntos de suspensión consecutivos. Nacen y mueren dentro de un fragmento, así que la reanudación nunca necesita recordarlas.
Vive en el objeto
Variables que se leen después de una espera. Se promocionan a campos del objeto de estado, con nombres sintéticos que en el bytecode se reconocen por su prefijo y su índice.
Los bucles no se reescriben
Un for que contiene una espera no se convierte en recursión: la variable del bucle se promociona a campo y la etiqueta hace que la reentrada caiga dentro del cuerpo. El bucle sigue siendo un bucle.
En el bytecode esos campos no llevan el nombre que tenían en el código fuente. El compilador los genera con identificadores sintéticos formados por una letra que indica la categoría del valor y un índice correlativo, de modo que una función con tres referencias vivas produce tres campos numerados y una con dos enteros vivos produce otros dos de otra familia. Saber esto ahorra tiempo al leer un volcado de memoria o al inspeccionar un objeto de estado en el depurador: los campos con nombre extraño no son basura del entorno de ejecución, son las variables locales del programa.
La tercera tarjeta merece énfasis porque es donde se paga con creces todo el aparato. En el estilo de callbacks, un bucle que contiene una operación asíncrona debía reescribirse como una función recursiva, y con ella se iba la claridad. Aquí el bucle sobrevive intacto en el código fuente porque la maquinaria que preserva la posición no está en la forma del código sino fuera de él, en un campo de un objeto. Lo mismo vale para el condicional, para el when y, de forma especialmente valiosa, para el try: el manejador cubre una región del método generado, y como la reanudación reentra en ese mismo método, la excepción que llegue por resumeWith acaba lanzándose dentro de la región protegida.
Es fácil suponer que cada vez que la corrutina se suspende se crea un objeto nuevo. No es así: el objeto de estado se crea una sola vez, en la primera entrada de la función, y se reutiliza en todas las reanudaciones posteriores; por eso el código generado empieza comprobando si la continuación recibida ya es del tipo esperado. Una función con quince puntos de suspensión que se suspenda quince veces asigna un objeto, no quince.
El objeto es a la vez el estado y el código
Queda por unir las dos mitades. El objeto de estado hereda de una clase base de la librería estándar que implementa Continuation, de modo que ese mismo objeto es la continuación que se pasa hacia abajo, a las funciones que esta llama. Cuando una de ellas termina e invoca resumeWith sobre él, la implementación heredada llama a invokeSuspend, y ese método reentra en el cuerpo transformado, que consulta la etiqueta y continúa por el fragmento correcto.
// El mismo objeto cumple dos papeles a la vez
val estado = PerfilEstado(completion)
cargarUsuario(id, estado) // como continuacion que se pasa hacia abajo
estado.resumeWith(valor) // como receptor de la reanudacion
De ahí se sigue una imagen que conviene retener: la pila de llamadas de una corrutina no está en la pila del hilo, sino formada por objetos de estado enlazados por el campo de terminación, cada uno apuntando al de su llamante. Cuando la corrutina se suspende, esa cadena permanece en el montículo mientras el hilo se marcha a hacer otra cosa; cuando se reanuda, la ejecución vuelve a entrar por el eslabón más profundo y va desenrollando la cadena hacia arriba a medida que cada nivel termina. Es una pila de verdad, con marcos de verdad, solo que fabricada con objetos y sostenida por el recolector de basura en lugar de por el hardware.
Hay una simetría que conviene contemplar despacio, porque coloca a las corrutinas en una tradición mucho más larga de lo que su fecha de aparición sugiere. Todo el mecanismo que acabamos de leer consiste en tomar algo que el hardware hacía por nosotros de forma implícita y rehacerlo explícitamente en el montículo: la dirección de retorno se convirtió en un campo entero, el marco se convirtió en un objeto, y la cadena de llamadas se convirtió en una lista enlazada de continuaciones. Esa misma jugada es la que se hace al escribir un intérprete no recursivo, al implementar excepciones en un lenguaje que no las tiene, al construir un recolector de basura que debe recorrer estructuras profundas sin desbordar, al serializar el estado de un proceso para migrarlo a otra máquina, y al implementar generadores en cualquier lenguaje que los ofrezca. Siempre es lo mismo: cuando el control debe hacer algo que la pila del hardware no sabe hacer, se deja de usar la pila del hardware y se construye una propia. Lo que hace que la versión de Kotlin merezca estudiarse es dónde decidió parar. Existían dos caminos posibles y el otro era tentador: fabricar una pila completa y darle a cada corrutina la suya, lo que habría permitido suspender desde cualquier profundidad y desde cualquier función, sin la palabra suspend y sin contagio hacia arriba, a cambio de reservar memoria para cada corrutina viva y de necesitar soporte del entorno de ejecución. Kotlin eligió el camino contrario y decidió reificar únicamente lo indispensable: solo se transforman las funciones marcadas, solo se guardan las variables que de verdad cruzan una espera, y solo se paga por los puntos donde el programador declaró que podía cederse el control. El precio de esa elección es el contagio de la marca por la cadena de llamantes; el premio es que una corrutina en espera ocupa lo que ocupan sus variables vivas y nada más, que la transformación es enteramente estática y verificable, y que todo funciona sobre una JVM que no sabe absolutamente nada de todo esto. Cuando en la última lección se diga que caben millones de corrutinas donde caben miles de hilos, la razón no será ningún truco de planificación: será que un hilo reserva su pila entera por si acaso, y una corrutina solo guarda lo que demostró necesitar.
- Toma una función suspendida tuya con tres esperas y dibuja sus fragmentos, indicando qué valor de etiqueta corresponde a cada uno y en qué momento exacto se escribe.
- Marca en esa misma función qué variables locales cruzan al menos un punto de suspensión y justifica por qué las demás pueden permanecer en la pila.
- Escribe un bucle que contenga una espera y razona dónde acaba la variable del bucle y por qué la reentrada cae dentro del cuerpo y no en su inicio.
- Explica por qué la etiqueta debe actualizarse antes de la llamada suspendida y qué ocurriría si el compilador la actualizara después.
- Argumenta por qué un
tryalrededor de una espera funciona, apoyándote en que la reanudación reentra en el mismo método generado.