Guía de estudio 03 · Práctica 3

Corrutinas, concurrencia y operaciones asíncronas en Android

Kotlin Coroutines, suspensión, concurrencia estructurada, cancelación y dispatchers

30 min de lectura Tema de la Práctica 3 Independiente del ejemplo de la práctica

Introducción

Una aplicación móvil debe responder a los gestos del usuario mientras carga información, espera una respuesta de red o ejecuta operaciones que requieren tiempo. Si estas operaciones bloquean el hilo principal, la interfaz deja de actualizarse y puede dejar de responder. Kotlin ofrece corrutinas para expresar trabajo asíncrono con un estilo de código secuencial y mecanismos explícitos para controlar su ejecución, sus resultados y su cancelación.

Esta guía estudia las funciones de suspensión, la diferencia entre secuencialidad y concurrencia, las herramientas launch, async y coroutineScope, la relación de las corrutinas con Jetpack Compose y ViewModel, y la selección de ámbitos y dispatchers. Los ejemplos representan tareas genéricas, consultas y componentes de interfaz que pueden adaptarse a diferentes aplicaciones Android.

El objetivo es interpretar qué hace el código, cuándo se ejecuta cada instrucción, cuál es el ciclo de vida del trabajo y qué errores aparecen cuando se confunden suspensión, concurrencia y paralelismo.

Resultados de aprendizaje

Al estudiar el material, se espera que puedas:

  • Distinguir suspensión y bloqueo para explicar por qué determinadas operaciones afectan la respuesta de la interfaz.
  • Interpretar el orden de ejecución de funciones suspend, launch, async y await para estimar el comportamiento temporal del programa.
  • Aplicar concurrencia estructurada para coordinar tareas relacionadas y esperar su finalización.
  • Reconocer los mecanismos de cancelación cooperativa y su relación con el ciclo de vida de la interfaz.
  • Seleccionar ámbitos y dispatchers según la responsabilidad, la duración y el tipo de trabajo.

Lo imprescindible para el examen

Diferenciar suspensión, bloqueo y concurrencia; interpretar launch, async y await; elegir ámbitos y dispatchers; explicar cancelación y concurrencia estructurada.

Se evalúa la capacidad de explicar, interpretar fragmentos y justificar decisiones. Las versiones puntuales de librerías, URLs, nombres de proyecto y comandos accesorios quedan como contexto de consulta.

Criterios transversales para interpretar esta guía

Principio transferible. Conviene distinguir la responsabilidad de una tecnología de la forma concreta en que se utilizó en la práctica. Un ejemplo de ViewModel, Retrofit, Hilt o WebSocket ilustra una solución posible; su configuración exacta no es una regla universal.

Decisión del proyecto. Los nombres, rutas, versiones, intervalos, tamaños y elecciones de librerías del caso original sirven para interpretar el código. Salvo indicación expresa, no se exige memorizarlos para el examen.

Alcance del código. Los fragmentos son recursos didácticos; pueden omitir imports, dependencias, permisos, detalles de manejo de errores o infraestructura. Comprender un fragmento no equivale a comprobar que compila como proyecto independiente.

Referencias cruzadas. La síntesis transversal de la colección reúne las distinciones entre estado y evento, persistencia, reintentos, idempotencia y límites de las pruebas.

1. Trabajo síncrono, hilos y suspensión

1.1 Por qué importa el hilo principal

En Android, el hilo principal procesa interacciones del usuario y trabajo relacionado con la interfaz. Cuando una operación larga ocupa ese hilo sin ceder el control, otros eventos deben esperar. El resultado puede ser una pantalla que no responde o que tarda en actualizarse.

Un hilo es un recurso de ejecución del sistema operativo. Una corrutina es una unidad de trabajo que puede suspenderse y reanudarse sin mantener necesariamente ocupado un hilo durante la espera. Las corrutinas se ejecutan sobre hilos, pero no necesitan disponer de uno exclusivo durante toda su existencia.

1.2 delay() frente a Thread.sleep()

import kotlinx.coroutines.delay

suspend fun esperarRespuesta() {
    delay(1000)
    println("Espera finalizada")
}

