Saltar al contenido
K
Corrutinas y FlowPaso a paso38 min de lectura · 5 min de máquina Intermedio

Un alcance propio

Al terminar: Crear un alcance propio con su contexto, entender GlobalScope y cancelar el trabajo de un Transmisor.

Al terminar esta lección, Marcador tendrá un Transmisor: una clase con un alcance propio que transmite varios partidos a la vez y puede cancelarlos todos de golpe con una sola llamada. Y habrás visto en la consola por qué GlobalScope deja corrutinas huérfanas y por qué un cálculo pesado necesita su propio hilo para poder cancelarse.

Es la pieza que conecta con Android: allí, cada pantalla tiene un alcance con dueño, y es lo que evita que una corrutina siga gastando batería cuando ya nadie la mira. Temas: contexto de corrutina, Job() y CoroutineScope(…), GlobalScope y por qué evitarlo.

El contexto: la ficha de cada corrutina

Cada corrutina lleva una ficha con sus datos: su Job y su dispatcher, que es quien decide en qué hilo corre. Por ejemplo, Dispatchers.Default es un grupo de hilos con tantos hilos como núcleos tiene el procesador (se ve a fondo en Dispatchers y withContext). Esa ficha es su contexto de corrutina (CoroutineContext): un conjunto de elementos. El operador + combina elementos: Dispatchers.Default + trabajo es un contexto con dos, dónde corre y de quién es hija.

Tres piezas que usa el paso:

PiezaQué hace
Job()crea un Job suelto, sin corrutina, que sirve de padre; cancelarlo cancela a todas las que cuelguen de él
CoroutineScope(contexto)crea un alcance propio con ese contexto: un alcance con dueño, porque quien lo crea decide cuándo se cancela
currentCoroutineContext()dentro de una suspend fun que no recibe alcance, da el contexto de la corrutina que la llamó; con él se llama a ensureActive()

Dentro de un launch { }, donde this ya es un alcance, basta escribir ensureActive() a secas, sin currentCoroutineContext(), como en la lección anterior.

Por qué no GlobalScope

GlobalScope es un alcance sin Job: sus corrutinas no son hijas de nadie y nadie las cancela.

Se ve así, en el Kotlin Playground: un padre lanza un GlobalScope.launch, se cancela el padre y el huérfano sigue vivo.

ejemplo mínimo: un huérfano de GlobalScope (con comentarios)
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
 
// @OptIn: una anotación (@Nombre) es una etiqueta para el compilador. Esta dice
// «sé que GlobalScope es delicado y lo uso a propósito»; sin ella, avisa.
// DelicateCoroutinesApi::class se escribe así; no hace falta entenderlo ahora.
@OptIn(DelicateCoroutinesApi::class)
fun main() {
    runBlocking {
        var huerfano: Job? = null                              // Job?: aún no hay ninguno
        val padre = launch {
            huerfano = GlobalScope.launch { delay(10_000) }    // sin padre: nadie lo cancelará
            delay(1_000)
        }
        delay(50)
        padre.cancelAndJoin()                                  // cancela al padre y espera
        println("Padre cancelado: ${padre.isCancelled}. Huérfano activo: ${huerfano?.isActive}")
        huerfano?.cancel()                                     // hay que cancelarlo a mano
    }
}

Al ejecutarlo, imprime:

Padre cancelado: true. Huérfano activo: true

El padre está cancelado y el huérfano sigue activo: hay que cancelarlo a mano, y en una app seguiría gastando batería y memoria después de cerrar la pantalla. Sin el @OptIn, el compilador avisa con this is a delicate API and its use requires care: compila, pero te pide que lo hagas a propósito. La alternativa es un alcance con dueño: en Marcador, la clase Transmisor (en el paso); más adelante, los alcances de Android, que verás en ViewModel: qué sobrevive.

Un alcance con dueño, en pequeño

Antes del paso, una clase pequeña con su propio alcance: una radio. Quien crea la radio decide cuándo se apaga todo lo que suena. El ejemplo suma además unos números al azar con un bucle que mira si lo han cancelado, para ver currentCoroutineContext() en acción. Se puede probar en el Playground:

ejemplo mínimo: una clase con alcance propio y un bucle cancelable (con comentarios)
import kotlinx.coroutines.*
import kotlin.random.Random
 
