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 fundirectamente desde una funciónfunregular (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— cadaawait()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
runBlockingen código de producción: bloquea el thread actual realmente, destruye el propósito de las coroutines. Solo úsalo enmain(), pruebas, o donde sea obligatorio un puente entre código bloqueante y código suspend. - Olvidar
await()enasync: la excepción es tragada, la coroutine se ejecuta pero nadie espera el resultado — desperdicio y difícil de rastrear el error. - Mezclar
Dispatchers.IOpara trabajo CPU-bound:Dispatchers.IOtiene 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 funque llama secuencialmente, sin necesidad deasync/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.