delay(1000) suspende la corrutina aproximadamente un segundo sin bloquear el hilo mientras espera. En cambio, Thread.sleep(1000) bloquea el hilo desde el que se invoca. Si ese hilo es el principal de Android, se impide temporalmente que procese otros eventos de la interfaz.

Mecanismo Qué espera ¿Bloquea el hilo durante la espera? Uso típico
delay(1000) La corrutina No Simulación de espera, temporización asíncrona
Thread.sleep(1000) El hilo actual Sí Código bloqueante fuera del hilo principal, en casos justificados

Importante: que una función tenga el modificador suspend no garantiza que su implementación sea no bloqueante. Si ejecuta un cálculo prolongado o invoca una API bloqueante sin cambiar a un dispatcher adecuado, puede seguir ocupando el hilo principal.

1.3 runBlocking() y el contexto de los ejemplos

Para ejecutar pequeños ejemplos desde una función main() convencional puede utilizarse:

import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    println("Inicio")
    delay(1000)
    println("Fin")
}

runBlocking permite acceder a funciones de suspensión desde código convencional, pero bloquea el hilo que lo invoca hasta terminar. Resulta útil en ejemplos de consola y en determinados contextos de prueba; no debe utilizarse en el hilo principal para ejecutar operaciones de una aplicación Android. Más adelante se presentan los ámbitos adecuados para Compose y ViewModel.

2. Funciones suspend y ejecución secuencial

Una función declarada con suspend puede invocar otras funciones de suspensión. El modificador indica que su ejecución puede suspenderse; no crea por sí mismo otra corrutina ni implica concurrencia.

import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking

suspend fun consultarPerfil(): String {
    delay(1000)
    return "Perfil"
}

suspend fun consultarPreferencias(): String {
    delay(1000)
    return "Preferencias"
}

fun main() = runBlocking {
    val perfil = consultarPerfil()
    val preferencias = consultarPreferencias()
    println("$perfil y $preferencias")
}

Las consultas se realizan una después de la otra. En este ejemplo, como cada función tarda aproximadamente un segundo, la operación completa tarda alrededor de dos segundos. La segunda función empieza después de que la primera devuelve su resultado.

Una función suspend suele llamarse desde otra función de suspensión o desde el bloque de una corrutina. El código de un controlador de clic convencional, por ejemplo, no puede llamar directamente a delay() sin iniciar o disponer de una corrutina.

Principio: suspensión y concurrencia son conceptos diferentes. El orden de las llamadas sigue siendo secuencial mientras no se inicie trabajo concurrente.

3. launch, Job y ejecución concurrente

launch inicia una nueva corrutina dentro de un CoroutineScope y devuelve un Job, que permite observar o cancelar ese trabajo. El bloque que invoca launch puede seguir ejecutando sus instrucciones sin esperar explícitamente la finalización de la nueva corrutina.

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("A. Inicio")

    val primera: Job = launch {
        delay(1000)
        println("C. Primera consulta terminada")
    }

    val segunda: Job = launch {
        delay(1000)
        println("D. Segunda consulta terminada")
    }

    println("B. Consultas iniciadas")
}

En este ejemplo, B aparece antes de los mensajes que siguen a los delay, y las dos consultas pueden progresar concurrentemente. Su duración total es cercana a un segundo, con una pequeña sobrecarga. El orden entre C y D no debe asumirse como una garantía general.

3.1 Concurrencia no equivale a paralelismo

  • Secuencialidad: una operación termina antes de que comience la siguiente.
  • Concurrencia: varias operaciones están en curso durante intervalos que se superponen y pueden alternar su ejecución.
  • Paralelismo: diferentes operaciones se ejecutan físicamente al mismo tiempo, normalmente en distintos núcleos o hilos.

Dos corrutinas que utilizan delay() pueden progresar concurrentemente aunque se reanuden en un solo hilo. launch no garantiza ejecución paralela: esto depende del dispatcher y de los recursos disponibles.

3.2 Job.join()

launch devuelve un Job, pero el trabajo lanzado no tiene por qué producir un resultado de tipo T. Cuando se necesita esperar su finalización puede utilizarse join():

suspend fun sincronizar(): Unit = coroutineScope {
    val tarea = launch { delay(500) }
    tarea.join()
    println("Sincronización completada")
}