// suspend fun sin alcance: currentCoroutineContext() da el de quien la llamó
suspend fun sumarAlAzar(vueltas: Int): Int {
    var total = 0
    repeat(vueltas) { i ->             // repeat repite el bloque; i cuenta las vueltas desde 0
        if (i % 1_000 == 0) currentCoroutineContext().ensureActive()   // ¿me cancelaron? Si sí, lanza
        total += Random.nextInt(10)    // entero al azar de 0 a 9
    }
    return total
}
 
// Una clase con alcance PROPIO: ella decide cuándo se cancela todo lo que lanza
class Radio {
    private val trabajo = Job()                                       // un Job suelto que hace de padre
    private val alcance = CoroutineScope(Dispatchers.Default + trabajo)   // + junta dispatcher y Job
    val encendida: Boolean get() = trabajo.isActive                   // true mientras el Job no se cancele
 
    fun sonar(cancion: String): Job = alcance.launch { delay(50); println("Suena $cancion") }
    fun apagar() = trabajo.cancel()                                   // cancela todo lo que cuelga de trabajo
}
 
fun main() = runBlocking {
    println("Suma entre 0 y 90.000: ${sumarAlAzar(10_000) in 0..90_000}")   // 10.000 vueltas de 0 a 9: nunca pasa de 90.000
 
    val radio = Radio()
    radio.sonar("Bolero").join()                  // join: espera a que acabe
    radio.apagar()
    println("Radio encendida: ${radio.encendida}")
}

Al ejecutarlo, imprime:

Suma entre 0 y 90.000: true
Suena Bolero
Radio encendida: false

Un detalle más antes del ejercicio: un alcance cancelado no se puede reutilizar. Todo lo que lances en él después ya nace cancelado, y no llega a empezar.

Reto · Ejercicio: una alarma que se silencia entera

En el Kotlin Playground, escribe una Alarma con su propio alcance en Dispatchers.Default. sonarEn(ms) lanza una corrutina que espera esos milisegundos, escribe «¡Riiing! (a los … ms)» y devuelve su Job; silenciar() cancela todo de golpe; encendida dice si sigue viva. Programa dos alarmas, una a los 100 ms y otra a los 1.000 ms, y silencia a los 300 ms. Después, programa una alarma más y comprueba, antes de ejecutar, qué crees que le pasa: ¿suena? La consola debe mostrar:

¡Riiing! (a los 100 ms)
Alarmas canceladas: [false, true]
Alarma tardía cancelada: true
Alarma encendida: false
Ver solución
solución: una alarma que se silencia entera
import kotlinx.coroutines.*
 
class Alarma {
    private val trabajo = Job()
    private val alcance = CoroutineScope(Dispatchers.Default + trabajo)
    val encendida: Boolean get() = trabajo.isActive
 
    fun sonarEn(ms: Long): Job = alcance.launch {
        delay(ms)
        println("¡Riiing! (a los $ms ms)")
    }
    fun silenciar() = trabajo.cancel()
}
 
fun main() = runBlocking {
    val alarma = Alarma()
    val alarmas = listOf(alarma.sonarEn(100), alarma.sonarEn(1_000))
    delay(300)
    alarma.silenciar()                      // la de 100 ms ya sonó; la de 1.000 ms se cancela
    alarmas.forEach { it.join() }
    println("Alarmas canceladas: ${alarmas.map { it.isCancelled }}")
 
    val tardia = alarma.sonarEn(50)         // el alcance ya está cancelado
    tardia.join()
    println("Alarma tardía cancelada: ${tardia.isCancelled}")
    println("Alarma encendida: ${alarma.encendida}")
}

La primera alarma ya había terminado cuando llegó la cancelación, por eso su isCancelled es false. La tardía nace cancelada: el Job() padre ya está cancelado y todo lo que cuelga de él nace igual. Por eso un alcance con dueño se crea una vez y se apaga una vez.

Paso · El Transmisor y el cálculo cancelable

Objetivo. Completar Cronometro.kt con probabilidadDeVictoria (un cálculo pesado que se puede cancelar) y la clase Transmisor; probar dos demos desde Main.kt; y copiar el test. Al terminar, la consola imprime cómo se cancelan los partidos del Transmisor y el cálculo pesado.

torneo → kotlin+java → com.sebastiannarvaez.marcador.torneo
Cronometro.ktse modifica
Main.ktse modifica
torneo → kotlin+java → com.sebastiannarvaez.marcador.torneo (test)
CancelacionTest.ktse rellena con el botón
Dos ficheros modificados y un test que se rellena con el botón.

