Cancelación de corrutinas básico
Llevo un par de semanas estudiando la cancelación de corrutinas en Kotlin. A primera vista parecía algo sencillo de entender y pensaba que con solo usar cancel() detendría la ejecución de la corrutina. Sin embargo, esa simple función realmente no cancela todo el código dentro del bloque de la misma corrutina.
Un ejemplo sencillo sería el siguiente:
coroutineScope {
val job = launch(Dispatchers.Default) {
var i = 0
while (i < 5_000_000) {
i++
}
println("Cancelled after $i cycles")
}
delay(1)
job.cancel()
job.join()
}
// prints -> Cancelled after 5000000 cycles
En el anterior bloque de código, existe una corrutina job que contiene un ciclo que se ejecuta hasta completar 5 millones de repeticiones y, al terminar, imprime el valor final de i. A pesar de que después de 1 milisegundo hay una cancelación job.cancel(), la ejecución del ciclo while continúa hasta terminar.
coroutineScope {
val job = launch(Dispatchers.Default) {
var i = 0
while (isActive && i < 5_000_000) {
i++
}
println("Cancelled after $i cycles")
}
delay(1)
job.cancel()
job.join()
}
// prints -> Cancelled after 76205 cycles
Ahora bien, si se agrega la validación isActive dentro de la condición del while(), obtendríamos que se ha cancelado antes de que el contador i alcance el límite de repeticiones (5 millones).
Esto ha sido un ejemplo de lo que se le conoce como cancelación cooperativa. En otras palabras, la misma corrutina tiene que “enterarse” de que fue cancelada y reaccionar a esta cancelación.
Para ayudar a enterarse se tiene que usar puntos de suspensión (suspension points) como lo son isActive, ensureActive, delay o yield. Estas funciones y variables internamente están observando el estado actual de la corrutina.
Una vez que el cancel() es llamado, el estado actual de la corrutina cambia, y cuando un loop o un procedimiento se encuentra con algún suspension point y este último detecta que la corrutina fue cancelada, entonces el código consecuente ya no se ejecuta.
Un último ejemplo muy similar antes de terminar… por el momento:
coroutineScope {
val job = launch(Dispatchers.Default) {
var i = 0
while (i < 5_000_000) {
yield()
i++
}
println("Cancelled after $i cycles")
}
delay(1)
job.cancel()
job.join()
}
// nothing is printed
En esta ocasión se usa el suspension point yield. Al ejecutar este código nos daremos cuenta que realmente sí se cancela la corrutina job, pero no se imprime en consola el número de repeticiones que ocurrieron, como se había hecho con isActive previamente.
Esto sucede porque yield, delay y ensureActive detectan la cancelación como excepción del tipo CancellationException y la propagan para justamente cancelar las ejecuciones a posteriori. Esto lo abordaremos después.