Respuesta corta: si metes una llamada bloqueante de verdad (Thread.sleep(), un driver JDBC, cualquier IO que no sea suspend) dentro de un withContext(Dispatchers.IO) { ... }, withTimeout() no la va a cancelar aunque salte el timeout. La cancelación en coroutines es cooperativa: solo se comprueba en los puntos de suspensión, y una llamada bloqueante no es un punto de suspensión. El hilo sigue ocupado hasta que esa llamada termina por sí sola.
El código que parece que debería funcionar
Es fácil escribir algo así y asumir que withTimeout corta la ejecución en cuanto pasa el tiempo límite:
suspend fun timeout() {
try {
withTimeout(1000) {
execute()
} catch (e: TimeoutCancellationException) {
logger.info("timed out", e)
}
}La intuición dice: "pido 1 segundo, Thread.sleep(2000) tarda 2, así que a los 1000ms debería lanzarse TimeoutCancellationException y seguir". No es lo que pasa. withTimeout lanza la cancelación internamente, pero esa cancelación es una señal — no interrumpe el hilo por la fuerza. Thread.sleep() no mira esa señal en ningún momento, así que el hilo sigue bloqueado los 2 segundos completos. La excepción llega, pero tarde, cuando Thread.sleep() por fin termina solo.
Por qué ensureActive() tampoco arregla esto
El primer instinto suele ser añadir un ensureActive() o comprobar isActive antes o después de la llamada bloqueante. Ayuda a detectar la cancelación entre llamadas, pero no sirve de nada dentro de una llamada que ya está bloqueando el hilo — para cuando ensureActive() se ejecuta, Thread.sleep() ya ha terminado. El problema no es que falte una comprobación, es que withContext(Dispatchers.IO) { ... } existe precisamente para eso: ejecutar código bloqueante que no sabe nada de coroutines ni de cancelación.
El arreglo: runInterruptible
La forma idiomática de que una llamada bloqueante sí responda a la cancelación es envolverla en runInterruptible:
private suspend fun execute() {
runInterruptible(Dispatchers.IO) {
Thread.sleep(2000)
}
}runInterruptible traduce la cancelación de la coroutine en una interrupción real del hilo (Thread.interrupt()). Si el código bloqueante que estás llamando respeta la interrupción de Java — como hace Thread.sleep(), o casi cualquier driver JDBC moderno, o los clientes HTTP bloqueantes habituales — entonces sí se corta cuando toca. Si la llamada bloqueante no responde a interrupciones (hay librerías antiguas que las ignoran), no hay arreglo mágico: esa llamada no es cancelable, punto, y hay que rediseñar o aceptar el coste.
La alternativa más simple, cuando aplica: si lo que necesitas es simplemente esperar, usa delay() en vez de Thread.sleep(). delay() es una función suspend de verdad, conoce la cancelación de forma nativa y no bloquea ningún hilo.
Dónde muerde esto en la práctica
Rara vez se ve con un Thread.sleep() tan explícito — ese es el ejemplo de libro. Donde aparece de verdad es envuelto en capas: un repositorio que llama a un driver de base de datos bloqueante, un cliente HTTP viejo sin soporte de coroutines, una librería de terceros con un método connect() que tarda lo que tarda. Todo eso suele acabar metido en un withContext(Dispatchers.IO) { ... } porque "es IO, así que va en el dispatcher de IO" — correcto — pero sin que nadie se pare a comprobar si esa llamada concreta responde a interrupciones.
El síntoma habitual no es una excepción rara, es más sutil: un timeout que debería proteger la app tarda sistemáticamente más de lo esperado, o una cancelación de usuario (cerrar una pantalla, cancelar una petición) no libera el hilo tan rápido como debería.
La comprobación rápida
Antes de envolver cualquier llamada bloqueante en withContext(Dispatchers.IO) y darla por "ya es cancelable", dos preguntas:
- ¿Esta llamada responde a
Thread.interrupt()? Si la respuesta es sí (o no lo sabes, pero la librería es razonablemente moderna), envuélvela enrunInterruptibleen vez de llamarla a pelo. - ¿De verdad necesitas bloquear un hilo, o lo que quieres es simplemente esperar? Si es lo segundo,
delay()resuelve el problema entero sin esta trampa.
No es un detalle académico — es la diferencia entre un timeout que protege tu app de verdad y uno que solo parece protegerla hasta que un día bajo carga no lo hace.
Este es, de hecho, el tipo de restricción que ningún agente de código va a conocer por su cuenta: que una librería concreta de tu stack no respeta interrupciones, lo sabes porque ya te ha mordido antes, no porque esté escrito en ningún sitio. Vale la pena dejarlo por escrito donde el agente (y el próximo que toque este código) lo pueda encontrar, en vez de depender de que alguien se acuerde.