En esta lección no se instala nada: todo lo que usas (CoroutineScope, Job, Dispatchers…) es de la librería de corrutinas.

Piezas nuevas de este paso. Las necesitas y aún no se han explicado (Job(), CoroutineScope(…), + y currentCoroutineContext() están en «El contexto», arriba):

PiezaQué hace
Random.nextInt(n) (de kotlin.random)un entero al azar entre 0 y n - 1
toDouble()convierte un entero en decimal, para que la división dé 0,6 y no 0 (una división entre enteros descarta los decimales)
launch(Dispatchers.Default) { … }ya lo usaste en la lección anterior; aquí se explica: pasarle un contexto a launch hace que la corrutina corra en ese dispatcher en lugar de heredar el del padre (el porqué, en la caja de justo debajo)

Intenta escribirlo tú, en dos tandas:

  1. El cálculo. probabilidadDeVictoria(partido, simulaciones) simula muchos partidos al azar. Cada equipo tiene una fuerza: sus goles más uno (fL el local, fV el visitante). En cada simulación, el local gana si Random.nextInt(fL + fV) < fL; la función devuelve las victorias locales dividido entre las simulaciones, como decimal. Mira cada 1.000 vueltas si lo han cancelado, como sumarAlAzar. Va en Cronometro.kt, entre jugarConCronometro e intentar.
  2. El Transmisor. Una clase con un alcance propio, en Dispatchers.Default, del que cuelga todo lo que transmite. activo dice si sigue vivo. transmitir(partido, msPorMinuto, alFinal) juega el partido con cronómetro en ese alcance, avisa con alFinal al terminar y devuelve el Job de esa corrutina. pitarElFinal() lo cancela todo de golpe. Va al final de Cronometro.kt.

Y en Main.kt, demoTransmisorPropio: transmite dos partidos, pita el final a los 300 ms y cancela el cálculo pesado a los 100 ms. Son demostraciones para ver cancelar, no sintaxis nueva. Tras lo de la lección anterior, debe verse:

== un alcance propio y cancelación cooperativa ==
Cancelados: [true, true], transmisor activo: false
El cálculo pesado se canceló en 0 ms

En Cronometro.kt hacen falta cuatro import más, junto a los que ya hay (puedes escribirlos o dejar que Android Studio los añada: aviso rojo → Option+Enter):

Cronometro.kt: import que se añaden
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlin.random.Random

En Main.kt, los import nuevos son estos:

Main.kt: import que se añaden
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
Intenta primero. Cuando termines, compara con la solución.Mostrar el código

probabilidadDeVictoria va entre jugarConCronometro e intentar, para que el fichero quede en el mismo orden que el del cierre del módulo:

Cronometro.kt: probabilidadDeVictoria, entre jugarConCronometro e intentar
// La cancelación es COOPERATIVA: cancelar solo marca al Job. Las funciones
// suspendidas de la librería (`delay`, `await`…) comprueban la marca y lanzan
// CancellationException. Un bucle que solo calcula, sin suspender nunca,
// tiene que mirar él mismo con `ensureActive()`; si no, no se entera.
suspend fun probabilidadDeVictoria(partido: Partido, simulaciones: Int = 5_000_000): Double {
    val fuerzaLocal = partido.golesLocal + 1
    val fuerzaVisitante = partido.golesVisitante + 1
    var victoriasLocales = 0
    for (i in 1..simulaciones) {
        // Cada mil vueltas, ¿sigo vivo? Lanza CancellationException si no.
        if (i % 1_000 == 0) currentCoroutineContext().ensureActive()
        if (Random.nextInt(fuerzaLocal + fuerzaVisitante) < fuerzaLocal) victoriasLocales++
    }
    return victoriasLocales.toDouble() / simulaciones
}

La clase Transmisor va al final del fichero, después de intentar:

Cronometro.kt: la clase Transmisor, al final
// Un alcance PROPIO: la clase decide cuándo empieza y cuándo acaba la vida de
// todo lo que lanza. `Job()` es el padre de todas las corrutinas del alcance;
// cancelarlo las cancela a todas. Al pitar el final, `pitarElFinal()`.
class Transmisor {
    private val trabajo = Job()
    private val alcance = CoroutineScope(Dispatchers.Default + trabajo)
 
