suspend: pausar sin bloquear el hilo
Todo el nivel anterior descansó sobre una palabra que dimos por sabida: intent es una corrutina y dentro de ella puedes suspender. Esta lección abre esa caja. Define con precisión la diferencia entre bloquear un hilo y suspender una corrutina, muestra que una función marcada con suspend es solo una función que puede devolver el control sin haber terminado, y reconstruye lo que el compilador de Kotlin escribe por debajo: la transformación a estilo de paso de continuaciones, el parámetro oculto de tipo Continuation que toda función suspendida recibe, la máquina de estados con su etiqueta de reanudación, el valor centinela COROUTINE_SUSPENDED y el método invokeSuspend que se vuelve a invocar cada vez que llega un resultado. Termina desmontando la confusión más frecuente del principiante: suspend no lanza nada en paralelo, solo describe un punto donde el hilo puede irse a hacer otra cosa.
Llevas un nivel entero apoyándote en una afirmación que aceptaste sin diseccionar: intent es una corrutina, y dentro de ella puedes esperar al repositorio sin congelar la pantalla. La frase es cierta, pero mientras siga siendo un conjuro no podrás razonar sobre lo que ocurre cuando algo va mal. Este nivel desmonta la maquinaria, y el desmontaje empieza por la pieza más pequeña y menos entendida de todas: la palabra suspend. No es una anotación mágica ni una promesa de paralelismo; es una marca que autoriza al compilador a reescribir tu función entera como una máquina de estados capaz de devolver el control a mitad de camino y retomarlo después, quizá en otro hilo, sin haber inmovilizado a nadie mientras tanto.
- Distinguir con precisión bloquear un hilo de suspender una corrutina, y saber qué recurso se libera en cada caso.
- Leer una función
suspendcomo lo que es: una función que puede retornar sin haber producido su resultado. - Reconstruir la transformación del compilador: el parámetro
Continuation, la máquina de estados y el centinela de suspensión. - Desactivar la confusión de que
suspendimplique concurrencia, y saber qué la introduce de verdad.
Bloquear inmoviliza, suspender devuelve
Un hilo del sistema operativo es un recurso caro y escaso: tiene su propia pila de memoria, del orden de un megabyte, y el planificador del núcleo debe turnarlo con todos los demás. Cuando llamas a una función bloqueante —leer un fichero, esperar una respuesta de red con una API síncrona, dormir con Thread.sleep— ese hilo queda inmovilizado. No ejecuta nada, no cede su pila, no sirve para otra tarea: simplemente está ahí, ocupado en no hacer nada, hasta que el resultado llega. Si el hilo inmovilizado es el principal de una app Android, la interfaz deja de repintarse y el usuario ve un congelamiento.
Suspender es la operación contraria en un sentido muy concreto. Cuando una corrutina alcanza un punto de suspensión y el resultado todavía no está disponible, la función retorna: cede el hilo, que queda libre para ejecutar cualquier otra cosa, y deja anotado en el montón un objeto con el punto exacto donde se quedó y las variables locales que necesitará al volver. Cuando el resultado aparece, alguien reanuda esa corrutina y la ejecución continúa justo después del punto donde se detuvo, sobre el hilo que en ese momento corresponda. La espera dejó de costar un hilo y pasó a costar un objeto.
// Bloqueante: el hilo que ejecuta esto queda inmovilizado dos segundos
fun cargarBloqueando(id: String): Perfil {
Thread.sleep(2000) // el hilo no puede hacer nada mas
return api.perfilSincrono(id)
}
// Suspendible: en el punto de suspension el hilo se va y vuelve luego
suspend fun cargar(id: String): Perfil {
delay(2000) // suspende: el hilo queda libre
return api.perfil(id) // otra funcion suspend
}
Las dos funciones tardan lo mismo en producir su resultado; la diferencia no está en la latencia sino en el coste de esperar. Con la primera, dos segundos de espera cuestan un hilo entero. Con la segunda cuestan un objeto de continuación en el montón, y el hilo que la ejecutaba puede atender miles de corrutinas más mientras tanto. Por eso una app con corrutinas puede tener decenas de miles de operaciones en vuelo sobre un puñado de hilos, algo sencillamente imposible con un modelo de un hilo por tarea.
Este es el malentendido que más código lento ha producido. Si dentro de una función suspend escribes una llamada bloqueante —un Thread.sleep, una lectura de fichero síncrona, un cliente HTTP antiguo—, el hilo se bloquea exactamente igual que antes: la palabra clave no convierte nada, solo autoriza a que existan puntos de suspensión dentro. Una función es de verdad no bloqueante cuando todo lo que hay en su camino también lo es. Cuando tengas que llamar a código bloqueante heredado, no lo disfraces: aíslalo con withContext en un dispatcher preparado para absorber bloqueos, que es justo el tema de la tercera lección de este nivel.
Lo que el compilador escribe por debajo
La JVM no sabe qué es una corrutina. No hay una instrucción de máquina para pausar un método a mitad y retomarlo después, así que la suspensión no puede ser una capacidad de la plataforma: tiene que ser una reescritura del código. El compilador de Kotlin aplica una transformación clásica en teoría de lenguajes, el estilo de paso de continuaciones, y el efecto es sorprendentemente concreto: toda función suspend recibe un parámetro extra que tú nunca escribes.
// Lo que escribes
suspend fun cargar(id: String): Perfil
// Lo que el compilador genera, en esencia
fun cargar(id: String, cont: Continuation<Perfil>): Any?
Esa Continuation es el resto del cómputo cosificado: un objeto con un método resumeWith al que se le entrega el resultado —o la excepción— para que la ejecución siga. Repara además en el tipo de retorno, que ya no es Perfil sino Any?. El motivo es que la función tiene ahora dos finales posibles: devolver el valor de verdad, si estaba disponible sin esperar, o devolver un objeto centinela llamado COROUTINE_SUSPENDED que significa me voy, ya te avisaré. Quien la llamó comprueba ese valor: si es el centinela, retorna a su vez, y la retirada se propaga hacia arriba hasta que el hilo queda libre.
Falta la segunda mitad. Al volver, la ejecución debe continuar en el punto exacto donde se detuvo, así que el compilador convierte el cuerpo entero en una máquina de estados: parte la función por cada punto de suspensión, numera los trozos, guarda ese número en un campo etiqueta dentro de la propia continuación, y mete todo el cuerpo en un método invokeSuspend que empieza con un salto según la etiqueta. Las variables locales que sobreviven a una suspensión dejan de vivir en la pila y pasan a ser campos de ese objeto, porque la pila desaparece cuando la función retorna.
flowchart TD E0[Etiqueta 0 primera entrada] --> A[llama a repo.perfil] A -->|devuelve COROUTINE SUSPENDED| L[La funcion retorna y el hilo queda libre] L --> R[Llega el resultado y alguien invoca resumeWith] R --> I[invokeSuspend salta segun la etiqueta] I --> E1[Etiqueta 1 continua tras el primer punto] E1 --> B[llama a repo.posts] B --> E2[Etiqueta 2 combina y devuelve el valor real] style L fill:#a6e3a1,color:#11111b style E2 fill:#cba6f7,color:#11111b
Tres consecuencias prácticas caen de aquí, y conviene fijarlas porque explican casi todo lo raro que verás después. La primera: una función suspend puede ser invocada muchas veces sobre el mismo objeto de continuación, una por cada reanudación, y por eso el mismo cuerpo se recorre a saltos. La segunda: no hay garantía alguna de que la reanudación ocurra en el hilo donde empezaste, porque quien llama a resumeWith es quien tenga el resultado; que la interfaz vuelva al hilo correcto es trabajo del dispatcher, no de suspend. La tercera: si el resultado ya está disponible, no hay suspensión ni objeto ni cambio de hilo, y la llamada cuesta prácticamente lo mismo que una función normal.
La restricción que Kotlin te impone —una función suspend solo puede invocarse desde otra función suspend o desde un constructor de corrutinas— deja de parecer arbitraria cuando ves la firma generada. Llamarla exige pasarle una Continuation, y quien no está dentro de una corrutina sencillamente no tiene ninguna que entregar. No es una regla de estilo sobre disciplina asíncrona: es una consecuencia mecánica de la reescritura. De ahí que el intent de Orbit sea el sitio donde por fin puedes suspender, porque es un constructor de corrutinas y por tanto el lugar donde nace la primera continuación.
La continuación en tus manos
Si la continuación es un objeto ordinario, nada impide que la toques tú. Kotlin expone esa puerta con suspendCancellableCoroutine, la función que convierte cualquier API de devolución de llamada en una función suspend de aspecto secuencial. Recibe un bloque al que se le entrega la continuación viva y se compromete a suspender hasta que alguien la reanude.
suspend fun localizacionActual(): Localizacion =
suspendCancellableCoroutine { cont ->
val callback = object : LocationCallback {
override fun onOk(loc: Localizacion) = cont.resume(loc)
override fun onError(e: Throwable) = cont.resumeWithException(e)
}
gestor.pedirUbicacion(callback)
cont.invokeOnCancellation { gestor.cancelar(callback) }
}
Lee lo que ocurre aquí, porque es el mecanismo de la lección funcionando a la vista. La función suspende, el hilo queda libre y la continuación se guarda dentro del objeto de devolución de llamada; cuando el sistema de localización responde, resume la reanuda con el valor y la ejecución continúa donde estaba. Si la respuesta es un error, resumeWithException la reanuda con la excepción, que emergerá en el punto de llamada como si la hubiera lanzado una función normal. Y el invokeOnCancellation cierra el círculo con la cuarta lección: es el gancho para devolver el recurso si la corrutina se cancela mientras esperaba.
Dos advertencias, ambas mecánicas. Una continuación se reanuda exactamente una vez: llamarla dos veces es un error, y las APIs de devolución de llamada que emiten varios resultados no encajan aquí sino en un Flow construido con callbackFlow. Y si no la reanudas nunca, la corrutina queda suspendida para siempre, esperando una llamada que no llegará. Ese es el precio de tener la continuación en la mano: recuperas el control, y con él la obligación de completar el protocolo.
Suspender no es lanzar
Queda por desactivar la confusión más extendida. Marcar una función con suspend no la ejecuta en segundo plano, no crea un hilo, no la vuelve paralela y no acelera nada por sí sola. Dos llamadas suspend seguidas se ejecutan estrictamente en orden: la segunda no empieza hasta que la primera ha producido su resultado. suspend describe cómo se espera, no cuántas cosas ocurren a la vez.
// Secuencial: 300 ms + 400 ms = 700 ms
suspend fun perfilCompleto(id: String): Vista {
val datos = repo.perfil(id) // 300 ms
val posts = repo.posts(id) // 400 ms, empieza cuando termina el anterior
return Vista(datos, posts)
}
// Concurrente: async lanza, await espera. Total 400 ms
suspend fun perfilRapido(id: String): Vista = coroutineScope {
val datos = async { repo.perfil(id) }
val posts = async { repo.posts(id) }
Vista(datos.await(), posts.await())
}
La concurrencia no la introduce la palabra clave sino el constructor: launch dispara trabajo y no devuelve resultado, async dispara trabajo y devuelve una promesa que se recoge con await. Ambos exigen un ámbito, y ese ámbito es el tema de la siguiente lección. Fíjate en que la versión secuencial no es un error: cuando el segundo dato depende del primero, es exactamente lo que quieres, y encadenar async sin necesidad solo añade objetos y ruido.
Bloquear
Inmoviliza un hilo del sistema con toda su pila. El coste de esperar se mide en hilos, y los hilos son caros y escasos.
Suspender
Retorna cediendo el hilo y guarda el punto de retorno en un objeto del montón. El coste de esperar se mide en asignaciones.
Lanzar
launch y async son lo que introduce concurrencia. Sin ellos, dos llamadas suspend seguidas son estrictamente secuenciales.
Trata la palabra clave como parte del contrato público, no como un detalle de implementación. Que una firma diga suspend avisa a quien la usa de que ahí dentro puede haber espera real y de que el punto de llamada es cancelable. Por eso conviene marcar como suspend lo que de verdad puede tardar y dejar sin marcar lo que solo calcula: una función que nunca suspende y aun así lo declara obliga a sus llamantes a vivir dentro de una corrutina sin ninguna contrapartida, y contagia el color asíncrono hacia arriba sin motivo.
Aquí está la idea que hace que este nivel entero merezca la pena, y no es sobre Kotlin sino sobre computación. En el modelo clásico, esperar es un estado del hilo: la información sobre dónde se quedó tu programa vive en la pila de llamadas, esa estructura que el procesador entiende pero que tu código no puede tocar, copiar ni almacenar. Como la pila es intocable, la única forma de recordar dónde ibas es no soltarla, y de ahí que esperar cueste un hilo entero: estás pagando un megabyte de memoria y una entrada en el planificador del núcleo por el privilegio de que alguien recuerde tu posición. La transformación a continuaciones ataca exactamente ese punto. Al reescribir la función como una máquina de estados con etiqueta y campos, el compilador saca el punto de retorno de la pila y lo pone en el montón, y en ese gesto ocurre algo que parece contable pero es filosófico: el resto de tu programa se vuelve un objeto ordinario. Un valor. Algo que se puede pasar como argumento, capturar en una estructura, encolar, reanudar más tarde, reanudar desde otro hilo o —y esto es decisivo— no reanudar nunca. La cancelación de la cuarta lección no es una función mágica que interrumpe código en marcha; es la decisión de no volver a llamar a una continuación que estaba esperando. La propagación de errores de la quinta no es una pila que se desenrolla; es reanudar una continuación con una excepción en vez de con un valor. Los dispatchers de la tercera son solo la política de en qué hilo se ejecuta esa reanudación. Fíjate en el patrón, porque se repite en toda la historia de los lenguajes: cada vez que algo implícito y privilegiado del entorno de ejecución se convierte en un valor de primera clase, aparece un abanico entero de operaciones nuevas que antes ni siquiera se podían formular. Ocurrió cuando las funciones se volvieron valores y nacieron el orden superior y los cierres; ocurre aquí con la continuación. Por eso suspend no es azúcar sintáctico sobre callbacks: los callbacks también cosifican el resto del cómputo, pero te obligan a escribirlo a mano, invertidos y anidados, con el flujo de control despedazado. El compilador hace la cosificación por ti y te devuelve el código secuencial que sabías leer. Ganas el poder de manipular la espera sin perder la forma de la prosa, y esa combinación es la razón por la que todo lo demás del nivel encaja sin esfuerzo.
- Escribe dos funciones que esperen un segundo, una con
Thread.sleepy otra condelay, y lanza cien mil de cada una. Anota cuál termina y cuál agota la memoria, y explica el resultado en términos de pila frente a montón. - Toma una función
suspendtuya con dos puntos de suspensión y dibuja a mano su máquina de estados: cuántas etiquetas tiene, qué variables locales deben promoverse a campos y por qué. - Explica con tus palabras qué significa que una función
suspenddevuelvaCOROUTINE_SUSPENDEDen lugar de su valor, y qué hace el llamante al recibirlo. - Convierte la versión secuencial de
perfilCompletoen la concurrente y mide ambas. Después argumenta un caso concreto en el que la secuencial sea la correcta y usarasyncfuera un error. - Busca en tu código una función marcada
suspendque en realidad llame a una API bloqueante. Explica qué le ocurre al hilo cuando se ejecuta y esboza cómo la aislarías.