join() suspende hasta que termina el trabajo correspondiente; no bloquea el hilo mientras espera. La diferencia entre llamar a launch y esperar con join es esencial al interpretar el orden de los mensajes y las actualizaciones de pantalla.

4. async, Deferred<T> y await()

Cuando una operación concurrente debe producir un valor, puede utilizarse async. Este constructor devuelve un Deferred<T>, un trabajo que contiene un resultado futuro. La función await() suspende a quien la llama hasta que el resultado está disponible, o propaga el fallo correspondiente.

import kotlinx.coroutines.*

suspend fun cargarInicio(): String = coroutineScope {
    val perfil = async { consultarPerfil() }
    val preferencias = async { consultarPreferencias() }

    "${perfil.await()} y ${preferencias.await()}"
}

Ambos trabajos se inician antes del primer await(). Si cada consulta tiene una espera independiente de un segundo, el tiempo conjunto será aproximadamente un segundo.

4.1 Ubicación de await() y tiempo total

Versión secuencial por la ubicación de la espera:

suspend fun cargaSecuencial(): String = coroutineScope {
    val perfil = async { consultarPerfil() }.await()
    val preferencias = async { consultarPreferencias() }.await()
    "$perfil y $preferencias"
}

Aunque aparecen dos llamadas a async, la segunda tarea no se crea hasta que ha terminado la primera. El tiempo esperado será aproximadamente dos segundos.

Versión concurrente:

suspend fun cargaConcurrente(): String = coroutineScope {
    val perfil = async { consultarPerfil() }
    val preferencias = async { consultarPreferencias() }
    "${perfil.await()} y ${preferencias.await()}"
}

El tiempo esperado será aproximadamente un segundo. Estas duraciones son ilustrativas, no compromisos exactos de planificación del sistema.

Herramienta Retorno Finalidad principal Cómo esperar
launch Job Ejecutar un trabajo que no necesita devolver un valor join()
async Deferred<T> Ejecutar un trabajo que producirá un resultado await()

En ambos casos, el trabajo puede fallar o ser cancelado. async no debe verse como una forma automática de acelerar cualquier función: solo aporta ventajas de tiempo cuando las operaciones pueden desarrollarse concurrentemente y la coordinación está bien planteada.

5. Concurrencia estructurada y coroutineScope

En la concurrencia estructurada, el trabajo concurrente se organiza dentro de ámbitos con relaciones de responsabilidad y ciclo de vida. Un ámbito espera a sus tareas hijas y coordina su cancelación y sus fallos.

suspend fun obtenerResumen(): String = coroutineScope {
    val perfil = async { consultarPerfil() }
    val preferencias = async { consultarPreferencias() }

    val resultado = "${perfil.await()} / ${preferencias.await()}"
    resultado
}

La función coroutineScope suspende a su llamador hasta que su bloque y todas las corrutinas hijas han terminado. Por eso obtenerResumen() ofrece una sola operación coherente a quien la invoca, aunque internamente coordine varias tareas.

5.1 Un error de coordinación frecuente

suspend fun ejecutarImportaciones() = coroutineScope {
    launch { importarProductos() }
    launch { importarPrecios() }
    println("Importaciones terminadas") // Mensaje prematuro
}

launch devuelve el control de inmediato, por lo que el mensaje puede imprimirse antes de que las dos importaciones finalicen. El hecho de que coroutineScope espere a sus hijas al salir del bloque no retrasa automáticamente las instrucciones que ya están dentro.

Una solución es añadir un coroutineScope interior que espere antes de continuar:

suspend fun ejecutarImportaciones() = coroutineScope {
    coroutineScope {
        launch { importarProductos() }
        launch { importarPrecios() }
    }
    println("Importaciones terminadas")
}

Otra alternativa consiste en conservar los Job y hacer join() en ambos antes de mostrar el mensaje. importarProductos() e importarPrecios() representan funciones suspend de la aplicación.

5.2 Errores y tareas hermanas