    val activo: Boolean get() = trabajo.isActive
 
    fun transmitir(partido: Partido, msPorMinuto: Long = 10, alFinal: (Partido) -> Unit = {}): Job =
        alcance.launch { alFinal(jugarConCronometro(partido, msPorMinuto)) }
 
    fun pitarElFinal() = trabajo.cancel()
}

En Main.kt, las líneas nuevas van dentro del runBlocking, justo después del println("Final …; el reloj ya no corre") de la lección anterior y antes de las dos llaves } que cierran el runBlocking y el main (no las dupliques):

Main.kt: las líneas nuevas
        println("== un alcance propio y cancelación cooperativa ==")
        demoTransmisorPropio()

Y la función va debajo del main:

Main.kt: la función nueva, debajo de main
// El Transmisor tiene su propio alcance. Se transmiten dos partidos y, a mitad,
// se pita el final del día: los dos se cancelan sin que ninguno llegue al 90.
suspend fun demoTransmisorPropio() = coroutineScope {
    val transmisor = Transmisor()
    val trabajos = listOf(Ejemplo.rayoContraToros, Ejemplo.lobosContraAguilas).map { guion ->
        transmisor.transmitir(guion) { println("Terminó ${it.local.nombre}: eso no debería verse") }
    }
    delay(300)
    transmisor.pitarElFinal()
    trabajos.forEach { it.join() }
    println("Cancelados: ${trabajos.map { it.isCancelled }}, transmisor activo: ${transmisor.activo}")
 
    // Un bucle de cálculo sin suspender solo se cancela si mira `ensureActive()`.
    // Va en Dispatchers.Default: en el hilo de runBlocking, el bucle ocuparía el
    // único hilo y ni siquiera podríamos llegar a llamar a cancelAndJoin().
    val calculo = launch(Dispatchers.Default) { println("resultado: ${probabilidadDeVictoria(Ejemplo.rayoContraToros, simulaciones = 2_000_000_000)}") }
    delay(100)
    val ms = measureTimeMillis { calculo.cancelAndJoin() }
    println("El cálculo pesado se canceló en $ms ms")
}

Rellena CancelacionTest.kt con el botón. Son 6 pruebas: el reloj se para al pitar el final, cancelar el Transmisor cancela sus partidos, un bucle de cálculo se cancela porque mira ensureActive, la probabilidad queda entre 0 y 1, intentar devuelve el error como valor e intentar no traga la cancelación. Prueban código que ya has escrito y por eso no se escriben a mano. El orden, como en Clases y objetos: clic derecho sobre el paquete com.sebastiannarvaez.marcador.torneo (test) → New → Kotlin Class/File → File → CancelacionTest; pulsa el botón; haz clic dentro del fichero, selecciona todo con Cmd+A y pega con Cmd+V.

CancelacionTest.kt

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
== un alcance propio y cancelación cooperativa ==
Cancelados: [true, true], transmisor activo: false
El cálculo pesado se canceló en 0 ms
  • No debe aparecer ninguna línea que empiece por resultado:: si sale, el cálculo pesado terminó entero en lugar de cancelarse (mira la caja de arriba sobre launch(Dispatchers.Default)). El 0 ms de la línea del cálculo es lo normal: la cancelación se nota enseguida.
  • 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 95 pruebas: abre el informe (como en Bucles y tu primer test) y comprueba que arriba pone 95 tests y 0 fallos (los 89 de antes más los 6 de CancelacionTest). En el paquete (test) está CancelacionTest.kt.
  • Los tests usan tiempo real con márgenes amplios (por ejemplo, ms < 1_000). En una máquina lenta podrías tener que subir el margen; en Tests de corrutinas: tiempo virtual verás cómo hacer que el reloj de los tests corra más deprisa que el real, y por qué es mejor.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.Cancelas el padre de una corrutina lanzada con GlobalScope.launch. ¿Qué sale?

  2. 2.Lanzas un cálculo largo con launch dentro de runBlocking y lo cancelas a los 100 ms. El bucle acaba entero y el programa no reacciona. ¿Por qué?

  3. 3.En el Transmisor, ¿qué cancela pitarElFinal(), que llama a trabajo.cancel()?

  4. 4.En CoroutineScope(Dispatchers.Default + trabajo), ¿qué hace el +?

Faltan 4 de 4