La cancelación es cooperativa (y tu limpieza también se cancela)
cancel() no detiene nada. Pone una bandera y deja que el próximo punto de suspensión lance — por eso una corrutina que nunca suspende es una corrutina que no podés frenar.
Explorar la explicación completa 8 ideas
La cancelación es la característica que hace que valga la pena la concurrencia estructurada: cuando un cliente de WebFlux se desconecta, cuando dispara un `withTimeout`, cuando se destruye una pantalla de Android, el trabajo de abajo debería parar — y parar *en todos lados*, hasta el fondo del árbol de hijos. Pero el mecanismo no es el que la mayoría supone. `job.cancel()` no interrumpe un hilo ni aborta un cómputo. Mueve el Job a un estado de *cancelación*, y desde ese momento cada **punto de suspensión** dentro de esa corrutina lanza una `CancellationException` en vez de reanudarse normalmente. La cancelación se entrega, no se impone.
La consecuencia inmediata es que una corrutina que nunca suspende nunca puede cancelarse. Un bucle apretado haciendo trabajo de CPU — parsear un archivo grande, correr una simulación, hashear — no tiene puntos de suspensión, así que `cancel()` pone la bandera y el bucle sigue hasta terminar igual. El arreglo es hacer que el bucle coopere: llamar a `ensureActive()` (que lanza si el Job está cancelado), o chequear `isActive` vos mismo, o llamar a `yield()` (que chequea la cancelación y además le da turno a otras corrutinas del dispatcher). Cada función suspendible de `kotlinx.coroutines` — `delay`, `await`, `Channel.send`, `Mutex.lock` — ya chequea por vos; solo *tu propio* trabajo no suspendible tiene que sumarse.
La segunda consecuencia es la que causa bugs en producción: una corrutina cancelada *ya está* en estado de cancelación cuando corre su bloque `finally`. Por lo tanto, cada punto de suspensión de ese bloque lanza de inmediato — así que una limpieza como `finally { connection.closeAsync(); metrics.flush() }` escrita con llamadas suspendibles no ejecuta ni una sola línea. El bloque corre, el primer `delay`/`await` de adentro lanza, y el recurso se filtra. No es un bug de la librería; es el contrato de cancelación aplicado con consistencia, y sorprende a todo el mundo exactamente una vez.
`withContext(NonCancellable) { … }` es la salida de emergencia. Instala un Job que nunca se cancela, así que las suspensiones de adentro se comportan normal, y es el *único* uso legítimo de `NonCancellable`: un bloque de limpieza corto y acotado. Envolver lógica de negocio en él convierte una corrutina cancelable en una imparable, que es estrictamente peor que no soportar cancelación — ahora el llamador cree que puede frenar el trabajo y no puede. Mantené el bloque lo bastante chico como para estar cómodo con que corra hasta el final después de una señal de apagado, porque es exactamente lo que va a hacer.
`CancellationException` es también la razón por la que el manejo amplio de excepciones es peligroso dentro de corrutinas. Es una `Exception` corriente, así que `catch (e: Exception)` la captura, y `runCatching { }` también (que captura `Throwable`). Un wrapper de reintento escrito así sigue iterando después del `cancel()`, produciendo una **corrutina zombi**: el scope padre siguió adelante, nadie la va a joinear nunca, y la garantía central de la concurrencia estructurada — cuando el scope retorna, sus hijos terminaron — es falsa en silencio. En un servidor con scope por request, eso es una corrutina filtrada por cada request cancelado. La regla es absoluta: o capturás la excepción específica que realmente manejás, o capturás ampliamente y relanzás primero `CancellationException`.
La cancelación propaga hacia abajo y, ante fallos, hacia arriba. Cancelar un padre cancela a todos los hijos; un hijo que *falla* cancela a su padre (que después cancela a los hermanos) salvo que el padre sea un `SupervisorJob`. Por eso `coroutineScope` y `supervisorScope` son una decisión de diseño y no una preferencia de estilo: el primero dice «estos resultados solo tienen sentido juntos», el segundo dice «estas son tareas independientes de mejor esfuerzo». Ojo con la asimetría — la supervisión evita que el *fallo* viaje hacia arriba, pero un supervisor cancelado igual cancela a sus hijos. Supervisión no es inmunidad.
Vale conocer con precisión algunas APIs. `cancel()` solo *pide* la cancelación y retorna de inmediato, así que leer estado justo después es una carrera; `cancelAndJoin()` espera a que la corrutina termine de desenrollarse. `withTimeout(t)` lanza `TimeoutCancellationException` (una `CancellationException`, así que cancela el bloque en vez de escapar como error normal) mientras que `withTimeoutOrNull(t)` devuelve `null`. `job.invokeOnCompletion { cause -> … }` te da un callback no suspendible que corre en todos los caminos terminales, y suele ser mejor lugar para la contabilidad que un `finally` que tiene que pelear con el contrato de cancelación.
El modelo mental a conservar: la cancelación es un *protocolo cooperativo*, no un interruptor de apagado. Tu código participa suspendiendo lo bastante seguido como para notarla, no tragándose la señal, y haciendo su limpieza explícitamente no cancelable por los pocos milisegundos que eso toma. El código que hace las tres cosas se puede frenar al instante y no deja nada atrás; el que no hace ninguna se ve bien en los tests y filtra bajo carga, porque solo bajo carga se cancela algo de verdad.
Aprendamos juntos.
Pregúntame sobre este curso o por dónde empezar.
import kotlinx.coroutines.*suspend fun stubborn() = coroutineScope {val job = launch(Dispatchers.Default) {var i = 0// No suspension point in here, so cancel() cannot be observed.while (i < 50_000_000) i++println("stubborn finished anyway: " + i)}delay(20)job.cancelAndJoin()}suspend fun cooperative() = coroutineScope {var seen = 0val job = launch(Dispatchers.Default) {try {var i = 0while (i < 50_000_000) {ensureActive() // throws once the Job is cancellingi++seen = i}} finally {// The job is ALREADY cancelling here, so a suspending cleanup// needs a Job that is not.withContext(NonCancellable) {delay(5)println("cleanup ran; stopped early = " + (seen < 50_000_000))}}}delay(20)job.cancelAndJoin()}fun main() = runBlocking {stubborn()cooperative()}
The three shapes side by side: a CPU loop that ignores cancellation, the cooperative version, and a suspending cleanup that only runs because it opted out of the cancelled Job.
Arena IDEimport kotlinx.coroutines.*suspend fun zombie() = coroutineScope {var ticks = 0val job = launch {repeat(20) {try {delay(10)ticks++} catch (e: Exception) {// WRONG: CancellationException is an Exception.// The loop keeps going after cancel().}}}delay(25)job.cancelAndJoin()println("zombie kept ticking to " + ticks)}suspend fun wellBehaved() = coroutineScope {var ticks = 0val job = launch {repeat(20) {try {delay(10)ticks++} catch (e: CancellationException) {throw e // never ours to handle} catch (e: Exception) {// real, retryable failures only}}}delay(25)job.cancelAndJoin()println("well-behaved stopped at " + ticks)}fun main() = runBlocking {zombie()wellBehaved()}
The swallowed-cancellation bug and its two correct forms. A broad catch inside a coroutine must let CancellationException through, or the loop becomes a zombie that outlives its scope.
Arena IDEimport kotlinx.coroutines.*suspend fun slowCall(): String {delay(200)return "done"}fun main() = runBlocking {// Throws TimeoutCancellationException, which IS a CancellationException.val thrown = try {withTimeout(50) { slowCall() }} catch (e: TimeoutCancellationException) {"timed out: " + e::class.simpleName}println(thrown)// Same cancellation, different reporting.println("orNull = " + withTimeoutOrNull(50) { slowCall() })// And it does finish when it fits inside the budget.println("in budget = " + withTimeoutOrNull(500) { slowCall() })}
Deadlines: withTimeout throws a CancellationException (so it cancels the block), withTimeoutOrNull hands you null instead. Both cancel the work — the difference is only how you find out.
Arena IDE@RestControllerclass ReportController(private val reports: ReportService) {// Spring cancels this coroutine when the HTTP client goes away.@GetMapping("/reports/{id}")suspend fun report(@PathVariable id: Long): Report =withTimeout(3_000) { reports.build(id) }}@Serviceclass ReportService(private val client: WebClient) {suspend fun build(id: Long): Report {val lease = acquireLease(id) // a real resourcetry {return client.get().uri("/raw/{id}", id).retrieve().awaitBody()} finally {// Without NonCancellable this releases NOTHING on the cancelled// path — i.e. on every client disconnect and every timeout.withContext(NonCancellable) { lease.release() }}}}
In a WebFlux handler you get cancellation for free when the client disconnects — which is exactly when a leaked resource costs you. Reference only (needs Spring).
🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. A coroutine is cancelled while it is inside a `try` block. Its `finally` block calls a suspending `flushMetrics()`. What happens?