Con el coroutineScope convencional, si una hija falla con una excepción distinta de una cancelación normal, el ámbito falla y cancela las demás tareas hijas. Es una propiedad útil cuando todas participan en una operación que debe completarse como un conjunto. Existen ámbitos de supervisión para otros requisitos, pero no forman parte del desarrollo principal de esta guía.

6. Cancelación cooperativa de corrutinas

Las corrutinas pueden cancelarse por decisión del programa o cuando termina el ámbito del que dependen. La cancelación es cooperativa: el trabajo debe llegar a puntos que respeten esa señal, como llamadas a funciones suspendidas cancelables, o comprobar explícitamente su estado.

6.1 Cancelar una operación

import kotlinx.coroutines.*

suspend fun demostracionCancelacion() = coroutineScope {
    val trabajo = launch {
        repeat(10) { paso ->
            delay(200)
            println("Paso $paso")
        }
    }

    delay(650)
    trabajo.cancelAndJoin()
    println("Trabajo detenido")
}

cancelAndJoin() solicita la cancelación y espera la terminación. Como el bucle contiene delay, la señal puede atenderse sin bloquear el hilo. El número de pasos impresos depende de la planificación y no debe interpretarse como un valor estrictamente garantizado.

6.2 CancellationException y manejo de errores

La cancelación habitual de una corrutina se comunica mediante CancellationException. Esta excepción forma parte del mecanismo de control, y no debe tratarse como un error funcional ordinario.

suspend fun cargarDatos() {
    try {
        delay(1000)
        println("Datos disponibles")
    } catch (e: kotlinx.coroutines.CancellationException) {
        // Liberar recursos o registrar la cancelación, si hace falta.
        throw e
    }
}

Si se captura una excepción general, debe evitarse consumir silenciosamente una CancellationException:

try {
    ejecutarOperacion()
} catch (e: Exception) {
    if (e is kotlinx.coroutines.CancellationException) throw e
    // Procesar o comunicar el error real.
}

En muchos casos no se necesita ningún catch para la cancelación: basta con permitir que se propague. Un bloque finally puede utilizarse para limpieza. Capturar una excepción de cancelación sin relanzarla puede producir resultados incoherentes o permitir que se ejecuten instrucciones posteriores que ya no deberían realizarse; no significa necesariamente que la tarea siga ejecutándose indefinidamente.

6.3 Cuando no hay puntos de suspensión

Un bucle de cálculo intensivo que nunca suspende puede ignorar temporalmente la solicitud de cancelación. Para esos casos existen comprobaciones como ensureActive() o isActive, según el contexto. No conviene confiar solo en Job.cancel() si el código realiza trabajo de CPU sin interrupciones cooperativas.

6.4 Cancelación y conservación del estado

Cancelar una corrutina y reiniciar el estado son operaciones distintas. Una descarga o una tarea de procesamiento puede pausarse o cancelarse sin que la interfaz borre necesariamente la información que ya mostraba. La decisión depende del modelo de estado de la pantalla y de si la operación admite una reanudación real.

7. Corrutinas y Jetpack Compose

Las funciones @Composable describen la interfaz y pueden ejecutarse repetidamente durante las recomposiciones. Por ello, iniciar trabajo asíncrono directamente desde el cuerpo de un Composable sin utilizar una API de efectos puede generar ejecuciones repetidas no deseadas.

7.1 LaunchedEffect

LaunchedEffect inicia una corrutina vinculada a una instancia del efecto dentro de la composición. Se cancela cuando el efecto abandona la composición y se reinicia cuando cambian sus claves.

@Composable
fun MensajeTemporal(mensaje: String) {
    var visible by remember { mutableStateOf(true) }

    LaunchedEffect(mensaje) {
        visible = true
        delay(2000)
        visible = false
    }

    if (visible) {
        Text(mensaje)
    }
}

Al cambiar mensaje, se cancela el trabajo anterior y se inicia uno nuevo para la nueva clave. Una recomposición con la misma clave no reinicia automáticamente el efecto.

Nota: una clave debe representar la identidad del trabajo que se desea repetir; elegir claves inestables puede ocasionar reinicios no previstos.

7.2 Un efecto condicionado por estado

