Kotlin: Coroutines y asincronía

Kotlin ofrece coroutines para manejar la asincronía de manera eficiente. Aprende cómo utilizarlas para construir aplicaciones concurrentes y mantener código limpio.

Aprende Kotlin GRATIS y certifícate

Las aplicaciones modernas a menudo requieren manejar operaciones asíncronas y concurrentes, como solicitudes de red, acceso a bases de datos o procesamiento de grandes volúmenes de datos. Kotlin proporciona coroutines como una solución eficiente para manejar la asincronía sin la complejidad que implica el uso de hilos tradicionales.

Fundamentos de las coroutines

Las coroutines en Kotlin permiten escribir código asíncrono de manera secuencial, facilitando la lectura y mantenimiento del código. A diferencia de los hilos, las coroutines son ligeras y pueden ejecutarse en un mismo hilo sin bloquearlo, lo que mejora el rendimiento de las aplicaciones.

Creación y lanzamiento de coroutines

Para iniciar una coroutine, se utilizan builders proporcionados por la librería kotlinx.coroutines, como launch y async.

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        delay(1000L)
        println("Coroutines en Kotlin")
    }
    println("Iniciando")
}

En este ejemplo:

  • runBlocking es una función que inicia un bloque de coroutines y bloquea el hilo principal hasta que finaliza.
  • launch inicia una nueva coroutine sin bloquear el hilo actual.
  • delay suspende la coroutine durante el tiempo especificado sin bloquear el hilo.

Funciones de suspensión

Las funciones marcadas con el modificador suspend pueden suspender su ejecución sin bloquear el hilo.

suspend fun fetchData(): String {
    delay(1000L) // Simula una operación de red
    return "Datos obtenidos"
}

Estas funciones pueden ser llamadas desde otras coroutines y permiten construir operaciones asíncronas de forma natural.

Builders de coroutines

launch

Inicia una nueva coroutine y no devuelve ningún resultado directo.

launch {
    // Código asíncrono
}

async

Inicia una coroutine y devuelve un Deferred, que representa un resultado futuro.

val resultado = async {
    // Cálculos intensivos
    42
}
println("El resultado es ${resultado.await()}")

Con await(), se espera el resultado de forma asíncrona.

Contexto y alcance de las coroutines

El contexto de una coroutine incluye información como el Job y el Dispatcher, que determina en qué hilo o hilos se ejecuta.

Dispatchers

  • Dispatchers.Default: Para operaciones intensivas en CPU.
  • Dispatchers.IO: Optimizado para operaciones de entrada/salida como acceso a archivos o redes.
  • Dispatchers.Main: Para actualizar interfaces de usuario en el hilo principal (especialmente en Android).
launch(Dispatchers.IO) {
    // Operaciones de E/S
}

Manejo de excepciones

Las coroutines ofrecen mecanismos para manejar excepciones de forma estructurada.

val handler = CoroutineExceptionHandler { _, exception ->
    println("Excepción capturada: ${exception.message}")
}

runBlocking {
    launch(handler) {
        throw RuntimeException("Error en coroutine")
    }
}
  • CoroutineExceptionHandler captura excepciones no manejadas en coroutines.
  • Es importante manejar excepciones para evitar que errores silenciosos afecten la aplicación.

Combinación de coroutines

Las coroutines pueden combinarse para ejecutar operaciones concurrentes y esperar a que todas finalicen.

suspend fun calcularResultado(): Int = coroutineScope {
    val uno = async { calcularUno() }
    val dos = async { calcularDos() }
    uno.await() + dos.await()
}

suspend fun calcularUno(): Int {
    delay(1000L)
    return 20
}

suspend fun calcularDos(): Int {
    delay(1000L)
    return 22
}

En este ejemplo, las funciones calcularUno y calcularDos se ejecutan en paralelo, y se espera por ambos resultados antes de continuar.

Secuencias asíncronas con Flow

