Alcance, Job y cancelación
Al terminar: Entender el alcance, el Job y la cancelación cooperativa de una corrutina.
Al terminar esta lección, Marcador tendrá un cronómetro que cuenta los minutos mientras se juega el partido y se apaga solo al pitar el final. Y habrás visto en la consola por qué cancelar no siempre detiene el trabajo.
Es el tema donde más se falla en las entrevistas y donde más apps pierden memoria o gastan batería: una corrutina que nadie cancela sigue viva cuando ya no hace falta. Temas: CoroutineScope, concurrencia estructurada, Job, cancelación cooperativa y CancellationException. La lección siguiente, Un alcance propio, crea alcances con dueño y explica por qué no GlobalScope (un alcance global, sin dueño, que nadie cancela).
Toda corrutina vive dentro de un alcance
Un alcance (CoroutineScope) es el lugar donde viven las corrutinas: launch y async son funciones suyas y no existen fuera de uno. Cada corrutina lanzada dentro es hija del alcance, y el alcance no se da por terminado hasta que terminan todas sus hijas. Eso es la concurrencia estructurada (structured concurrency), con tres consecuencias:
| Si… | Entonces… |
|---|---|
| El alcance se cancela | se cancelan todas sus hijas |
| Una hija falla | el fallo sube al padre y cancela a las hermanas (cómo evitarlo, en Errores en corrutinas) |
| El bloque acaba | espera a sus hijas antes de continuar |
Un Job es el manejador de una corrutina o de un alcance: dice si está activa (isActive, que vale true mientras no la hayan cancelado ni haya terminado), y se puede cancelar (cancel()) o esperar (join()). Dentro de un launch { }, isActive se escribe a secas porque el bloque tiene un this que es el alcance de la corrutina (como en una función de extensión: this es el objeto sobre el que se llama), y isActive es una propiedad suya. Cancelar un Job lo pasa al estado «cancelándose» (cancelling), y la cancelación baja a sus hijos.
Un ejemplo para el Kotlin Playground: una corrutina que cuenta un «tic» cada 100 ms hasta que la cancelan.
import kotlinx.coroutines.*
fun main() = runBlocking { // runBlocking es el alcance: todo lo de dentro cuelga de él
val trabajo = launch { // hija del alcance de runBlocking; trabajo es su Job
while (isActive) { // «¿me han cancelado?»: true mientras siga viva
delay(100) // espera 100 ms sin bloquear el hilo
println("tic")
}
}
delay(350) // el padre espera 350 ms: la hija da 3 tics
trabajo.cancel() // marca el Job como cancelado
trabajo.join() // espera a que acabe de verdad
println("isCancelled = ${trabajo.isCancelled}") // true: la cancelaron
}Al ejecutarlo, imprime:
tic
tic
tic
isCancelled = trueLos tres tic son los de los 100, 200 y 300 ms; a los 350 ms llega el cancel().
cancelAndJoin() es exactamente cancel() seguido de join(): cancela y espera a que la corrutina acabe de verdad. Se usa mucho en lo que sigue.
La cancelación es cooperativa
cancel() no interrumpe nada: solo marca el Job. La corrutina se entera cuando llega a un punto que mira la marca. Las funciones suspendidas de la librería (delay, await, join…) lo hacen solas y, si la marca está puesta, lanzan una CancellationException. Un bucle que solo calcula, sin suspender nunca, no mira nada: hay que mirar a mano con isActive (devuelve true o false) o con ensureActive() (lanza la CancellationException si la corrutina está cancelada).
Un ejemplo de cada lado, para que lo veas. Es un bucle infinito que solo suma y que mira la marca cada 1.000 vueltas (suma % 1_000 == 0 es cierto solo en las vueltas 1.000, 2.000, 3.000…; el _ es el separador de miles que ya conoces):
import kotlinx.coroutines.*
fun main() = runBlocking {
// Dispatchers.Default: un grupo de hilos aparte (se ve en la lección Dispatchers). Con
// launch(Dispatchers.Default) la corrutina corre en OTRO hilo, y así este hilo queda
// libre para cancelarla (el porqué, en la lección siguiente)
val pesado = launch(Dispatchers.Default) {
var suma = 0
while (true) { // infinito a propósito
suma++
if (suma % 1_000 == 0) ensureActive() // cada mil vueltas: ¿me cancelaron? Si sí, lanza
}
}
delay(50) // lo dejamos trabajar 50 ms
pesado.cancelAndJoin() // cancela y espera: acaba porque ensureActive() lanza
println("cancelado: ${pesado.isCancelled}")
}cancelado: trueSin la línea de ensureActive(), el bucle no miraría nunca la marca y cancelAndJoin() esperaría para siempre.
Medido en la Mac de 11 núcleos del curso, en Dispatchers.Default (un grupo de hilos con tantos hilos como núcleos, que se ve a fondo en Dispatchers y withContext). Las cifras dependen de la máquina; lo que se mantiene es la diferencia:
| Caso | Qué pasó |
|---|---|
Bucle de 1.500.000.000 vueltas sin ensureActive(), cancelado a los 50 ms | cancelAndJoin tardó 1405 ms, isCancelled=true, y el bucle terminó las 1.500.000.000 vueltas |
Un cálculo de 2.000.000.000 de vueltas que sí mira ensureActive() cada 1.000 vueltas (el que escribirás en la lección siguiente), cancelado a los 100 ms | se canceló en 0 ms |
Thread.sleep(1_000) en lugar de delay(1_000), cancelado a los 100 ms | cancelAndJoin tardó 900 ms y terminó igual (con delay, 2 ms) |
Cancelar sin punto de comprobación marca el Job y el trabajo se acaba igual. Thread.sleep tampoco comprueba nada.
Error típico: tragarse la cancelación
Envuelves una llamada en try/catch (e: Exception) para que un fallo no rompa nada. Pero CancellationException es una Exception: así es como una corrutina cancelada sale de un delay.
Dicho de otro modo: la cancelación viaja como una excepción, y por eso un catch demasiado ancho la intercepta. Si la atrapas y sigues, la corrutina continúa como si nadie la hubiera cancelado. Pruébalo en el Playground:
import kotlinx.coroutines.*
fun main() = runBlocking {
val trabajo = launch {
try {
delay(10_000) // aquí llega la cancelación, como una excepción
} catch (e: Exception) {
println("atrapada: $e") // atrapa TAMBIÉN la cancelación
}
println("sigo trabajando…") // se ejecuta aunque me hayan cancelado
}
delay(20)
trabajo.cancelAndJoin()
}La consola muestra (lo que va detrás de la arroba cambia en cada ejecución, y el texto largo es solo el aviso de que la cancelaron):
atrapada: kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@3224f60b
sigo trabajando…JobCancellationException es un tipo de CancellationException; lo que va detrás de la arroba no importa. Se atrapa, el código de después se ejecuta con el Job ya en estado Cancelling y la corrutina sigue trabajando. runCatching hace lo mismo.
suspend fun <T> intentar(bloque: suspend () -> T): Result<T> = try {
Result.success(bloque())
} catch (cancelacion: CancellationException) {
throw cancelacion // la cancelación no es un fallo: se relanza
} catch (error: Exception) {
Result.failure(error)
}suspend () -> T, el tipo del parámetro bloque, es el de una lambda que puede llamar a funciones suspend y devuelve un T (el tipo que haga falta). Sin la palabra suspend, la lambda no podría llamar a delay.
Regla: si atrapas Exception, atrapa antes CancellationException y vuelve a lanzarla.
Swift tiene la misma cancelación cooperativa, con task.cancel() y Task.isCancelled: la verás en Cancelar tareas.
Antes del ejercicio, dos piezas que necesita y que aún no has visto juntas: una función de extensión sobre CoroutineScope (así puede llamar a launch, que es una función del alcance) que devuelve el Job del launch, y un parámetro (Int) -> Unit, una lambda que recibe un número y que quien llama usa para modificar una var suya. Pruébalo en el Playground:
import kotlinx.coroutines.*
// Extensión sobre CoroutineScope: dentro, launch es this.launch. Devuelve el Job del launch
fun CoroutineScope.saludador(alSaludo: (Int) -> Unit): Job = launch {
repeat(3) { n ->
delay(50)
alSaludo(n + 1) // llama a la lambda que le pasó quien llamó
}
}
fun main() = runBlocking {
var ultimo = 0 // una var de fuera: la lambda la modifica
val saludos = saludador { ultimo = it; println("saludo $it") } // it es el Int que recibe
saludos.join() // el Job devuelto sirve para esperar (o cancelar)
println("último: $ultimo")
}saludo 1
saludo 2
saludo 3
último: 3Reto · Ejercicio: un contador dentro de una tarea
En el Kotlin Playground, escribe un contador que, lanzado desde un alcance, imprima un tic cada 100 ms hasta que lo cancelen y devuelva su Job para poder cancelarlo. Después, una función que pone el contador en marcha, espera 350 ms, lo cancela esperando a que acabe, dice si quedó cancelado y devuelve cuántos tics contó. Pregúntate antes qué pasaría si no lo cancelara. Como en el ejemplo de los «tic», el bucle tiene que mirar si lo han cancelado. La consola debe mostrar:
tic 1
tic 2
tic 3
contador cancelado: true
Tarea terminada con 3 ticsVer solución
import kotlinx.coroutines.*
// Extensión sobre CoroutineScope: así puede llamar a launch, y devuelve el Job para poder cancelarlo
fun CoroutineScope.contador(alTic: (Int) -> Unit): Job = launch {
var n = 0
while (isActive) { // mira la marca de cancelación
delay(100)
n++
alTic(n)
}
}
suspend fun tareaConContador(ms: Long): Int = coroutineScope {
var tics = 0
val c = contador { tics = it; println("tic $it") }
delay(ms)
c.cancelAndJoin() // salen tic 1, tic 2 y tic 3
println("contador cancelado: ${c.isCancelled}")
tics
}
fun main() = runBlocking {
val total = tareaConContador(350)
println("Tarea terminada con $total tics")
}Sin el cancelAndJoin(), coroutineScope no volvería nunca: espera a sus hijas y el contador es infinito. Es la concurrencia estructurada, la misma que usa jugarConCronometro en el paso de abajo.
Paso · El cronómetro se cancela al pitar el final
Objetivo. Crear Cronometro.kt con el reloj, jugarConCronometro e intentar, y probar la demo del reloj desde Main.kt. Al terminar, la consola imprime el reloj cada 15 minutos y el resultado del partido. No hay test nuevo: los de antes siguen en verde.
En esta lección no se instala nada: todo lo que usas (CoroutineScope, Job, isActive…) es de la librería que instalaste en Corrutinas y suspend.
Cronometro.kt se crea con clic derecho sobre el paquete com.sebastiannarvaez.marcador.torneo (el que no lleva (test)) → New → Kotlin Class/File → File → Cronometro.
El paso no trae piezas nuevas: usa launch, isActive, cancel y una función de extensión sobre CoroutineScope, igual que el ejercicio de arriba, y coroutineScope y jugarPartido de las lecciones anteriores.
Intenta escribirlo tú, en dos piezas:
- El reloj.
cronometro(msPorMinuto, alMinuto)se lanza desde un alcance y arranca un reloj que, cadamsPorMinuto, suma un minuto y avisa conalMinuto; no para nunca por sí solo, solo si lo cancelan, y devuelve suJobpara cancelarlo.jugarConCronometro(partido, msPorMinuto, alMinuto)pone el reloj en marcha, juega el partido y para el reloj al acabar. Antes de abrir el bloque de abajo, pregúntate qué pasaría si no lo parara (la respuesta, después del bloque). intentar. La versión corregida del error típico de arriba, tal cual.
Lo demás lo escribes leyendo el bloque de abajo. Tras lo de las lecciones anteriores, la consola debe terminar con estas líneas (el último minuto del reloj puede variar):
== el cronómetro se cancela al pitar el final ==
reloj: minuto 15
reloj: minuto 30
reloj: minuto 45
reloj: minuto 60
reloj: minuto 75
Final 2-1; el reloj ya no correIntenta primero. Cuando termines, compara con la solución.Mostrar el código
Cronometro.kt queda así:
package com.sebastiannarvaez.marcador.torneo
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
// Concurrencia estructurada: toda corrutina vive dentro de un alcance
// (CoroutineScope) y el alcance no termina hasta que terminan sus hijas.
// Si el alcance se cancela, se cancelan todas las hijas. Nada queda huérfano.
// Una función de extensión sobre CoroutineScope: `launch` sin nombre de alcance
// dentro es `this.launch`, es decir, hija de quien la llame.
// `isActive` es la forma cooperativa de mirar «¿me han cancelado?».
fun CoroutineScope.cronometro(msPorMinuto: Long, alMinuto: (Int) -> Unit): Job = launch {
var minuto = 0
while (isActive) {
delay(msPorMinuto)
minuto++
alMinuto(minuto)
}
}
// El cronómetro es infinito. `coroutineScope` no termina mientras tenga hijas
// vivas, así que si nadie lo cancelara, esta función no volvería nunca.
// Al pitar el final se cancela el reloj y el alcance puede cerrarse.
suspend fun jugarConCronometro(
partido: Partido,
msPorMinuto: Long = 10,
alMinuto: (Int) -> Unit = {},
): Partido = coroutineScope {
val reloj = cronometro(msPorMinuto, alMinuto)
val final = jugarPartido(partido, msPorMinuto)
reloj.cancel()
final
}
// `runCatching` y `catch (e: Exception)` atrapan TAMBIÉN la CancellationException,
// porque es una Exception. Si la tragas, la corrutina cancelada sigue como si
// nada. Regla: atrápala y vuelve a lanzarla.
suspend fun <T> intentar(bloque: suspend () -> T): Result<T> = try {
Result.success(bloque())
} catch (cancelacion: CancellationException) {
throw cancelacion
} catch (error: Exception) {
Result.failure(error)
}En Main.kt, las líneas nuevas van dentro del runBlocking, justo después del último println de la lección anterior (el que empieza por Dos estadísticas en) y antes de las dos llaves } que cierran el runBlocking y el main (no las dupliques):
println("== el cronómetro se cancela al pitar el final ==")
val conReloj = jugarConCronometro(Ejemplo.rayoContraToros) { minuto ->
if (minuto % 15 == 0) println(" reloj: minuto $minuto")
}
println("Final ${conReloj.golesLocal}-${conReloj.golesVisitante}; el reloj ya no corre")La respuesta a la pregunta del reloj. Sin reloj.cancel(), jugarConCronometro no volvería nunca: coroutineScope espera a sus hijas y el cronómetro es infinito. Es la concurrencia estructurada, funcionando como debe.
Punto de control
- En la Terminal de Android Studio,
./gradlew :torneo:runtermina enBUILD SUCCESSFULe imprime, tras lo de las lecciones anteriores:
== el cronómetro se cancela al pitar el final ==
reloj: minuto 15
reloj: minuto 30
reloj: minuto 45
reloj: minuto 60
reloj: minuto 75
Final 2-1; el reloj ya no corre- En esta ejecución el reloj no llegó a imprimir el minuto 90: se cancela en cuanto
jugarPartidotermina. Con tiempos reales, el último minuto que llega a imprimir puede variar; no es un fallo. ./gradlew :torneo:testpasa igual que antes: abre el informe (como en Bucles y tu primer test) y comprueba que arriba pone 89 tests y 0 fallos. En esta lección no se añade ninguno.
La lección siguiente, Un alcance propio, crea el Transmisor, una clase con su propio alcance, y prueba todo esto con CancelacionTest.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.Un bucle que solo calcula, sin suspender nunca y sin
ensureActive(), se cancela a los 50 ms. ¿Qué pasó en la medición?2.Dentro de una corrutina escribes
try { delay(10_000) } catch (e: Exception) { println(e) }y luego más código. Al cancelarla, ¿qué ocurre?3.¿Qué consecuencia tiene la concurrencia estructurada en
jugarConCronometro, cuyo reloj es infinito?4.¿Qué diferencia hay entre
trabajo.cancel()ytrabajo.cancelAndJoin()?