@Composable
fun TemporizadorSimple() {
    var activo by remember { mutableStateOf(false) }
    var segundos by remember { mutableIntStateOf(0) }

    if (activo) {
        LaunchedEffect(Unit) {
            while (true) {
                delay(1000)
                segundos++
            }
        }
    }

    Button(onClick = { activo = !activo }) {
        Text(if (activo) "Pausar: $segundos" else "Continuar: $segundos")
    }
}

Al cambiar activo a false, el LaunchedEffect deja de formar parte de la composición y se cancela. El valor de segundos permanece porque su estado pertenece al Composable exterior, que sigue en la composición. Si este Composable desaparece o se recrea en determinadas condiciones, remember no garantiza la conservación del contador. Para preservar el valor ante recreaciones de configuración se puede evaluar rememberSaveable o un ViewModel, como se estudió en la guía anterior.

7.3 rememberCoroutineScope() para eventos

Cuando un evento puntual debe lanzar trabajo, rememberCoroutineScope() proporciona un ámbito relacionado con la composición:

@Composable
fun BotonDeNotificacion() {
    val scope = rememberCoroutineScope()
    var texto by remember { mutableStateOf("Listo") }

    Button(onClick = {
        scope.launch {
            texto = "Procesando"
            delay(500)
            texto = "Completado"
        }
    }) {
        Text(texto)
    }
}

En este caso launch se invoca desde el controlador del evento, que no es por sí mismo una función suspend. Cuando el Composable abandona la composición, el ámbito recordado se cancela.

Criterio de selección: LaunchedEffect es adecuado para trabajo que debe iniciarse o reiniciarse como consecuencia de la presencia del componente y de sus claves. rememberCoroutineScope() sirve para iniciar trabajo desde eventos de interfaz. Para cargas y operaciones que pertenecen al estado de pantalla, suele ser más adecuado delegar en un ViewModel.

8. Ámbitos y ciclo de vida

Un CoroutineScope define el contexto en el que se inician y organizan las corrutinas. Seleccionar el ámbito significa decidir a qué componente pertenece el trabajo y cuándo debe cancelarse.

Ámbito o herramienta Duración asociada Uso habitual
LaunchedEffect Mientras su efecto permanece en la composición con las claves correspondientes Trabajo reactivo a la composición
rememberCoroutineScope() Mientras permanece en composición el punto que creó el ámbito Trabajos iniciados por eventos de UI
viewModelScope Hasta que el ViewModel se limpia (onCleared) Cargas de datos y operaciones de estado de pantalla
lifecycleScope Hasta la destrucción del Lifecycle asociado Trabajo vinculado a Activity o Fragment
runBlocking Bloquea hasta que acaba su bloque y su trabajo hijo Ejemplos de consola y casos de prueba específicos

8.1 Uso de viewModelScope

class ProductosViewModel(
    private val repositorio: ProductosRepository
) : ViewModel() {

    var cargando by mutableStateOf(false)
        private set

    var productos by mutableStateOf<List<Producto>>(emptyList())
        private set

    fun cargarProductos() {
        viewModelScope.launch {
            cargando = true
            try {
                productos = repositorio.obtenerProductos()
            } finally {
                cargando = false
            }
        }
    }
}

En este ejemplo, Producto y ProductosRepository representan tipos del dominio y de la capa de datos. Se presupone que obtenerProductos() es una función suspend segura para invocarse desde el hilo principal. Para una aplicación real también sería necesario decidir cómo representar y comunicar errores.

La corrutina pertenece al ViewModel. Si se destruye una pantalla pero el ViewModel continúa siendo propiedad de otro ámbito vigente, su trabajo puede continuar. Por ello, la duración no se decide por el nombre del método viewModelScope, sino por quién es el propietario del ViewModel.

Una corrutina del ViewModel no sobrevive por sí misma a la terminación del proceso de la aplicación. Si una tarea debe continuar de forma confiable tras abandonar la app o finalizar el proceso, se requiere un mecanismo de trabajo persistente adecuado, fuera del alcance de esta guía.

9. Dispatchers y seguridad del hilo principal

Un dispatcher determina dónde se programa la ejecución de una corrutina. La elección depende de si se requiere trabajar con la UI, realizar operaciones bloqueantes o ejecutar cálculos intensivos.

