Saltar al contenido
K
Corrutinas y FlowPaso a paso38 min Intermedio

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 cancelase cancelan todas sus hijas
Una hija fallael fallo sube al padre y cancela a las hermanas (cómo evitarlo, en Errores en corrutinas)
El bloque acabaespera 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.

ejemplo mínimo: alcance, Job y cancelación (con comentarios)
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 = true

Los 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):

ejemplo mínimo: un bucle que mira si lo han cancelado (con comentarios)
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: true

Sin 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:

CasoQué pasó
Bucle de 1.500.000.000 vueltas sin ensureActive(), cancelado a los 50 mscancelAndJoin 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 msse canceló en 0 ms
Thread.sleep(1_000) en lugar de delay(1_000), cancelado a los 100 mscancelAndJoin 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:

con el bug: catch que atrapa la cancelación (con comentarios)
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.

corregido: relanzar la CancellationException
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:

ejemplo mínimo: extensión sobre CoroutineScope que devuelve el Job (con comentarios)
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: 3

Reto · 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 tics
Ver solución
solución: un contador dentro de una tarea
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.

torneo → kotlin+java → com.sebastiannarvaez.marcador.torneo
Cronometro.ktse crea
Main.ktse modifica
Un fichero nuevo y uno modificado.

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:

  1. El reloj. cronometro(msPorMinuto, alMinuto) se lanza desde un alcance y arranca un reloj que, cada msPorMinuto, suma un minuto y avisa con alMinuto; no para nunca por sí solo, solo si lo cancelan, y devuelve su Job para 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).
  2. 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 corre
Intenta primero. Cuando termines, compara con la solución.Mostrar el código

Cronometro.kt queda así:

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Cronometro.kt
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):

Main.kt: las líneas nuevas
        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:run termina en BUILD SUCCESSFUL e 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 jugarPartido termina. Con tiempos reales, el último minuto que llega a imprimir puede variar; no es un fallo.
  • ./gradlew :torneo:test pasa 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. 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. 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. 3.¿Qué consecuencia tiene la concurrencia estructurada en jugarConCronometro, cuyo reloj es infinito?

  4. 4.¿Qué diferencia hay entre trabajo.cancel() y trabajo.cancelAndJoin()?

Faltan 4 de 4