Esta pagina se ve mejor con JavaScript habilitado

Dominando Kotlin Coroutines: Dispatchers, Jobs y Concurrencia Estructurada

 ·  ☕ 5 minutos lectura  ·  ✍️ Ignacio Carrión

Dominando Kotlin Coroutines: Dispatchers, Jobs y Concurrencia Estructurada

Kotlin Coroutines ofrecen una forma ligera y estructurada de escribir código asíncrono y concurrente. Esta guía se centra en tres pilares que usarás a diario: Dispatchers (dónde corren las coroutines), Jobs (qué corre y cómo se supervisa), y la concurrencia estructurada (las reglas que mantienen tu código asíncrono bajo control).

Encontrarás modelos mentales, snippets ejecutables, gotchas y patrones listos para producción.


TL;DR

  • Dispatcher: decide el hilo o pool donde ejecuta una coroutine (Dispatchers.Default, IO, Main, Unconfined).
  • Job: un handle a una coroutine en ejecución; soporta lifecycle, cancelación, relaciones padre/hijo y concurrencia estructurada.
  • Concurrencia estructurada: lanza coroutines en un scope; los hijos se cancelan cuando el scope se cancela; los errores no se filtran silenciosamente.
  • Prefiere withContext para cambiar de hilo dentro de una función suspend y launch para fire-and-forget en un scope con lifecycle.
  • Cancela siempre los scopes que crees; prefiere dispatchers provistos por el scope en lugar de globales.

Un modelo mental simple

  • Coroutine: una tarea que puede suspenderse sin bloquear un hilo.
  • Dispatcher: el contexto de ejecución (thread pool) que usa la tarea.
  • Job: el controlador del ciclo de vida de la tarea.
  • Scope: une un CoroutineContext (dispatcher + Job + extras) a un lifecycle.
1
2
3
4
5
6
7
8
class Repository(
    private val externalApi: ExternalApi,
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) {
    suspend fun loadUser(id: String): User = withContext(dispatcher) {
        externalApi.fetchUser(id) // suspende sin bloquear un hilo
    }
}

Dispatchers a fondo

  • Dispatchers.Default: trabajo CPU-bound (pool compartido ajustado a núcleos).
  • Dispatchers.IO: IO bloqueante (archivos, sockets, JDBC). Usa un pool más grande.
  • Dispatchers.Main: hilo de UI en Android/desktop; solo para trabajo ligero y rápido.
  • Dispatchers.Unconfined: avanzado/raro; empieza donde se llama y puede reanudar en otro lugar.

Cambiar de dispatcher dentro de funciones suspend:

1
2
3
4
suspend fun resizeAndSave(bitmap: Bitmap, file: File) {
    val resized = withContext(Dispatchers.Default) { resize(bitmap) } // CPU-bound
    withContext(Dispatchers.IO) { file.outputStream().use { save(resized, it) } } // IO-bound
}

Dispatcher personalizado (paralelismo limitado):

1
val singleIO = Dispatchers.IO.limitedParallelism(1)

Jobs y ciclo de vida

Un Job representa una unidad de trabajo cancelable. Toda coroutine tiene uno. Los Jobs pueden formar jerarquías: cancelar un padre cancela a todos sus hijos.

1
2
3
4
5
6
7
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
val job: Job = scope.launch {
    // Trabajo
}

// Más tarde
job.cancel("no longer needed")

Tipos y utilidades clave:

  • Job: job normal; un fallo cancela al padre salvo que haya supervisión adecuada.
  • SupervisorJob: los hijos fallan de forma independiente; el padre no se cancela por fallos de un hijo.
  • coroutineContext[Job]: accede al job actual.

Concurrencia estructurada en la práctica

  • Lanza coroutines dentro de un scope que controlas (por ejemplo, en Android viewModelScope, lifecycleScope).
  • Prefiere coroutineScope {} o supervisorScope {} dentro de funciones suspend para lanzar hijos.
1
2
3
4
5
suspend fun loadScreenData(repo: Repo): ScreenData = coroutineScope {
    val user = async { repo.user() }
    val posts = async { repo.posts() }
    ScreenData(user.await(), posts.await())
}

Si un hijo no debe cancelar a los demás, usa supervisorScope:

1
2
3
4
5
suspend fun loadPartial(repo: Repo): Partial = supervisorScope {
    val user = async { repo.user() }
    val comments = async { repo.comments() } // podría fallar
    Partial(user.await(), runCatching { comments.await() }.getOrNull())
}