Dispatcher Aplicación habitual Ejemplo
Dispatchers.Main Estado e interacciones de la interfaz Actualizar UiState
Dispatchers.IO Trabajo de E/S bloqueante Leer archivos mediante API bloqueante
Dispatchers.Default Trabajo intensivo de CPU Procesar grandes colecciones o imágenes

La función withContext permite ejecutar un bloque utilizando otro contexto y esperar su resultado:

suspend fun calcularEstadisticas(datos: List<Int>): Double =
    withContext(Dispatchers.Default) {
        if (datos.isEmpty()) 0.0 else datos.average()
    }

Aquí el cálculo se realiza en un dispatcher apropiado para CPU. Al terminar, la ejecución vuelve al contexto del llamador, salvo que se haya cancelado o haya ocurrido un fallo.

9.1 suspend no mueve automáticamente el trabajo a IO

Este fragmento puede bloquear el hilo que lo llama si leerArchivoBloqueante() realiza E/S bloqueante:

suspend fun leerContenido(): String {
    return leerArchivoBloqueante()
}

Para hacer segura su invocación desde el hilo principal se puede encapsular la operación:

suspend fun leerContenido(): String = withContext(Dispatchers.IO) {
    leerArchivoBloqueante()
}

Marcar una función suspend no reemplaza la selección de un dispatcher cuando se utiliza una API bloqueante.

9.2 Bibliotecas que gestionan su propio trabajo asíncrono

Muchas APIs suspendidas de bibliotecas como Room, y las integraciones habituales de Retrofit con funciones suspend, gestionan internamente sus operaciones sin exigir un withContext(Dispatchers.IO) en cada llamada. En esos casos puede invocarse el método suspendido desde viewModelScope, sujeto a las garantías de la API utilizada. Esto no significa que cualquier implementación propia de repositorio sea segura para el hilo principal.

Conexión transversal: alcance y decisiones

Cancelar una operación también libera recursos

La cancelación de una corrutina debe detener el trabajo que depende de ella y liberar lo que abrió cuando corresponda. No se debe absorber inadvertidamente CancellationException en un catch (Exception): el trabajo cancelado no es un fallo de negocio para mostrar o reintentar. En flujos conectados a sockets, la liberación puede requerir awaitClose { fuente.cancel() } (guía 08) o cerrar explícitamente la conexión (guía 12). La existencia de una corrutina no implica que una biblioteca bloqueante respete automáticamente su cancelación.

10. Errores frecuentes y diagnóstico

Síntoma Causa probable Qué revisar
Una pantalla deja de responder durante una operación Código bloqueante o cálculo pesado en el hilo principal Thread.sleep, E/S síncrona, bucles CPU y dispatcher
Dos tareas tardan la suma de sus tiempos Llamadas suspendidas secuenciales o await demasiado temprano Inicio de async o launch y puntos de espera
El mensaje «terminado» aparece antes de finalizar launch inicia sin esperar join, un ámbito interior o una coordinación explícita
Una operación se repite tras recomposiciones Trabajo lanzado directamente en composición o claves inadecuadas LaunchedEffect y ciclo de vida de sus claves
La pausa/cancelación parece ignorarse Se consumió CancellationException o el cálculo no coopera Propagación de cancelación y puntos de comprobación
Un trabajo se cancela al salir de una pantalla Su ámbito dependía de la composición o del ViewModel de ese destino Propietario del ámbito y duración necesaria
Suspend function ... should be called only from a coroutine Llamada suspend desde contexto no suspendido Iniciar la operación en un ámbito pertinente
Unresolved reference: launch Falta un import o el contexto de CoroutineScope Dependencia de corrutinas, import y receptor
El contador no se recompone El valor no es estado observable por Compose mutableIntStateOf, mutableStateOf y lecturas de estado

11. Vocabulario esencial