Flow es una API fría y asíncrona para manejar flujos de datos de manera reactiva.

import kotlinx.coroutines.flow.*

fun obtenerNumeros(): Flow<Int> = flow {
    for (i in 1..3) {
        delay(100L)
        emit(i)
    }
}

fun main() = runBlocking {
    obtenerNumeros().collect { valor ->
        println("Recibido: $valor")
    }
}
  • flow construye un flujo de datos.
  • collect recolecta los valores emitidos.
  • Ideal para transmitir múltiples valores de forma asíncrona.

Cancelación de coroutines

Las coroutines son cancelables y cooperan con el mecanismo de cancelación.

val job = launch {
    repeat(1000) { i ->
        println("Trabajo $i")
        delay(500L)
    }
}

delay(2000L)
println("Cancelando coroutine")
job.cancelAndJoin()
println("Coroutine cancelada")
  • cancelAndJoin cancela la coroutine y espera a que finalice.
  • Es importante que las coroutines sean cooperativas, utilizando funciones de suspensión que verifican el estado de cancelación.

Tiempo de espera con withTimeout

Para limitar el tiempo de ejecución de una coroutine, se utiliza withTimeout.

try {
    withTimeout(1300L) {
        repeat(1000) { i ->
            println("Trabajo $i")
            delay(500L)
        }
    }
} catch (e: TimeoutCancellationException) {
    println("Tiempo de espera excedido")
}
  • withTimeout lanza una excepción TimeoutCancellationException si el bloque supera el tiempo especificado.
  • Permite establecer límites claros en operaciones potencialmente largas.

Estructura y alcance de coroutines

Es recomendable utilizar scopes específicos para controlar el ciclo de vida de las coroutines.

class MiClase {
    private val scope = CoroutineScope(Dispatchers.Default)

    fun iniciar() {
        scope.launch {
            // Tarea asíncrona
        }
    }

    fun detener() {
        scope.cancel() // Cancela todas las coroutines iniciadas en este scope
    }
}
  • CoroutineScope agrupa coroutines para una gestión conjunta.
  • Facilita la limpieza y cancelación cuando ya no son necesarias.

Integración en aplicaciones Android

En Android, existen scopes proporcionados por componentes de arquitectura para facilitar el uso de coroutines respetando el ciclo de vida.

viewModelScope

En ViewModels, se utiliza viewModelScope para iniciar coroutines que se cancelan automáticamente cuando el ViewModel se destruye.

class MiViewModel : ViewModel() {
    fun cargarDatos() {
        viewModelScope.launch {
            val datos = repositorio.obtenerDatos()
            // Actualizar UI
        }
    }
}

lifecycleScope

En Activities y Fragments, lifecycleScope permite lanzar coroutines que se cancelan según el estado del ciclo de vida.

class MiActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycleScope.launch {
            // Operaciones que se cancelan en onDestroy
        }
    }
}

Buenas prácticas con coroutines

  • Estructura jerárquica: Utilizar scopes y contextos adecuados para organizar coroutines.
  • Manejo de excepciones: Capturar y manejar excepciones para evitar comportamientos inesperados.
  • Evitar GlobalScope: Preferir scopes específicos para controlar el ciclo de vida y evitar fugas de memoria.
  • Funciones de suspensión eficientes: Asegurarse de que las funciones marcadas como suspend sean realmente asíncronas o que puedan suspenderse.
  • Uso adecuado de Dispatchers: Seleccionar el dispatcher correcto según la naturaleza de la operación (CPU, E/S, UI).
Empezar curso de Kotlin

Lecciones de este módulo de Kotlin

Lecciones de programación del módulo Coroutines y asincronía del curso de Kotlin.

Ejercicios de programación en este módulo de Kotlin

Evalúa tus conocimientos en Coroutines y asincronía con ejercicios de programación Coroutines y asincronía de tipo Test, Puzzle, Código y Proyecto con VSCode.