Cancelación y timeouts

  • La cancelación es cooperativa: revisa isActive o llama funciones suspend cancelables.
  • Usa withTimeout/withTimeoutOrNull para límites superiores.
1
2
3
4
5
6
7
8
suspend fun poll(api: Api): Data? = withTimeoutOrNull(1_000) {
    while (isActive) {
        val data = api.tryFetch()
        if (data != null) return@withTimeoutOrNull data
        delay(50)
    }
    null
}

Cierra o limpia siempre en finally:

1
2
3
4
5
6
7
8
scope.launch(Dispatchers.IO) {
    val socket = openSocket()
    try {
        readLoop(socket)
    } finally {
        socket.close() // también se llama en cancelación
    }
}

Manejo de excepciones

  • Las excepciones en un hijo cancelan al padre en scopes regulares.
  • En un SupervisorJob/supervisorScope, los hermanos quedan aislados.
  • Usa CoroutineExceptionHandler solo para excepciones de nivel superior no capturadas.
1
2
3
4
5
val handler = CoroutineExceptionHandler { _, e ->
    log("Unhandled: ${e.message}")
}

val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + handler)

Para fallos esperados en hijos, captura localmente:

1
2
3
4
5
6
7
scope.launch {
    try {
        fetch()
    } catch (e: IOException) {
        showError(e)
    }
}

Elementos de contexto y withContext

CoroutineContext es un conjunto de elementos: Dispatcher, Job, CoroutineName, etc.

  • Combina con +.
  • Reemplaza un elemento añadiendo otro con la misma clave.
1
2
3
4
5
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob() + CoroutineName("sync"))

suspend fun syncAll() = withContext(CoroutineName("sync-step")) {
    // Solo cambia el nombre; dispatcher/job heredados
}

Elegir el Dispatcher correcto

  • CPU-bound: Default (o un pool dedicado limitado si necesitas aislamiento).
  • IO-bound: IO.
  • Actualizaciones de UI: Main.
  • Evita Unconfined salvo que sepas por qué lo necesitas.

Anti-patrones:

  • Crear tu propio newFixedThreadPoolContext sin una razón fuerte.
  • Llamar withContext(Dispatchers.Main) para trabajo largo.
  • Usar GlobalScope en código de app (difícil de cancelar y testear).

Patrones y recetas comunes

Fire-and-forget ligado al lifecycle:

1
2
3
4
// Android ViewModel
class MyViewModel(/* ... */) : ViewModel() {
    fun refresh() = viewModelScope.launch { repository.refresh() }
}

Pipeline:

1
2
3
4
5
suspend fun process(input: Input): Output = withContext(Dispatchers.Default) {
    val parsed = parse(input)
    val enriched = withContext(Dispatchers.IO) { enrich(parsed) }
    transform(enriched)
}

Backpressure con limitedParallelism:

1
2
3
4
suspend fun fetchAll(ids: List<String>): List<Item> = coroutineScope {
    val gate = Dispatchers.IO.limitedParallelism(8)
    ids.map { id -> async(gate) { fetch(id) } }.awaitAll()
}

Testing de coroutines

  • Usa kotlinx-coroutines-test.
  • Reemplaza dispatchers y controla el tiempo virtual.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@OptIn(ExperimentalCoroutinesApi::class)
class RepoTest {
    private val dispatcher = StandardTestDispatcher()
    private val scope = TestScope(dispatcher)

    @Before fun setUp() { Dispatchers.setMain(dispatcher) }
    @After fun tearDown() { Dispatchers.resetMain() }

    @Test fun loadsUser() = scope.runTest {
        val repo = Repo(/* fakes */)
        val user = repo.user()
        assertEquals("42", user.id)
    }
}

Consejos de rendimiento y trampas

  • Mantén las coroutines de grano grueso; demasiadas coroutines pequeñas añaden overhead.
  • Evita cambios de contexto innecesarios.
  • Prefiere scopes estructurados frente a GlobalScope.launch ad-hoc.
  • Recuerda que async sin await fuga errores; usa launch para fire-and-forget.

Chuleta rápida

  • Crear un scope: CoroutineScope(Job() + Dispatchers.Default)
  • Cambiar de hilo: withContext(Dispatchers.IO) { ... }
  • Lanzar tareas hijas: coroutineScope { val a = async { ... }; a.await() }
  • Timeouts: withTimeout(…), withTimeoutOrNull(…)
  • Supervisión: SupervisorJob, supervisorScope { … }
  • Manejador de excepciones: CoroutineExceptionHandler { ctx, e -> … }
compartir en

Ignacio Carrión
Escrito por
Ignacio Carrión
Android Developer