Concepto Significado
Corrutina Unidad de trabajo que puede suspenderse y reanudarse en un contexto de ejecución
suspend Modificador de una función que puede invocar operaciones de suspensión
Suspensión Pausa de una corrutina sin bloquear necesariamente el hilo
Bloqueo Ocupación de un hilo mientras espera o realiza trabajo
Concurrencia Progreso de varias tareas durante periodos superpuestos
Paralelismo Ejecución simultánea real de tareas
CoroutineScope Contexto de organización y ciclo de vida del trabajo concurrente
Job Representación de un trabajo cancelable y esperable
Deferred<T> Job que produce un resultado de tipo T
await() Espera suspendida del resultado de un Deferred
join() Espera suspendida de la finalización de un Job
Concurrencia estructurada Organización del trabajo hijo dentro de ámbitos con espera y cancelación coordinadas
Dispatcher Política de ejecución que determina los hilos disponibles para el trabajo
Cancelación cooperativa Cancelación que requiere alcanzar puntos suspendibles o comprobar la señal
LaunchedEffect Efecto de Compose que ejecuta una corrutina vinculada a claves y composición
Main-safe Propiedad de una función que se puede invocar desde el hilo principal sin bloquearlo indebidamente

12. Preguntas de autoevaluación

Comprensión conceptual

  1. ¿En qué se diferencian una corrutina y un hilo? Explica por qué delay(1000) y Thread.sleep(1000) no tienen el mismo efecto sobre Android.
  2. ¿Qué garantiza el modificador suspend y qué no garantiza sobre concurrencia, paralelismo y cambio de hilo?
  3. Compara launch y async en términos de valor devuelto, espera y casos de uso.
  4. ¿Qué es la concurrencia estructurada y por qué un ámbito debe esperar a sus tareas hijas?
  5. ¿En qué se diferencian LaunchedEffect, rememberCoroutineScope() y viewModelScope respecto al inicio y duración del trabajo?
  6. ¿Qué significa que una función suspendida sea main-safe? ¿Toda función suspend lo es?

Interpretación de código

  1. Supón que a() y b() son funciones suspendidas, cada una con un único delay(1000). ¿Cuánto tarda este fragmento, aproximadamente, y por qué?

    coroutineScope {
        a()
        b()
    }
  2. ¿Qué cambia respecto a la pregunta anterior?

    coroutineScope {
        val uno = async { a() }
        val dos = async { b() }
        uno.await()
        dos.await()
    }
  3. ¿Por qué println("Listo") puede ejecutarse antes de la finalización de las tareas?

    coroutineScope {
        launch { a() }
        launch { b() }
        println("Listo")
    }
  4. Un estudiante escribe async { a() }.await() antes de iniciar async { b() }. ¿Qué efecto tiene sobre la concurrencia?

  5. Se modifica el valor de una clave de LaunchedEffect(clave). ¿Qué sucede con el trabajo del efecto anterior?

  6. Una corrutina ejecuta un while (true) con cálculos de CPU y sin llamadas suspendidas. ¿Por qué cancel() puede no detenerla oportunamente?

Aplicación y diagnóstico

  1. Una pantalla de productos inicia una consulta cuando se abre. El trabajo no debe reiniciarse solo porque un texto de la UI cambió. Propón dónde ubicar la operación y justifica tu elección.
  2. Una operación de sincronización actualiza el estado de pantalla a «completado» apenas comienza, aunque todavía hay tres tareas en curso. Explica la causa probable y dos formas de coordinar correctamente la finalización.
  3. Una implementación captura Exception, muestra un mensaje de error y continúa. ¿Qué precaución requiere para no alterar el comportamiento de cancelación?
  4. Debes leer un archivo mediante una API bloqueante y después ordenar una colección muy grande. ¿Qué dispatcher utilizarías para cada etapa y por qué?
  5. Un formulario inicia un trabajo desde un botón. Compara las consecuencias de lanzarlo con rememberCoroutineScope() y con viewModelScope si el usuario abandona el destino.
  6. Una tarea debe seguir ejecutándose aunque se cierre la Activity y el proceso termine. ¿Basta con viewModelScope? Explica qué limitación existe.

