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:
| Pieza | Qué 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.
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: trueEl 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:
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: falseUn 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: falseVer solución
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.
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):
| Pieza | Qué 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:
- El cálculo.
probabilidadDeVictoria(partido, simulaciones)simula muchos partidos al azar. Cada equipo tiene una fuerza: sus goles más uno (fLel local,fVel visitante). En cada simulación, el local gana siRandom.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, comosumarAlAzar. Va enCronometro.kt, entrejugarConCronometroeintentar. - El
Transmisor. Una clase con un alcance propio, enDispatchers.Default, del que cuelga todo lo que transmite.activodice si sigue vivo.transmitir(partido, msPorMinuto, alFinal)juega el partido con cronómetro en ese alcance, avisa conalFinalal terminar y devuelve elJobde esa corrutina.pitarElFinal()lo cancela todo de golpe. Va al final deCronometro.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 msEn 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):
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlin.random.RandomEn Main.kt, los import nuevos son estos:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delayIntenta 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:
// 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:
// 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):
println("== un alcance propio y cancelación cooperativa ==")
demoTransmisorPropio()Y la función va debajo del 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.
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
== 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 sobrelaunch(Dispatchers.Default)). El0 msde 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
jugarPartidotermina. Con tiempos reales, el último minuto que llega a imprimir puede variar; no es un fallo. ./gradlew :torneo:testpasa 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 deCancelacionTest). 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.Cancelas el padre de una corrutina lanzada con
GlobalScope.launch. ¿Qué sale?2.Lanzas un cálculo largo con
launchdentro derunBlockingy lo cancelas a los 100 ms. El bucle acaba entero y el programa no reacciona. ¿Por qué?3.En el
Transmisor, ¿qué cancelapitarElFinal(), que llama atrabajo.cancel()?4.En
CoroutineScope(Dispatchers.Default + trabajo), ¿qué hace el+?