Noticia Cancelación cooperativa de Coroutines: Cómo y cuándo detener una tarea en segundo plano

Cancelación cooperativa de Coroutines


Seguro que alguna vez te ha pasado que lanzas un proceso en segundo plano y, de repente, el usuario cierra la pantalla o cambia de opinión. Si no gestionas bien ese hilo, te encuentras con que la aplicación sigue currando como si nada, consumiendo batería y memoria RAM como si no hubiera un mañana. Aquí es donde entra en juego la cancelación cooperativa, un concepto fundamental para que nuestras aplicaciones no se vuelvan locas y sean realmente eficientes.

Para que esto funcione, no basta con dar una orden de «parar»; la corrutina debe estar dispuesta a colaborar y comprobar si ya no es necesaria. Ya sea que estés programando en el ecosistema de Android con Kotlin Coroutines o dándole al Python con asyncio, entender cuándo y cómo detener una tarea es la diferencia entre una app profesional y una que se cierra sola por falta de recursos.

El concepto de cooperatividad en la detención​


En el mundo de las corrutinas, la cancelación no es un hachazo fulminante. Es decir, si lanzas un Job y luego llamas a cancel(), la corrutina no se detiene instantáneamente si está ejecutando un bucle pesado de CPU. Para que el proceso sea efectivo, la tarea debe ser capaz de suspenderse o verificar explícitamente si ha sido cancelada.

Una forma muy efectiva de lograr esto en Kotlin es mediante la función ensureActive(), que lanza una excepción de cancelación si la corrutina ya no está activa. Por otro lado, todas las funciones de suspensión estándar, como delay o withContext, ya vienen preparadas para esto, por lo que si tu código depende de ellas, ya tienes gran parte del camino hecho.

Gestión de Scopes y Dispatchers para evitar fugas​


No podemos lanzar tareas al aire sin control. El uso de GlobalScope es, en general, una mala idea porque crea corrutinas que no se detienen automáticamente, lo que facilita la aparición de fugas de memoria. Lo ideal es utilizar alcances ligados al ciclo de vida del componente, como el viewModelScope en Android, que se limpia solo cuando el ViewModel se destruye.

Para que el rendimiento sea óptimo, debemos elegir bien dónde se ejecuta el trabajo. El Dispatchers.Main es sagrado para la UI y solo debe hacer cosas ligeras. Si necesitamos leer un archivo o hacer una petición a una API, debemos saltar al Dispatchers.IO. Y si tenemos que procesar un JSON gigante o hacer cálculos matemáticos complejos, lo lógico es usar Dispatchers.Default, que aprovecha todos los núcleos de la CPU.

La concurrencia estructurada y los TaskGroups​


Cuando manejamos múltiples tareas a la vez, la concurrencia estructurada es nuestra mejor aliada. En Python, la clase TaskGroup permite lanzar varias tareas y asegurarse de que todas finalicen antes de salir del bloque. Lo interesante es que si una de las tareas falla, el grupo cancela automáticamente el resto de los procesos hermanos, evitando que queden tareas zombis ejecutándose en el fondo.

En Kotlin, podemos conseguir un efecto similar con coroutineScope o supervisorScope. La diferencia es que el supervisor no tumba a los hermanos si uno falla, lo cual es vital cuando las tareas son independientes entre sí y no queremos que un error puntual detenga todo el flujo de trabajo.

Manejo de errores y la trampa de las excepciones​


Aquí es donde muchos programadores meten la pata. Al capturar excepciones con un bloque try-catch, es muy común atrapar todas las Exception genéricas. El problema es que la CancellationException es la señal que usa el sistema para detener la corrutina. Si la capturas y no la vuelves a lanzar, estás «engañando» al sistema y la tarea seguirá ejecutándose aunque le hayas pedido que pare.

La regla de oro es capturar excepciones específicas, como IOException, y dejar que las de cancelación fluyan libremente. Solo si necesitas hacer una limpieza profunda de recursos (como cerrar un archivo o una conexión), puedes usar un bloque finally para asegurarte de que todo quede niquelado antes de que la corrutina desaparezca definitivamente.

Flujos de datos reactivos con Flow y asyncio​


Para casos donde no queremos un único resultado sino un flujo constante de datos, el uso de StateFlow y SharedFlow en Kotlin o los iteradores asíncronos en Python es la clave. En Android, el patrón de usar un StateFlow en el repositorio y recolectarlo en la UI mediante collectAsStateWithLifecycle permite que la recolección de datos se pause automáticamente cuando la app pasa a segundo plano.

Si tienes una funcionalidad de búsqueda donde el usuario escribe letra a letra, no quieres lanzar diez peticiones al servidor. Aquí es donde collectLatest brilla, ya que cancela automáticamente la recolección anterior en cuanto llega un nuevo valor, optimizando el ancho de banda y la carga del dispositivo.

Sincronización y control de tiempo​


A veces, una tarea se queda colgada y necesitamos un límite. El uso de withTimeout en Kotlin o asyncio.timeout() en Python nos permite definir un tiempo máximo de espera. Si la tarea no termina en ese plazo, se lanza un error de tiempo agotado y se procede a la cancelación de la tarea, evitando que el usuario se quede mirando una pantalla de carga infinita.

Para aquellos que necesitan proteger una operación crítica y que no debe cancelarse bajo ninguna circunstancia, existe la función shield() en asyncio. Esta crea una capa de protección que impide que la cancelación externa afecte al proceso interno, aunque se recomienda usarla con mucha cautela para no generar procesos incontrolables.

Continúar leyendo...