AA2.1 Corrutinas estructuradas y cancelación¶
Qué se ha ejecutado y qué no
Los ejemplos con salida se han compilado y ejecutado de verdad al construir esta web (Kotlin con kotlinx-coroutines 1.11.0). Los fragmentos de ViewModel y de Compose marcados como «sin ejecutar» necesitan Android.
Concurrencia estructurada¶
La idea que lo ordena todo: cada corrutina tiene un padre y vive dentro de un ámbito (scope). Eso da tres garantías:
- El padre no termina hasta que acaban sus hijos.
- Cancelar al padre cancela a todos los hijos.
- Si un hijo falla, el fallo sube al padre (que cancela a los demás hijos).
En Android el ámbito lo pone la plataforma: viewModelScope (se cancela cuando el ViewModel desaparece) y lifecycleScope (cuando se destruye la pantalla). Así no queda trabajo huérfano cuando el usuario se va.
fun main() = runBlocking {
val padre = launch {
launch {
try { delay(1000); println("hijo 1 termina") }
finally { println("hijo 1: limpieza") }
}
launch {
try { delay(1000); println("hijo 2 termina") }
finally { println("hijo 2: limpieza") }
}
}
delay(100)
padre.cancelAndJoin() // cancelamos SOLO al padre
println("¿padre cancelado? ${padre.isCancelled}")
}
Salida:
hijo 1: limpieza
hijo 2: limpieza
¿padre cancelado? true
Nadie cancela a los hijos directamente, pero los dos pasan por su finally y ninguno llega a terminar. Esto es lo que ocurre cuando el usuario sale de una pantalla en mitad de una descarga: el trabajo se corta solo.
No uses GlobalScope
GlobalScope.launch { ... } crea una corrutina sin padre: nada la cancela y puede seguir viva y gastando batería cuando ya no hace falta. En Android se usa siempre el ámbito de la pantalla (viewModelScope, lifecycleScope) o uno propio que tú cancelas.
launch y async¶
| Qué devuelve | Para qué | |
|---|---|---|
launch |
Un Job (sin resultado) |
Lanzar y olvidar: guardar, registrar, actualizar el estado |
async |
Un Deferred<T> (un resultado futuro) |
Lanzar varias cosas a la vez y recoger los resultados con await() |
La diferencia práctica de usar async es el tiempo. Dos peticiones que tardan 300 ms cada una:
suspend fun pedirPerfil(): String { delay(300); return "perfil de Ana" }
suspend fun pedirAmigos(): List<String> { delay(300); return listOf("Luis", "Marta") }
fun main() = runBlocking {
val inicio = System.currentTimeMillis()
val perfil = pedirPerfil()
val amigos = pedirAmigos()
val secuencial = System.currentTimeMillis() - inicio
println("secuencial: $perfil, $amigos")
val inicio2 = System.currentTimeMillis()
val p = async { pedirPerfil() }
val a = async { pedirAmigos() }
println("en paralelo: ${p.await()}, ${a.await()}")
val paralelo = System.currentTimeMillis() - inicio2
println("¿secuencial ≥ 600 ms? ${secuencial >= 600}")
println("¿paralelo < 500 ms? ${paralelo < 500}")
}
Salida:
secuencial: perfil de Ana, [Luis, Marta]
en paralelo: perfil de Ana, [Luis, Marta]
¿secuencial ≥ 600 ms? true
¿paralelo < 500 ms? true
Secuencial suma los dos tiempos; en paralelo el total es lo que tarda la más lenta. (Se imprime una comparación y no los milisegundos exactos, porque cambian en cada ejecución.)
Cuando algo falla¶
Aquí está la parte que más sorprende. Por defecto, el fallo de un hijo cancela a sus hermanos y sube al padre:
fun main() = runBlocking {
val resultado = runCatching {
coroutineScope {
launch {
delay(100)
throw IllegalStateException("falló A")
}
launch {
try { delay(500); println("B termina") }
catch (e: CancellationException) { println("B cancelado porque A falló"); throw e }
}
}
}
println("coroutineScope -> ${resultado.exceptionOrNull()?.message}")
}
Salida:
B cancelado porque A falló
coroutineScope -> falló A
Muchas veces eso es lo que quieres (si fallan los datos del perfil, no tiene sentido seguir con los amigos). Pero a veces los hijos son independientes: una pantalla con tres tarjetas que cargan por separado no debería quedarse sin las otras dos porque una falle. Para eso existe supervisorScope: el fallo de un hijo no cancela a los demás.
fun main() = runBlocking {
val manejador = CoroutineExceptionHandler { _, e -> println("manejador recibe: ${e.message}") }
supervisorScope {
launch(manejador) {
delay(100)
throw IllegalStateException("falló A")
}
launch {
delay(300)
println("B termina con normalidad")
}
}
println("el ámbito terminó")
}
Salida:
manejador recibe: falló A
B termina con normalidad
el ámbito terminó
| Ámbito | Si un hijo falla | Cuándo |
|---|---|---|
coroutineScope |
Cancela a los hermanos y relanza la excepción | Las partes forman un todo |
supervisorScope |
Los hermanos siguen | Las partes son independientes |
Dónde se captura el error
Con launch una excepción no capturada va al CoroutineExceptionHandler (o cierra la app). Con async la excepción se guarda y salta al llamar a await(): ahí es donde hay que poner el try/catch. Un try/catch alrededor de launch { ... } no sirve: el error ocurre más tarde, dentro de la corrutina.
Cancelar es cooperar¶
Cancelar una corrutina no la detiene a la fuerza: le pide que pare, y ella tiene que mirar si se lo han pedido. Todas las funciones de suspensión de kotlinx.coroutines (delay, withContext...) lo miran solas. Un bucle de cálculo que no suspende nunca, no:
fun main() = runBlocking {
// Esta corrutina se cancela a sí misma en la iteración 2, pero NO comprueba la cancelación
val sinComprobar = launch(Dispatchers.Default) {
for (i in 1..5) {
if (i == 2) coroutineContext.job.cancel()
println("sin comprobar: iteración $i")
}
}
sinComprobar.join()
// Esta sí comprueba antes de cada vuelta
val comprobando = launch(Dispatchers.Default) {
for (i in 1..5) {
ensureActive()
if (i == 2) coroutineContext.job.cancel()
println("comprobando: iteración $i")
}
}
comprobando.join()
}
Salida:
sin comprobar: iteración 1
sin comprobar: iteración 2
sin comprobar: iteración 3
sin comprobar: iteración 4
sin comprobar: iteración 5
comprobando: iteración 1
comprobando: iteración 2
La primera hace las cinco vueltas aunque estuviera cancelada desde la segunda. La segunda se detiene al llegar a la siguiente comprobación. En código real, la cancelación llega de fuera, pero el efecto es idéntico. Tienes tres formas de comprobarla en un bucle largo:
| Forma | Qué hace |
|---|---|
ensureActive() |
Lanza CancellationException si ya está cancelada |
isActive |
Una propiedad Boolean para usar en while (isActive) |
yield() |
Cede el turno y comprueba la cancelación |
No te «comas» la cancelación¶
La cancelación es una excepción (CancellationException). Si la capturas con un catch (e: Exception) genérico y no la vuelves a lanzar, la corrutina cree que sigue viva:
fun main() = runBlocking {
val trabajo = launch {
try {
delay(1000)
} catch (e: Exception) { // ¡captura también la cancelación!
println("capturé: ${e::class.simpleName}")
}
println("sigo vivo, pero ¿cancelado? ${coroutineContext.job.isCancelled}")
}
delay(100)
trabajo.cancelAndJoin()
}
Salida:
capturé: JobCancellationException
sigo vivo, pero ¿cancelado? true
Salió del delay por la cancelación, se la tragó y siguió ejecutando código de un trabajo ya cancelado (cualquier siguiente delay fallaría de inmediato). La regla: o no capturas Exception, o capturas las concretas que esperas (IOException...), o vuelves a lanzar la CancellationException:
try { /* ... */ }
catch (e: CancellationException) { throw e } // siempre se relanza
catch (e: Exception) { /* tu manejo del error */ }
Limpiar después de cancelar: NonCancellable¶
Dentro de un finally de una corrutina ya cancelada, cualquier delay o llamada de suspensión falla al instante. Si necesitas una limpieza que suspenda (cerrar un archivo, avisar al servidor), se protege con NonCancellable. Y para poner límite de tiempo a algo, withTimeoutOrNull:
fun main() = runBlocking {
val resultado = withTimeoutOrNull(200) {
try {
delay(1000)
"terminó"
} finally {
withContext(NonCancellable) { // aquí sí se puede suspender
delay(10)
println("limpieza completa")
}
}
}
println("resultado: $resultado")
}
Salida:
limpieza completa
resultado: null
Hilos: withContext y la regla de la «seguridad en el hilo principal»¶
La interfaz solo se puede tocar desde el hilo principal, y ese hilo no puede bloquearse (si pasa de unos segundos, Android muestra el famoso «la app no responde»). Por eso el trabajo pesado se manda a otro dispatcher:
| Dispatcher | Para qué |
|---|---|
Dispatchers.Main |
Interfaz y estado. Es el de viewModelScope |
Dispatchers.IO |
Red, disco, base de datos (esperas largas) |
Dispatchers.Default |
Cálculo pesado (ordenar, procesar imágenes) |
La buena práctica: una función suspend debe ser segura para llamarla desde el hilo principal, y si hace trabajo pesado, es ella quien cambia de dispatcher con withContext, no quien la llama:
suspend fun leerDelDisco(): String = withContext(Dispatchers.IO) {
println(" dentro de leerDelDisco: ¿en un hilo de IO? ${Thread.currentThread().name.startsWith("DefaultDispatcher")}")
delay(50)
"contenido"
}
fun main() = runBlocking {
println("antes: ¿en el hilo principal? ${Thread.currentThread().name == "main"}")
val texto = leerDelDisco()
println("después: ¿vuelta al hilo principal? ${Thread.currentThread().name == "main"} · $texto")
}
Salida:
antes: ¿en el hilo principal? true
dentro de leerDelDisco: ¿en un hilo de IO? true
después: ¿vuelta al hilo principal? true · contenido
La función cambia de hilo, hace su trabajo y devuelve la ejecución al hilo de quien la llamó. (En este ejemplo el «hilo principal» es el de runBlocking, llamado main; en Android es el hilo de la interfaz.)
En un ViewModel (sin ejecutar)¶
// Sin ejecutar: necesita Android
class PerfilViewModel(private val repositorio: PerfilRepositorio) : ViewModel() {
private val _estado = MutableStateFlow<PerfilUi>(PerfilUi.Cargando)
val estado: StateFlow<PerfilUi> = _estado.asStateFlow()
private var trabajoCarga: Job? = null
fun cargar(id: Int) {
trabajoCarga?.cancel() // si ya había una carga en marcha, la cancelo
trabajoCarga = viewModelScope.launch {
_estado.value = PerfilUi.Cargando
try {
val (perfil, amigos) = coroutineScope {
val p = async { repositorio.perfil(id) }
val a = async { repositorio.amigos(id) }
p.await() to a.await()
}
_estado.value = PerfilUi.Datos(perfil, amigos)
} catch (e: CancellationException) {
throw e // nunca se traga
} catch (e: Exception) {
_estado.value = PerfilUi.Error(e.message ?: "Error desconocido")
}
}
}
}
Reúne casi todo lo de esta página: ámbito del ViewModel, cancelar la carga anterior si el usuario pide otra, dos peticiones en paralelo, y el error convertido en estado (visto en la AA1).
Errores frecuentes¶
| Error | Cómo evitarlo |
|---|---|
GlobalScope.launch |
viewModelScope / lifecycleScope |
catch (e: Exception) sin relanzar la cancelación |
Captura excepciones concretas o relanza CancellationException |
try/catch alrededor de launch |
El try/catch va dentro de la corrutina (o en await()) |
| Peticiones en secuencia que no dependen entre sí | async + await() |
| Bucle de cálculo sin puntos de suspensión | ensureActive() / isActive / yield() |
Llamar a Thread.sleep en una corrutina |
delay (no bloquea el hilo) |
| Lanzar una carga nueva sin cancelar la anterior | Guarda el Job y cancélalo antes |
Para practicar¶
Los ejercicios AA2.1 a AA2.3 de AA2 · Ejercicios practican async, la cancelación cooperativa y supervisorScope.