13. Respuestas posibles

  1. Un hilo es un recurso de ejecución del sistema; una corrutina es un trabajo suspendible ejecutado sobre hilos. delay suspende sin mantener ocupado el hilo, mientras Thread.sleep bloquea el hilo invocador, lo que puede congelar la UI si es el principal.
  2. suspend habilita funciones de suspensión y llamadas desde un contexto apropiado. No inicia otra corrutina, no garantiza simultaneidad ni cambia automáticamente a un dispatcher de trabajo.
  3. launch devuelve un Job y suele utilizarse para trabajo sin resultado; se espera con join. async devuelve Deferred<T> para resultados y se espera con await.
  4. Las tareas quedan vinculadas a un ámbito con responsabilidades de espera, cancelación y gestión de fallos. Así se evita tratar el trabajo hijo como operaciones independientes sin dueño claro.
  5. LaunchedEffect se inicia por composición y claves; rememberCoroutineScope permite lanzar desde eventos y se cancela al abandonar composición; viewModelScope está ligado a la vida del ViewModel.
  6. Significa que la función puede invocarse desde el hilo principal sin bloquearlo indebidamente. No todas las funciones suspend cumplen esa propiedad: depende de su implementación.
  7. Aproximadamente dos segundos. Una llamada empieza después de la otra, porque suspend no implica concurrencia.
  8. Aproximadamente un segundo si ambas esperas son independientes. Los dos async se crean antes del primer await, permitiendo que sus intervalos se superpongan.
  9. launch devuelve el control enseguida. coroutineScope espera a sus hijas al terminar el bloque, pero no retrasa el println situado antes de esa salida.
  10. Hace que la segunda tarea no se inicie hasta haber obtenido el resultado de la primera; el comportamiento temporal vuelve a ser secuencial.
  11. La instancia anterior del efecto se cancela y se inicia una nueva con la clave actual, siempre que el efecto permanezca en la composición.
  12. La cancelación requiere cooperación. Sin puntos suspendibles ni comprobaciones como ensureActive, un cálculo puede continuar ocupando CPU.
  13. Una opción habitual es delegar la carga a un ViewModel mediante viewModelScope, con un estado que represente carga, resultados y error. Alternativamente, LaunchedEffect con claves estables puede servir si el trabajo pertenece estrictamente a la composición. La elección depende del propietario deseado.
  14. Probablemente se inicia trabajo con launch y se cambia el estado inmediatamente después. Se puede esperar con los Job.join() pertinentes o crear un coroutineScope interior que agrupe las tareas antes de actualizar el estado.
  15. Debe reconocer y relanzar CancellationException, o capturar excepciones más específicas. Consumir la cancelación puede hacer que el código trate una interrupción normal como un fallo convencional y continúe indebidamente.
  16. Dispatchers.IO para la API de lectura bloqueante y Dispatchers.Default para el ordenamiento intensivo de CPU, utilizando withContext en la implementación adecuada.
  17. Si la composición desaparece, el ámbito obtenido con rememberCoroutineScope() se cancela. viewModelScope puede continuar mientras el propietario del ViewModel siga vivo; si ese ViewModel se elimina al abandonar el destino, también se cancela.
  18. No. viewModelScope se cancela al limpiar el ViewModel y no sobrevive a la terminación del proceso. La ejecución confiable más allá de esa duración requiere una solución de trabajo persistente apropiada.

Cierre

Comprender las corrutinas implica distinguir qué trabajo puede suspenderse, qué tareas avanzan de forma concurrente, cuándo se espera un resultado y quién es responsable de cancelar la ejecución. Estas decisiones afectan directamente la respuesta de la interfaz, la coherencia del estado y el consumo de recursos de una aplicación Android.

La práctica de analizar pequeñas secuencias de código permite reconocer fallos que pueden quedar ocultos en aplicaciones más grandes. Antes de incorporar una operación asíncrona, conviene identificar su ámbito, su dispatcher, los puntos de espera y su comportamiento ante errores y cancelaciones.

Para cerrar el estudio, reflexiona sobre estas preguntas:

  1. ¿Qué indica que una operación debería ejecutarse fuera del hilo principal?
  2. ¿Cómo distinguirías un problema de secuencialidad de un problema de bloqueo?
  3. ¿Qué ocurriría si una pantalla desaparece mientras una operación asíncrona sigue en curso?
  4. ¿En qué situaciones interesa esperar todas las tareas y en cuáles necesitas únicamente el primer resultado disponible?
  5. ¿Cómo comprobarías que la cancelación de una operación conserva un estado coherente en la interfaz?