Si alguna vez has sentido que tu aplicación de Android se queda congelada al cargar unos datos o que el código asíncrono se vuelve un laberinto de callbacks, es que necesitas dominar las corrutinas de Kotlin. Básicamente, estas herramientas nos permiten escribir procesos que no bloquean el hilo principal, logrando que la interfaz de usuario se mantenga fluida mientras hacemos tareas pesadas por detrás.
El truco para que esto funcione a la perfección no es solo lanzar corrutinas al azar, sino saber exactamente dónde se ejecutan. Aquí es donde entran los Dispatchers, que actúan como directores de orquesta decidiendo qué hilo se encarga de cada tarea para no saturar la CPU ni dejar la pantalla colgada.
Los Pilares de la Ejecución: Dispatchers
En Kotlin, no podemos lanzar una corrutina al vacío; siempre necesita un despachador. El Dispatcher.Main es el encargado de todo lo que el usuario ve. Se usa exclusivamente para interactuar con la UI, actualizar un LiveData o lanzar funciones rápidas. Si intentas hacer un cálculo complejo aquí, la app se lagueará inevitablemente.
Para las tareas que requieren leer archivos, escribir en una base de datos Room o hacer peticiones HTTP, tenemos el Dispatchers.IO. Este está optimizado para tareas de entrada y salida, donde el hilo pasa mucho tiempo esperando una respuesta externa. Por eso, este despachador puede escalar hasta 64 hilos, ya que no consume CPU intensivamente mientras espera.
Cuando el problema es la potencia de cálculo, como procesar un JSON enorme o ordenar una lista gigante, el Dispatchers.Default es la opción correcta. A diferencia del de IO, este se ajusta al número de núcleos físicos de tu procesador. No tiene sentido crear cien hilos si solo tienes cuatro núcleos, porque el sistema perdería tiempo precioso en el cambio de contexto.
Existe también el Dispatchers.Unconfined, aunque es muy raro verlo en proyectos reales. Este comienza la ejecución en el hilo donde se llama y luego se reanuda donde sea que la función suspendida haya terminado, por lo que no se recomienda a menos que sepas exactamente qué estás haciendo.
Controlando la ejecución con withContext y la Seguridad del Main Thread
Una de las mejores prácticas en Android es hacer que todas las funciones sean seguras para el hilo principal. Esto se logra usando
withContext(). En lugar de obligar a quien llama a la función a saber en qué hilo ejecutarla, la propia función se encarga de cambiar el contexto.Cuando usamos
withContext(Dispatchers.IO), la corrutina se suspende en el hilo actual, se mueve al pool de IO para hacer el trabajo sucio y, una vez terminado, devuelve el resultado al hilo original. Lo mejor es que esto no añade una carga extra de rendimiento comparado con los viejos callbacks y permite escribir código que parece lineal pero que es asíncrono.Es fundamental entender que marcar una función como
suspend no significa que se ejecute automáticamente en un hilo secundario. Una función suspendida puede correr perfectamente en el Main Thread; lo que hace es pausar la ejecución sin bloquear el hilo, guardando el estado en el marco de pila para reanudarlo más tarde.Gestión del Ciclo de Vida: Scopes y Jobs
Lanzar corrutinas con GlobalScope es, en la mayoría de los casos, un error garrafal porque es muy difícil de testear y puede causar fugas de memoria. Lo ideal es usar scopes ligados al ciclo de vida. En Android, contamos con
viewModelScope para los ViewModels y lifecycleScope para las Activities o Fragments.Cuando una Activity se destruye, su
lifecycleScope se cancela automáticamente, lo que a su vez detiene todas las corrutinas activas en ese ámbito. Esto evita que la app intente actualizar una interfaz que ya no existe, previniendo los típicos crashes por referencias nulas.Cada vez que usamos
launch o async, obtenemos un objeto Job. Este Job es como un mando a distancia que nos permite controlar la corrutina. Podemos usar job.cancel() para abortar la tarea o job.join() si necesitamos esperar a que una corrutina termine antes de seguir con la siguiente.Concurrencia y Paralelismo Real
Mucha gente confunde concurrencia con paralelismo. La concurrencia es como un malabarista que maneja varias bolas; parece que todas vuelan a la vez, pero solo toca una en cada momento. El paralelismo real ocurre cuando tenemos varios núcleos de CPU trabajando simultáneamente en tareas distintas.
Para lograr esto, usamos el constructor
async, que nos devuelve un Deferred. Si lanzamos dos tareas con async(Dispatchers.Default) en un móvil con varios núcleos, ambas se ejecutarán exactamente al mismo tiempo. Para recuperar los valores, simplemente llamamos a await().Si queremos lanzar varias tareas y asegurarnos de que todas terminen antes de seguir, lo más limpio es usar
coroutineScope { ... }. Esto crea un entorno de concurrencia estructurada donde, si una de las tareas hijas falla, el scope gestiona el error y evita que las excepciones se filtren silenciosamente, algo que podría pasar si usamos async sin el await correspondiente.Anatomía del Context Switching
El cambio de contexto o context switching es el proceso donde el sistema operativo pausa un hilo, guarda su estado y carga otro. Aunque las corrutinas son ligeras, cambiar entre
Dispatchers.Default y Dispatchers.IO implica mover la ejecución entre diferentes pools de hilos.Si abusamos de los cambios de contexto, el rendimiento puede bajar. Sin embargo, el framework de Kotlin es muy listo y optimiza los saltos. Si ya estás en un pool de hilos compatible, el sistema intentará mantenerte en el mismo hilo para evitar el overhead innecesario de guardar y cargar registros de la CPU.
A tener en cuenta: el uso de pools de hilos no garantiza que una corrutina se ejecute siempre en el mismo hilo de principio a fin. Después de un
suspend y un resume, es posible que la corrutina despierte en un hilo diferente, por lo que depender de variables locales del hilo (ThreadLocal) puede ser arriesgado.Para dominar el flujo asíncrono, lo ideal es lanzar la corrutina en el hilo principal mediante el scope adecuado, delegar las tareas pesadas a
Dispatchers.IO o Default usando withContext, y aprovechar la potencia de async cuando necesitemos resultados en paralelo para que la aplicación vuele y la experiencia del usuario sea impecable.Continúar leyendo...