Fecha de publicación: 2023-11-26
Las coroutines son la forma moderna y elegante de manejar concurrencia en Kotlin. A diferencia de los hilos tradicionales, son livianas, no bloquean hilos del sistema operativo y permiten escribir código asíncrono de forma secuencial y legible. Si alguna vez lidiaste con callback hell o con la complejidad de coordinar hilos manualmente, las coroutines están diseñadas exactamente para resolver esos problemas.
El problema que resuelven
El código asíncrono tradicional tiene tres problemas recurrentes que hacen difícil su mantenimiento:
- Callback hell: anidar callbacks genera código difícil de leer y depurar
- Gestión manual de hilos: crear y coordinar
Threades costoso y propenso a errores - Manejo de errores complejo: propagar excepciones entre hilos requiere código adicional
Las coroutines resuelven todo esto con una propuesta clara: escribir código asíncrono con la misma estructura lineal del código sincrónico. El compilador de Kotlin se encarga de transformarlo en la maquinaria de concurrencia correspondiente.
Conceptos fundamentales
Antes de ver código, conviene entender los cuatro pilares sobre los que se construyen las coroutines:
Funciones
suspend: Son funciones que pueden pausar su ejecución y reanudarla más tarde sin bloquear el hilo en el que corren. Solo pueden ser llamadas desde otra funciónsuspend o desde una coroutine. La palabra clave suspend es una promesa al compilador: “esta función puede tardar, pero no voy a bloquear el hilo mientras espero”.CoroutineScope: Define el ámbito de vida de una coroutine. Cuando el scope se cancela, todas las coroutines dentro de él se cancelan también. Este concepto es la base de la structured concurrency, que veremos más adelante.launch vs async: Son las dos formas de iniciar una coroutine.launch es fire-and-forget: inicia la coroutine y devuelve un Job sin valor de retorno. async inicia la coroutine y devuelve un Deferred<T>, que es una promesa de un valor futuro que se obtiene con await().Dispatchers: Determinan en qué hilo o pool de hilos se ejecuta la coroutine. Kotlin ofrece cuatro opciones principales:
Dispatchers.IO: operaciones de red, base de datos, disco. Pool optimizado para bloqueos de I/O.Dispatchers.Default: operaciones intensivas en CPU como ordenamiento o procesamiento de datos.Dispatchers.Main: hilo principal de la UI, disponible en Android y aplicaciones de escritorio.Dispatchers.Unconfined: no confina la coroutine a un hilo específico. No se recomienda en producción.
Tu primera coroutine
El ejemplo más directo para entender cómo funcionan las coroutines es observar la ejecución concurrente con
runBlocking y launch:kotlin
1import kotlinx.coroutines.*
2
3fun main() = runBlocking {
4 println("Inicio: ${Thread.currentThread().name}")
5
6 launch {
7 delay(1000)
8 println("Coroutine 1: ${Thread.currentThread().name}")
9 }
10
11 launch {
12 delay(500)
13 println("Coroutine 2: ${Thread.currentThread().name}")
14 }
15
16 println("Fin del bloque principal")
17}La salida será la siguiente:
bash
1Inicio: main
2Fin del bloque principal
3Coroutine 2: main
4Coroutine 1: mainObserva tres cosas importantes en este ejemplo:
runBlockingcrea un scope y bloquea el hilo actual hasta que todas las coroutines dentro terminen. Se usa principalmente en funcionesmainy en tests.- La Coroutine 2 termina antes que la Coroutine 1 porque su
delayes menor, aunque fue lanzada después. - Ambas coroutines corren en el mismo hilo
main. No se crean hilos nuevos.
async/await para ejecución paralela
Cuando necesitas el resultado de múltiples operaciones asíncronas,
async permite ejecutarlas en paralelo y luego recolectar los resultados con await(). La diferencia en tiempo de ejecución puede ser significativa:kotlin
1suspend fun fetchUserData(): String {
2 delay(1000) // Simula llamada a API
3 return "Usuario: Jorge"
4}
5
6suspend fun fetchOrders(): List<String> {
7 delay(1500) // Simula consulta a BD
8 return listOf("Orden-001", "Orden-002")
9}
10
11fun main() = runBlocking {
12 val startTime = System.currentTimeMillis()
13
14 // Ejecución secuencial: ~2500ms
15 val user = fetchUserData()
16 val orders = fetchOrders()
17 println("Secuencial: ${System.currentTimeMillis() - startTime}ms")
18
19 val startParallel = System.currentTimeMillis()
20
21 // Ejecución paralela: ~1500ms
22 val userDeferred = async { fetchUserData() }
23 val ordersDeferred = async { fetchOrders() }
24
25 val userResult = userDeferred.await()
26 val ordersResult = ordersDeferred.await()
27
28 println("Paralelo: ${System.currentTimeMillis() - startParallel}ms")
29 println("Usuario: $userResult")
30 println("Órdenes: $ordersResult")
31}La clave está en que
async inicia la coroutine de inmediato pero no espera su resultado. Al llamar a await() es cuando la coroutine actual se suspende hasta que elDeferred tiene un valor. Con ambas coroutines corriendo en paralelo, el tiempo total es aproximadamente el de la operación más lenta, no la suma de ambas.Dispatchers en detalle
La elección del Dispatcher correcto es importante para el rendimiento. Usar
Dispatchers.Defaultpara operaciones de red o Dispatchers.IO para cálculos intensivos no causará errores, pero sí puede degradar el rendimiento. La función withContext permite cambiar el Dispatcher dentro de una función suspend:kotlin
1suspend fun processData(): List<String> = coroutineScope {
2 // Leer de base de datos en el pool de I/O
3 val data = withContext(Dispatchers.IO) {
4 fetchFromDatabase()
5 }
6
7 // Procesamiento pesado en el pool de CPU
8 val result = withContext(Dispatchers.Default) {
9 data.map { item -> transform(item) }
10 }
11
12 result
13}withContext no crea una nueva coroutine: simplemente cambia el contexto de ejecución de la coroutine actual. Cuando el bloque termina, la ejecución vuelve al Dispatcher original. Esto es semánticamente más limpio que crear coroutines anidadas solo para cambiar el hilo.Structured Concurrency: por qué importa
La structured concurrency es uno de los conceptos más importantes de las coroutines y frecuentemente el menos comprendido. La idea central es simple: las coroutines hijas no pueden vivir más que su scope padre.
Esto significa que si el scope se cancela o falla, todas sus coroutines hijas se cancelan automáticamente. Esto previene dos problemas clásicos de la concurrencia:
- Memory leaks: coroutines que siguen corriendo aunque ya nadie las necesita
- Zombie coroutines: tareas en background que consumen recursos sin control
kotlin
1// coroutineScope: falla si alguna coroutine hija falla
2suspend fun fetchAllData(): Pair<String, List<String>> = coroutineScope {
3 val userDeferred = async { fetchUserData() }
4 val ordersDeferred = async { fetchOrders() }
5
6 // Si cualquiera de las dos falla, el scope completo se cancela
7 Pair(userDeferred.await(), ordersDeferred.await())
8}
9
10// supervisorScope: permite que las coroutines hijas fallen de forma independiente
11suspend fun fetchWithFallback() = supervisorScope {
12 val userDeferred = async { fetchUserData() }
13 val ordersDeferred = async { fetchOrders() }
14
15 val user = try {
16 userDeferred.await()
17 } catch (e: Exception) {
18 "Usuario desconocido" // Fallback si falla
19 }
20
21 val orders = try {
22 ordersDeferred.await()
23 } catch (e: Exception) {
24 emptyList<String>() // Fallback si falla
25 }
26
27 Pair(user, orders)
28}La diferencia entre
coroutineScope y supervisorScope es su política ante errores. Usa coroutineScope cuando el fallo de cualquier parte invalida el resultado completo. Usa supervisorScope cuando quieres que cada coroutine hija maneje sus propios errores de forma independiente.Manejo de errores
Las excepciones en coroutines se propagan de forma estructurada. Para capturar errores de forma global en una coroutine lanzada con
launch, se usa CoroutineExceptionHandler:kotlin
1val handler = CoroutineExceptionHandler { _, exception ->
2 println("Error capturado: ${exception.message}")
3}
4
5fun main() = runBlocking {
6 val job = launch(handler) {
7 throw RuntimeException("Algo salió mal")
8 }
9 job.join()
10 println("El programa continúa después del error")
11}Para coroutines lanzadas con
async, el comportamiento es diferente: la excepción no se lanza hasta que se llama a await(). Esto permite un manejo más explícito contry/catch:kotlin
1fun main() = runBlocking {
2 val deferred = async {
3 throw RuntimeException("Error en async")
4 "Este valor nunca se devuelve"
5 }
6
7 try {
8 val result = deferred.await()
9 } catch (e: RuntimeException) {
10 println("Capturado: ${e.message}")
11 }
12}Caso practico: llamadas HTTP paralelas
Un caso de uso muy común es un servicio que necesita datos de múltiples APIs externas para construir una respuesta. Sin coroutines, esto se haría de forma secuencial o con una complejidad considerable. Con coroutines, la solución es directa:
kotlin
1data class DashboardData(
2 val user: UserProfile,
3 val orders: List<Order>,
4 val notifications: List<Notification>
5)
6
7class DashboardService(
8 private val userClient: UserApiClient,
9 private val ordersClient: OrdersApiClient,
10 private val notificationsClient: NotificationsApiClient
11) {
12 suspend fun getDashboardData(userId: String): DashboardData = coroutineScope {
13 // Las tres llamadas se inician en paralelo
14 val userDeferred = async(Dispatchers.IO) {
15 userClient.getUser(userId)
16 }
17 val ordersDeferred = async(Dispatchers.IO) {
18 ordersClient.getOrdersByUser(userId)
19 }
20 val notificationsDeferred = async(Dispatchers.IO) {
21 notificationsClient.getUnread(userId)
22 }
23
24 // Se esperan los tres resultados
25 DashboardData(
26 user = userDeferred.await(),
27 orders = ordersDeferred.await(),
28 notifications = notificationsDeferred.await()
29 )
30 }
31}En lugar de esperar el tiempo total de las tres llamadas en secuencia, el tiempo de respuesta queda determinado por la llamada más lenta. Si cada una tarda 300ms en promedio, la versión secuencial tarda ~900ms; la versión paralela tarda ~300ms.
Coroutines en Spring Boot
Spring Boot 5+ con WebFlux soporta funciones
suspend directamente en los controladores, lo que permite escribir endpoints no bloqueantes con la sintaxis limpia de las coroutines:kotlin
1@RestController
2@RequestMapping("/api")
3class DashboardController(private val dashboardService: DashboardService) {
4
5 @GetMapping("/dashboard/{userId}")
6 suspend fun getDashboard(@PathVariable userId: String): DashboardData {
7 return dashboardService.getDashboardData(userId)
8 }
9}Para habilitar soporte de coroutines en Spring Boot, agrega la dependencia correspondiente en tu
build.gradle.kts:kotlin
1dependencies {
2 implementation("org.springframework.boot:spring-boot-starter-webflux")
3 implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
4 implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
5}Con esta configuración, Spring Boot convierte automáticamente las funciones
suspend en el flujo reactivo que WebFlux espera. No es necesario usar Mono ni Fluxdirectamente: las coroutines se encargan de la integración.Conclusion
Las coroutines no son un lujo opcional en Kotlin: son la forma idiomática de manejar concurrencia en el ecosistema moderno del lenguaje. Simplifican el código asíncrono al permitir escribirlo de forma secuencial, eliminan el callback hell, y su modelo de structured concurrency previene errores comunes como memory leaks y zombie coroutines. Si escribes Kotlin para Android, para servicios Spring Boot, o para cualquier sistema que gestione operaciones asíncronas, las coroutines son la herramienta que debes dominar.


