El valor centinela
Una función suspendida tiene dos desenlaces y solo un canal de retorno, y la forma de distinguirlos es un objeto singular que no significa nada más que todavia no. Esta lección examina COROUTINE_SUSPENDED: por qué es un objeto y no un nulo ni una excepción, cómo el camino rápido devuelve el resultado sin ceder el control, qué contrato exacto obliga a llamar a resumeWith una única vez, y por qué de todo esto se sigue que suspenderse en Kotlin cueste órdenes de magnitud menos que bloquear.
De las dos modificaciones que sufre la firma de una función suspendida, la del parámetro añadido se explica sola en cuanto se entiende qué es una continuación. La otra, la ampliación del tipo de retorno, tiene una explicación menos evidente y bastante más interesante, porque revela que en Kotlin la suspensión es un desenlace posible y no el desenlace inevitable. Una función marcada como suspendida no se suspende cada vez que se la llama: se suspende solo cuando de verdad no puede terminar todavía, y en todos los demás casos devuelve su resultado por el mismo camino y con el mismo coste que una función ordinaria. La distinción entre ambos casos se transmite mediante un único objeto de la librería estándar cuyo valor no importa en absoluto y cuya identidad lo es todo. Este es probablemente el detalle más pequeño de todo el nivel y es también el que explica por qué el modelo entero resulta económicamente viable.
- Explicar qué es
COROUTINE_SUSPENDEDy por qué el mecanismo exige un objeto singular en lugar de un nulo o una excepción. - Distinguir el camino rápido del camino lento en una llamada suspendida y describir qué ocurre en cada uno.
- Enunciar el contrato de reanudación y las dos formas de violarlo.
- Justificar con este mecanismo por qué el coste de suspenderse es incomparable con el de bloquear un hilo.
Un objeto que solo significa “todavía no”
El compilador necesita que una llamada suspendida pueda comunicar dos cosas distintas por un único canal de retorno: o bien aquí tienes el resultado, o bien no hay resultado y te avisaré por la continuación. Como el segundo mensaje no es un valor del tipo declarado, hace falta un centinela: un valor reservado que no pueda confundirse con ningún resultado legítimo.
// En la libreria estandar, dentro de kotlin.coroutines.intrinsics
val COROUTINE_SUSPENDED: Any
// El codigo generado lo compara siempre por identidad
val r = cargarUsuario(id, estado)
if (r === COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
Ese valor pertenece a un paquete cuyo nombre ya avisa de su naturaleza: reside entre los elementos intrínsecos de las corrutinas, la zona de la librería estándar donde vive lo que el compilador necesita y el programador no. Su tipo declarado es el más amplio posible, precisamente porque debe poder devolverse desde funciones cuyo tipo de retorno declarado es cualquiera.
Las dos alternativas obvias fallan por razones que conviene tener claras. El nulo no sirve porque una función suspendida puede devolver legítimamente nulo, y entonces el llamante no podría distinguir un resultado ausente de una cesión de control. La excepción no sirve porque la suspensión es el caso normal en cualquier código asíncrono real y las excepciones son caras y semánticamente equivocadas para señalar un curso esperado. Lo que queda es un objeto singular, comparado por identidad y no por igualdad, que no pertenece a ningún tipo del dominio y que el programador nunca ve. La propagación hacia arriba es igual de directa: si una función descubre que su llamada se suspendió, no puede continuar, así que devuelve el mismo centinela a su propio llamante, y la cadena se desmonta hasta el constructor de la corrutina liberando el hilo por completo.
El centinela es visible desde la librería estándar y desde el bytecode, pero no aparece jamás en código Kotlin ordinario. El compilador inserta las comparaciones, el compilador propaga el valor y el compilador se asegura de que ningún resultado de usuario pueda coincidir con él. Solo se manipula a mano al escribir adaptadores de bajo nivel, y ese es exactamente el terreno donde la librería expone las funciones que lo devuelven.
Los dos desenlaces de una llamada
La forma canónica de fabricar un punto de suspensión a mano es la función que entrega la continuación a un bloque y decide después qué devolver. Si el bloque produce un valor, ese valor es el resultado y no ha habido suspensión alguna. Si el bloque devuelve el centinela, la función cede el control y alguien deberá reanudarla más tarde.
suspend fun <T> ejemplo(): T = suspendCoroutineUninterceptedOrReturn { cont ->
if (hayValorYa()) valorInmediato() // camino rapido: retorna normal
else {
registrarCallback(cont) // camino lento
COROUTINE_SUSPENDED
}
}
Esa doble salida es la que permite escribir funciones suspendidas que casi nunca se suspenden. Una caché consultada por una función suspendida devuelve el valor guardado sin ceder el control en absoluto: la llamada baja por la pila, encuentra el dato, y vuelve por retorno ordinario atravesando todos los niveles intermedios sin que ninguno de ellos llegue siquiera a escribir su etiqueta de reanudación. Solo cuando el dato falta se paga el camino lento, y aun entonces el precio es una escritura de campos y un retorno, no una operación del sistema operativo.
suspend fun obtener(id: String): Usuario {
cache[id]?.let { return it } // aqui no se suspende nada
return red.cargar(id) // aqui probablemente si
}
Esta propiedad tiene un efecto de diseño que se aprecia mejor por contraste. Sin ella, quien escribe una capa de acceso a datos se ve obligado a elegir: o expone una API síncrona rápida y obliga a los llamantes a resolver por su cuenta el caso remoto, o expone una API asíncrona uniforme y hace pagar el precio del planificador incluso a quien solo quería leer un valor que ya estaba en memoria. Con el camino rápido, la misma firma sirve para ambos escenarios y la elección deja de existir, porque el coste se decide en cada llamada y no en la declaración.
La versión más usada de este mecanismo es la que además intercepta la reanudación para reenviarla al despachador correspondiente; su nombre es más corto y su semántica, la misma con una capa añadida. La variante sin interceptar que hemos mostrado es la primitiva de base, y es la que conviene tener en la cabeza al razonar sobre el bytecode.
// Version de alto nivel: siempre entrega la continuacion al bloque
suspend fun esperarValor(): Int = suspendCoroutine { cont ->
fuente.registrar { valor -> cont.resume(valor) }
}
flowchart TD
A[Llamada a una funcion suspendida] --> B{Puede terminar ahora}
B -- si --> C[Devuelve el resultado por retorno]
C --> D[El llamante continua en el mismo hilo]
B -- no --> E[Registra la continuacion y devuelve el centinela]
E --> F[Cada nivel propaga el centinela hacia arriba]
F --> G[El hilo queda libre para otra corrutina]
G --> H[Mas tarde alguien llama a resumeWith]
H --> I[La ejecucion reentra por la etiqueta guardada]El contrato que nadie comprueba por ti
De la existencia de dos desenlaces se sigue una obligación exacta, y es el único punto de todo el mecanismo donde el compilador no puede protegernos porque depende de código que se ejecuta más tarde. La regla tiene dos mitades y ambas son estrictas.
Si devuelves el centinela
Alguien tiene que llamar a resumeWith sobre esa continuación exactamente una vez, ni cero ni dos. Cero significa una corrutina que nunca despierta y una fuga silenciosa de memoria. Dos lanza un error en tiempo de ejecución.
Si devuelves un valor
No debes llamar a resumeWith en absoluto. El resultado ya viajó por retorno, y reanudar además haría avanzar la máquina de estados dos veces desde el mismo punto.
La variante cancelable
suspendCancellableCoroutine añade a lo anterior la posibilidad de registrar qué hacer si la espera se aborta, y es la forma correcta de envolver cualquier API que ofrezca cancelación propia.
La primera mitad del contrato tiene una consecuencia que conviene subrayar porque produce fallos mudos. Una continuación que nunca se reanuda no lanza nada, no imprime nada y no aparece en ningún registro: simplemente la corrutina se queda para siempre en el estado en que estaba, con todas sus variables vivas retenidas en el montículo y con su lugar ocupado en la jerarquía de su alcance. Desde fuera se percibe como una operación que no termina nunca, y desde el perfilador, como memoria que no se libera. El caso típico es un adaptador con un camino de error olvidado.
// Defectuoso: si la API responde con fallo, nadie reanuda jamas
suspend fun cargar(id: String): Usuario =
suspendCancellableCoroutine { cont ->
api.cargar(id, alTerminar = { cont.resume(it) })
// falta el camino de error: la corrutina queda colgada
}
Ese contrato es la razón de que envolver una API de callbacks tenga una forma tan concreta y de que convenga escribirla siempre igual. La continuación se entrega al callback, el callback la reanuda con el valor o con la excepción, y el bloque devuelve el centinela porque el resultado no puede estar disponible todavía. Es el mismo estilo de paso de continuaciones de la primera lección, con la diferencia de que ahora está confinado a un adaptador de cinco líneas en lugar de contaminar todo el programa.
suspend fun cargarUsuario(id: String): Usuario =
suspendCancellableCoroutine { cont ->
val peticion = api.cargar(id,
alTerminar = { cont.resume(it) },
alFallar = { cont.resumeWithException(it) },
)
cont.invokeOnCancellation { peticion.abortar() }
}
Reanudar una continuación dos veces lanza una excepción en tiempo de ejecución, y es un fallo especialmente difícil de diagnosticar porque suele venir de una API subyacente que invoca su callback más de una vez en circunstancias raras. Al escribir un adaptador, la pregunta que hay que hacerle a la API que se envuelve no es qué devuelve, sino cuántas veces puede llamar a su callback: si la respuesta es más de una, lo que se está envolviendo no es una función suspendida sino un flujo, y forzarlo a esta forma producirá caídas intermitentes.
Por qué esto sale barato
Con el mecanismo completo delante, el coste real de suspenderse se puede enumerar sin misticismo. Escribir un entero en un campo, escribir las variables vivas en otros campos, devolver una referencia constante y propagarla por unos pocos marcos de pila que se destruyen normalmente. No hay llamada al sistema, no hay cambio de contexto, no hay bloqueo, no hay planificador del sistema operativo interviniendo y no hay ninguna estructura reservada por si acaso.
Compárese con la alternativa que reemplaza. Bloquear un hilo obliga al sistema operativo a sacarlo de ejecución, guardar sus registros, elegir otro hilo, restaurar los suyos y devolverle el procesador, y el hilo bloqueado sigue reteniendo mientras tanto su pila entera. La diferencia entre ambas operaciones no es de porcentaje sino de orden de magnitud, y explica sin más argumentos por qué el mismo hardware puede sostener una cantidad de corrutinas en espera que sería absurda en hilos.
Y cuando el camino tomado es el rápido, ni siquiera hay eso. La llamada baja, encuentra el dato, vuelve por retorno y el llamante continúa en la instrucción siguiente sin haber tocado la etiqueta, sin haber guardado ninguna variable en el objeto de estado y sin que ningún despachador se entere de que hubo una operación potencialmente asíncrona. En ese caso, el coste añadido respecto a una función ordinaria se reduce a un parámetro más en la llamada y a una comparación de identidad, y es una diferencia que ninguna medición razonable distingue del ruido.
Conviene no pasarse al extremo contrario. Una función suspendida con puntos de suspensión asigna su objeto de estado al entrar, aunque después tome el camino rápido en todas sus llamadas, y ese objeto es memoria real que el recolector acabará visitando. Existen optimizaciones, como la omisión de la máquina de estados cuando el cuerpo es una única llamada suspendida en posición final, pero el criterio sano es el habitual: la suspensión es órdenes de magnitud más barata que el bloqueo, no es más barata que no hacer nada, y una función marcada suspend que nunca cede el control es una función ordinaria con impuesto.
Hay una asimetría en el diseño de las corrutinas de Kotlin que rara vez se enuncia y que sostiene todo lo demás: la palabra suspend no promete que la función vaya a suspenderse, solo declara que podría. Esa diferencia entre lo posible y lo efectivo parece un matiz lógico y es en realidad la decisión económica central del modelo. Un diseño alternativo perfectamente razonable habría hecho que toda llamada suspendida cediera el control y volviera a través de una cola, lo que hubiera simplificado enormemente el compilador y hubiera dado garantías de equidad muy limpias, a cambio de convertir cada llamada en un viaje por el planificador. Con ese diseño, una caché consultada mediante una función suspendida costaría lo mismo que una petición de red, y el programador se vería obligado a mantener dos versiones de cada API, una barata y síncrona y otra cara y asíncrona, que es exactamente la fragmentación que las corrutinas venían a eliminar. Kotlin se negó a esa fragmentación, y el precio que pagó por negarse es este objeto centinela y las comparaciones de identidad que el compilador siembra por todo el código generado. A cambio obtuvo algo que va mucho más allá del rendimiento: obtuvo que una firma suspendida sea una descripción de capacidad en lugar de una descripción de coste. Cuando una función declara suspend, está diciendo que en algún escenario podría necesitar esperar, no que esperar sea lo que hace; y por eso puede escribirse una única API que sirva igual de bien cuando el dato está en memoria, cuando está en disco y cuando está al otro lado del mundo, sin que el llamante tenga que elegir entre variantes ni el diseñador tenga que anticipar el escenario. Ahí está la razón profunda de que el modelo permita construir abstracciones que atraviesan capas enteras sin que cada capa tenga que decidir si es rápida o lenta: la suspensión es una posibilidad declarada en el tipo y resuelta en cada llamada, y el camino rápido no es una optimización añadida después sino la mitad del contrato desde el primer día.
- Escribe una función suspendida que consulte una caché y solo vaya a la red si falla. Razona qué ocurre en la máquina de estados en cada uno de los dos casos.
- Explica por qué el centinela se compara por identidad y no por igualdad, y qué fallaría si un tipo del usuario pudiera considerarse igual a él.
- Argumenta por qué el nulo y la excepción son señales inadecuadas para comunicar que una llamada cedió el control.
- Envuelve con
suspendCancellableCoroutineuna API tuya basada en callbacks y comprueba que tu adaptador llama aresumeWithexactamente una vez en todos los caminos posibles, incluido el de error. - Enumera paso a paso lo que hace la máquina al suspenderse y lo que hace el sistema operativo al bloquear un hilo, y justifica de dónde sale la diferencia de magnitud.