Coroutines en Kotlin: launch, async y scope

1. Descripción general

Las coroutines son la forma en que Kotlin maneja código asincrónico sin callback hell ni bloqueo de threads. Este artículo explica cómo funcionan launch, async, suspend fun y CoroutineScope, y cuándo usar cada una. Al terminar, podrás escribir código concurrente limpio, sabrás cómo controlar el ciclo de vida de una coroutine y probarla con JUnit 5.

2. Dependencias necesarias

Las coroutines no vienen incluidas en la librería estándar de Kotlin, necesitas agregar kotlinx-coroutines-core.

<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-core</artifactId>
    <version>1.8.1</version>
</dependency>
<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-test</artifactId>
    <version>1.8.1</version>
    <scope>test</scope>
</dependency>

Si usas Spring Boot, el módulo spring-boot-starter-webflux o spring-boot-starter ya incluye las extensiones de Kotlin que soportan suspend fun en controladores.

3. suspend fun: la unidad básica de coroutine

suspend es una palabra clave que marca una función que puede pausarse (suspend) sin bloquear el thread.

suspend fun fetchOrder(orderId: Long): Order {
    delay(200) // simula una llamada I/O, no bloquea el thread realmente
    return Order(orderId, "PENDING")
}

data class Order(val id: Long, val status: String)

delay() es una función suspend integrada, diferente de Thread.sleep() porque no mantiene el thread en espera. suspend fun solo puede llamarse desde otra suspend fun o desde dentro de un coroutine builder (launch, async, runBlocking).

Nota: Llamar a suspend fun directamente desde una función fun regular (no suspend) resultará en un error de compilación. Este es el mecanismo de Kotlin para obligarte a declarar explícitamente el contexto asincrónico.

4. launch: coroutine de lanzar y olvidar

launch crea una nueva coroutine, devuelve un Job, no devuelve un resultado de cálculo — úsalo cuando solo necesites “ejecutar y olvidar”.

fun main() = runBlocking {
    val job: Job = launch {
        println("Iniciando procesamiento de pedido en thread: ${Thread.currentThread().name}")
        val order = fetchOrder(1L)
        println("Estado del pedido: ${order.status}")
    }
    println("launch ha retornado, coroutine ejecutándose en paralelo")
    job.join() // espera a que la coroutine termine
}
launch ha retornado, coroutine ejecutándose en paralelo
Iniciando procesamiento de pedido en thread: main
Estado del pedido: PENDING

runBlocking crea un scope para ejecutar coroutines en main (solo para demo/test, no uses en código de producción real). launch no bloquea el thread actual — programa la coroutine para ejecutarse y retorna inmediatamente. job.join() es el punto donde esperas a que la coroutine termine.

5. async: coroutine con resultado de retorno

async es similar a launch pero devuelve un Deferred<T> — una “promise” en la que llamas .await() para obtener el resultado.

suspend fun fetchCustomer(customerId: Long): String {
    delay(150)
    return "Customer-$customerId"
}

suspend fun loadOrderDetail(orderId: Long, customerId: Long): String = coroutineScope {
    val orderDeferred = async { fetchOrder(orderId) }
    val customerDeferred = async { fetchCustomer(customerId) }

    val order = orderDeferred.await()
    val customer = customerDeferred.await()
    "Pedido ${order.id} (${order.status}) de $customer"
}

Los dos comandos async se ejecutan en paralelo (concurrente), no secuencialmente. El tiempo total de espera aquí es ~200ms (máximo de 200ms y 150ms), no 350ms. await() es el punto donde suspende para obtener el resultado — si llamas a await() inmediatamente después de cada async en lugar de separarlos, el código se ejecutará secuencialmente y perderá la ventaja del paralelismo.

// Incorrecto: se ejecuta secuencialmente, pierde el propósito de async
val order = async { fetchOrder(orderId) }.await()
val customer = async { fetchCustomer(customerId) }.await()

Nota: El código anterior no tiene error de sintaxis pero arruina el propósito de usar async — cada await() bloquea inmediatamente después de crear, por lo que las dos coroutines nunca se ejecutan simultáneamente.

6. CoroutineScope: quién gestiona el ciclo de vida de coroutine

Toda coroutine debe pertenecer a un CoroutineScope. El scope determina: cuánto vive una coroutine, cuándo se cancela, cómo se propagan las excepciones.

class OrderService {
    private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())

    fun processOrderAsync(orderId: Long) {
        serviceScope.launch {
            val order = fetchOrder(orderId)
            println("Pedido ${order.id} procesado")
        }
    }

    fun shutdown() {
        serviceScope.cancel() // cancela todas las coroutines hijas en ejecución dentro de este scope
    }
}

serviceScope se mantiene como un field de la clase — esta es la forma de evitar coroutines “huérfanas” ejecutándose eternamente después de que el objeto ya no se usa. SupervisorJob() ayuda a que una coroutine hija que falle no cancele otras coroutines hermanas (diferente a un Job() normal, donde una hija que falle cancela a todas las demás).

6.1. coroutineScope vs CoroutineScope

No confundas coroutineScope { ... } (función suspend, letra minúscula) con CoroutineScope(...) (constructor que crea un nuevo scope, letra mayúscula).

suspend fun loadDashboard(): String = coroutineScope {
    // coroutineScope espera a que TODAS las coroutines hijas dentro terminen
    // luego retorna, y usa el contexto de la coroutine padre
    val a = async { fetchOrder(1L) }
    val b = async { fetchCustomer(2L) }
    "${a.await()} - ${b.await()}"
}

coroutineScope es un suspend function builder usado para agrupar múltiples coroutines hijas, garantizando concurrencia estructurada: la función solo retorna cuando todos los async/launch dentro terminan (o uno de ellos falla y todo se cancela). No crea un nuevo scope independiente sino que hereda el contexto actual — diferente a CoroutineScope(...) en la sección 6 que crea un scope que vive independientemente, no se cancela automáticamente cuando termina la función.

7. Dispatcher: en qué thread se ejecuta coroutine

Dispatcher determina dónde se ejecuta una coroutine — similar a elegir un Executor en Java.

suspend fun readConfigFile(path: String): String = withContext(Dispatchers.IO) {
    // Dispatchers.IO para I/O bloqueante (leer archivo, llamar JDBC...)
    File(path).readText()
}

suspend fun heavyCompute(data: List<Int>): Long = withContext(Dispatchers.Default) {
    // Dispatchers.Default para trabajo CPU-bound
    data.sumOf { it.toLong() * it }
}

withContext cambia la coroutine actual a ejecutarse en un dispatcher diferente y automáticamente vuelve al dispatcher anterior cuando termina — muy útil cuando necesitas llamar código bloqueante (JDBC, file I/O) sin querer ocupar el thread principal de Dispatchers.Default o Dispatchers.Main (en Android).

Dispatcher Usado para
Dispatchers.Default Cálculos CPU-bound (parse, sort, compute)
Dispatchers.IO I/O bloqueante (archivo, JDBC, network bloqueante)
Dispatchers.Main Actualizar UI (solo en Android/JavaFX)
Dispatchers.Unconfined Raramente usado, se ejecuta en el thread que lo llamó

8. Manejo de excepciones en coroutine

Las excepciones en launch y async se propagan de manera diferente — esta es la fuente de bugs más común.

fun main() = runBlocking {
    // launch: excepción se lanza inmediatamente, puede bloquear el scope si no se captura
    val job = launch {
        try {
            throw IllegalStateException("Pedido no existe")
        } catch (e: IllegalStateException) {
            println("Se capturó el error en launch: ${e.message}")
        }
    }
    job.join()

    // async: excepción se mantiene en Deferred, solo se lanza cuando llamas await()
    val deferred = async {
        throw IllegalStateException("Cliente no existe")
    }
    try {
        deferred.await()
    } catch (e: IllegalStateException) {
        println("Se capturó el error en async: ${e.message}")
    }
}
Se capturó el error en launch: Pedido no existe
Se capturó el error en async: Cliente no existe

Con async, si olvidas llamar a .await(), la excepción será silenciosamente “tragada” hasta que Deferred sea recolectada por garbage collection o llames a await() en algún lugar — muy fácil de causar bugs difíciles de depurar en producción.

9. Escribir pruebas para coroutine

Usa kotlinx-coroutines-test con runTest para probar suspend function sin tener que esperar delay real.

class OrderServiceTest {

    @Test
    fun `fetchOrder retorna status PENDING`() = runTest {
        val order = fetchOrder(1L)

        assertThat(order.id).isEqualTo(1L)
        assertThat(order.status).isEqualTo("PENDING")
    }

    @Test
    fun `loadOrderDetail ejecuta dos coroutines en paralelo`() = runTest {
        val result = loadOrderDetail(orderId = 1L, customerId = 2L)

        assertThat(result).contains("Order 1")
        assertThat(result).contains("Customer-2")
    }
}

runTest automáticamente usa tiempo virtual — delay(200) dentro de fetchOrder no hace que la prueba se ejecute lentamente por 200ms real, salta inmediatamente al punto de reanudación de la lógica. Esta es la razón por la que no debes probar coroutine con runBlocking en pruebas unitarias: espera tiempo real, haciendo que la suite de pruebas se ralentice gradualmente con el número de pruebas.

10. Errores comunes

  • Usar GlobalScope: las coroutines viven durante toda la vida de la aplicación, no se cancelan según ningún ciclo de vida — causa fugas. Siempre crea un scope vinculado a una clase/componente específica como en la sección 6.
  • Llamar a runBlocking en código de producción: bloquea el thread actual realmente, destruye el propósito de las coroutines. Solo úsalo en main(), pruebas, o donde sea obligatorio un puente entre código bloqueante y código suspend.
  • Olvidar await() en async: la excepción es tragada, la coroutine se ejecuta pero nadie espera el resultado — desperdicio y difícil de rastrear el error.
  • Mezclar Dispatchers.IO para trabajo CPU-bound: Dispatchers.IO tiene un pool de threads mucho más grande (optimizado para I/O en espera), usarlo para cálculos pesados reducirá la eficiencia del scheduling.

Nota: Si tu servicio no necesita concurrencia real (solo llamadas secuenciales a algunos I/O), las coroutines podrían ser over-engineering. A veces una simple función suspend fun que llama secuencialmente, sin necesidad de async/launch, es suficientemente clara y más fácil de mantener.

11. Conclusión

suspend fun es la unidad fundamental, launch se usa cuando no necesitas un resultado de retorno, async se usa cuando necesitas ejecutar en paralelo y obtener el resultado mediante await(). CoroutineScope determina el ciclo de vida — siempre vincula el scope a un componente específico, evita GlobalScope, y prefiere coroutineScope/SupervisorJob para controlar la concurrencia estructurada. Dominar estos tres conceptos es suficiente para escribir la mayoría del código asincrónico en Kotlin de manera segura y fácil de probar.

kotlincoroutinesconcurrencyandroidspring-boot