Dispatchers: en qué hilo corre cada cosa
La suspensión decide cuándo una corrutina cede el control y la concurrencia estructurada decide quién responde por ella, pero falta la tercera pregunta: sobre qué hilo se ejecuta el código entre dos puntos de suspensión. Esa política es el dispatcher, un elemento del contexto que se hereda por el árbol y que se cambia con withContext. Esta lección diseca los tres despachadores estándar de Kotlin y el criterio que los separa, que no es la velocidad sino la naturaleza del trabajo: Main es un único hilo del que depende el dibujo de la interfaz, IO es un pool elástico pensado para absorber bloqueos, y Default está dimensionado por el número de núcleos para cómputo real. Termina con la anatomía del error clásico, bloquear el hilo principal, y con la regla de dónde debe vivir un cambio de dispatcher.
Una corrutina no tiene hilo propio. Esa frase, que suena a paradoja después de dos lecciones hablando de ejecución, es la clave de todo lo que viene: la corrutina es una unidad de trabajo suspendible y los hilos son un recurso que se le presta por tramos, entre un punto de suspensión y el siguiente. Quién decide qué hilo se presta en cada tramo es un elemento del contexto llamado dispatcher, y su elección es la diferencia entre una interfaz fluida y una que se congela. Aquí no hay magia que aprender: hay tres despachadores estándar, un criterio para elegirlos que no tiene nada que ver con la velocidad, una función para cambiar de uno a otro y un error clásico que conviene poder diagnosticar de un vistazo.
- Entender el dispatcher como el elemento del contexto que decide en qué hilo se reanuda cada tramo.
- Separar
Main,IOyDefaultpor la naturaleza del trabajo y no por una supuesta rapidez. - Usar
withContextpara cambiar de hilo dentro de una corrutina sin romper la estructura ni el orden. - Diagnosticar el bloqueo del hilo principal y saber en qué capa corregirlo.
El contexto lleva la política de hilos
Toda corrutina arrastra un CoroutineContext, un mapa inmutable de elementos indexados por clave donde viven el Job que ya conoces, un nombre opcional para depurar, un manejador de excepciones —tema de la última lección— y el CoroutineDispatcher. El dispatcher es el elemento que responde a una única pregunta, pero la responde muchas veces: cuando esta corrutina deba ejecutarse o reanudarse, ¿sobre qué hilo la pongo?
Dos propiedades del contexto explican el comportamiento cotidiano. La primera es que se hereda: una corrutina lanzada dentro de otra recibe el contexto del padre salvo en lo que se sobrescriba explícitamente, así que basta fijar el dispatcher arriba para que todo el subárbol lo use. La segunda es que se combina con el operador de suma: pasar Dispatchers.IO + CoroutineName("sync") a un constructor produce un contexto donde ambos elementos conviven y el resto se hereda tal cual.
// El dispatcher se elige al lanzar, o se cambia dentro con withContext
viewModelScope.launch { // hereda Dispatchers.Main.immediate
val json = withContext(Dispatchers.IO) { // salta a un hilo del pool de E/S
api.descargarCatalogo() // llamada de red bloqueante
}
val ordenado = withContext(Dispatchers.Default) {
json.items.sortedWith(comparadorCaro) // computo intensivo
}
estado.value = ordenado // de vuelta en Main, seguro para la UI
}
Repara en lo que withContext es y en lo que no es. No lanza una corrutina nueva ni introduce concurrencia: es una función suspend que ejecuta su bloque en otro contexto, suspende hasta que termina y devuelve el resultado en el punto exacto donde estabas. El código sigue leyéndose de arriba abajo y sigue siendo secuencial; lo único que cambió a mitad fue el hilo. Y cuando el bloque acaba, la reanudación vuelve al dispatcher heredado, así que la última línea corre otra vez en el principal sin que tengas que pedirlo.
Dispatchers.Main siempre encola la ejecución en la cola de mensajes del hilo principal, aunque ya estuvieras en él, lo que introduce un ciclo de retraso. Dispatchers.Main.immediate comprueba antes si ya estás en el hilo principal y, si es así, ejecuta directamente. Ese detalle importa en una interfaz reactiva: viewModelScope usa la variante inmediata precisamente para que una actualización de estado disparada desde el hilo principal se vea en el mismo fotograma y no en el siguiente. Es también el motivo de que un reduce de Orbit lanzado desde la interfaz se aplique sin parpadeo perceptible.
Vale la pena señalar que el dispatcher no interviene en cada instrucción, sino solo en las fronteras: cuando la corrutina arranca y cada vez que se reanuda tras suspender. Entre dos puntos de suspensión, el código corre del tirón sobre el hilo que le tocó, sin supervisión ni conmutaciones. Esa granularidad explica a la vez su bajo coste y su límite: si tu tramo dura doscientos milisegundos sin suspender, el dispatcher no tiene ninguna oportunidad de intervenir, por bien elegido que esté.
Los tres despachadores y el criterio que los separa
La confusión más extendida es tratarlos como niveles de rendimiento, como si IO fuera el rápido y Default el lento. No es eso. Los tres se distinguen por cómo están dimensionados, y ese dimensionado responde a qué clase de trabajo esperan recibir.
Dispatchers.Main
Un único hilo, el que dibuja la interfaz y procesa los gestos. Solo debe usarse para tocar la vista y actualizar estado. Cualquier trabajo largo aquí es un congelamiento visible.
Dispatchers.IO
Pool elástico que crece hasta unas decenas de hilos. Diseñado para trabajo que espera: red, disco, base de datos, llamadas bloqueantes heredadas. Tener muchos hilos inactivos es barato.
Dispatchers.Default
Pool acotado al número de núcleos. Diseñado para trabajo que calcula: ordenar, parsear, filtrar listas grandes, transformar imágenes. Más hilos que núcleos solo añadiría cambios de contexto.
Unconfined y confinados
Unconfined reanuda en el hilo que completó la operación previa y solo tiene sentido en casos muy concretos. Un ejecutor de un solo hilo convertido a dispatcher sirve para serializar acceso a un recurso.
El criterio, en una frase: si tu código espera, va a IO, porque el hilo estará parado y sobran hilos parados; si tu código piensa, va a Default, porque el hilo estará quemando procesador y no hay más procesador que el que hay. Poner cómputo pesado en IO no lo acelera, solo permite que se solapen más tareas de las que la máquina puede calcular a la vez, con lo que todas van más lentas. Poner una llamada bloqueante en Default es peor: si ocupas los pocos hilos del pool esperando a la red, dejas al resto de la aplicación sin capacidad de cálculo.
flowchart LR UI[Gesto del usuario en Main] --> I[intent lanzado en Main immediate] I --> IO[withContext IO descarga y lee disco] IO --> D[withContext Default ordena y transforma] D --> M[Vuelta a Main y reduce del estado] M --> UI style UI fill:#89b4fa,color:#11111b style IO fill:#f9e2af,color:#11111b style D fill:#fab387,color:#11111b style M fill:#a6e3a1,color:#11111b
Hay un detalle de implementación con consecuencias prácticas: IO y Default comparten por debajo el mismo grupo de hilos, y saltar de uno a otro suele evitar un cambio de hilo real, limitándose a reasignar el permiso de uso. Eso hace que un withContext entre ambos sea mucho más barato de lo que su apariencia sugiere. La lectura correcta no es despreocuparse, sino dejar de temer el cambio de dispatcher: expresar con claridad la naturaleza de cada tramo cuesta poco y comunica mucho.
El error clásico tiene siempre la misma forma. Alguien lanza en viewModelScope, que hereda el hilo principal, y dentro llama a una función que bloquea: una consulta síncrona a la base de datos, una lectura de fichero, un cliente HTTP antiguo, un Thread.sleep de prueba que se quedó. El hilo principal deja de vaciar su cola de mensajes; si eso dura más de unos pocos fotogramas el usuario ve tirones, y si supera unos segundos el sistema muestra el diálogo de aplicación no responde. Fíjate en que marcar la función como suspend no lo arregla —eso ya lo viste en la primera lección—, y en que el síntoma no siempre aparece en desarrollo, donde la red es rápida y el dispositivo potente. La regla defensiva: toda llamada bloqueante que entre en tu código desde una biblioteca ajena debe quedar envuelta en withContext(Dispatchers.IO) en el mismo sitio donde entra.
Un dispatcher no es un candado
Hay una confusión con consecuencias serias que conviene despejar antes de seguir: elegir dispatcher no protege el estado compartido. Dispatchers.Default reparte tus corrutinas entre tantos hilos como núcleos, e IO entre decenas, así que dos corrutinas del mismo ámbito pueden estar leyendo y escribiendo la misma variable a la vez. Que el modelo de corrutinas sea cómodo no suspende las reglas de la memoria compartida: una lectura y una escritura simultáneas sobre un campo mutable siguen siendo una carrera de datos.
// Roto: mil corrutinas en Default incrementan la misma variable
var contador = 0
withContext(Dispatchers.Default) {
repeat(1000) { launch { contador++ } } // el total no sera 1000
}
// Correcto: exclusion mutua explicita con Mutex, que suspende en vez de bloquear
val candado = Mutex()
withContext(Dispatchers.Default) {
repeat(1000) { launch { candado.withLock { contador++ } } }
}
Las salidas idiomáticas son tres y conviene conocer el orden de preferencia. La primera y mejor es no compartir: si el estado es inmutable y cada corrutina devuelve un valor que alguien combina al final, el problema desaparece por construcción, y es exactamente lo que hace MVI con su estado inmutable y su reducción serializada. La segunda es confinar: un dispatcher de un solo hilo garantiza que nadie toque el recurso a la vez, y es la forma barata de serializar el acceso a un objeto heredado que no es seguro. La tercera es excluir con Mutex, cuya diferencia con un candado clásico importa: al esperar suspende la corrutina en lugar de bloquear el hilo, así que respeta todo lo que llevas visto.
Aquí se aclara del todo por qué Orbit puede permitirse que llames a reduce desde cualquier hilo. No es que sus reducciones sean mágicamente seguras: es que el container las serializa por dentro, de modo que dos reducciones nunca se solapan a mitad de la actualización. Esa decisión es la versión concreta de la primera salida —no compartir— aplicada al único estado que de verdad importa en la pantalla, y explica por qué el resto de tu lógica puede repartirse entre dispatchers sin candados propios.
Dónde poner el cambio de dispatcher
Saber elegir el dispatcher correcto deja abierta una pregunta de diseño: en qué capa hacer el cambio. La respuesta idiomática es contundente y ahorra muchos problemas: el cambio vive en la función que hace el trabajo, no en quien la llama.
// Bien: el repositorio se hace cargo de su propia naturaleza
class CatalogoRepo(private val dao: CatalogoDao) {
suspend fun cargar(): List<Item> = withContext(Dispatchers.IO) {
dao.consultaBloqueante()
}
}
// El llamante no necesita saber nada y el codigo queda limpio
fun refrescar() = intent {
val items = repo.cargar() // seguro desde cualquier dispatcher
reduce { state.copy(items = items) }
}
A esta convención se la llama seguridad del hilo principal: una función suspend pública debe poder invocarse desde el hilo principal sin bloquearlo, sea cual sea lo que haga por dentro. Si cada capa cumple su parte, el que consume nunca tiene que preguntarse en qué hilo está, y desaparece el patrón frágil de envolver cada llamada en withContext desde arriba, que además obliga a recordar la naturaleza interna de una función ajena y se rompe en silencio cuando esa implementación cambia.
En el caso concreto de Orbit, esto encaja limpiamente con lo que ya sabes. El intent corre sobre el dispatcher del container, que en Android es el principal inmediato; el trabajo caro se delega a funciones de repositorio que ya cambian de dispatcher por su cuenta; y el reduce se queda donde debe estar, en un tramo corto y barato. Que Orbit serialice las reducciones significa además que da igual desde qué hilo las invoques, así que no necesitas volver a Main explícitamente antes de reducir: lo que sí necesitas es no meter el cálculo pesado dentro del reduce, que debe ser una transformación pura y rápida.
Escribir Dispatchers.IO literalmente dentro de una clase la ata a un pool real y complica los tests, que quedan a merced del planificador. La práctica sana es inyectar los despachadores mediante una pequeña interfaz proveedora y sustituirlos en pruebas por los de kotlinx-coroutines-test, cuyo planificador virtualiza el tiempo y ejecuta de forma determinista. Ganas dos cosas: los tests dejan de depender de esperas reales y puedes forzar entrelazados concretos para reproducir carreras que en producción aparecerían una vez de cada mil.
Conviene desarmar del todo la intuición equivocada que sostiene casi todos los errores de esta lección, porque no es una cuestión de rendimiento sino de arquitectura del sistema. El hilo principal de una aplicación no es un hilo débil ni especialmente lento; es un hilo ordinario con una particularidad estructural: es el único autorizado a tocar la jerarquía de vistas, y por tanto es el único que puede producir un fotograma. Todo lo que ese hilo hace se ejecuta en serie desde una cola de mensajes —un gesto, una animación, una medición de la disposición, un dibujo—, y el sistema le exige entregar un fotograma cada dieciséis milisegundos en una pantalla de sesenta hercios, o cada ocho en una de ciento veinte. Cuando pones ahí una operación que tarda doscientos milisegundos, no estás ralentizando un cálculo: estás impidiendo que se dibujen doce fotogramas seguidos, y lo que el usuario percibe no es lentitud sino que la aplicación se ha roto, porque el pulgar se mueve y la pantalla no responde. Esta es la razón profunda por la que la solución nunca puede ser optimizar el trabajo hasta que quepa: aunque lo hicieras hoy, la red de un usuario en un tren, un dispositivo cuatro veces más lento o una lista diez veces más larga volverían a romperlo mañana. La única solución estructuralmente correcta es sacar de ese hilo todo lo que no sea decidir y dibujar, y ese es exactamente el servicio que presta un dispatcher. Fíjate ahora en lo que sostiene toda la construcción: sacar trabajo del hilo principal solo es barato porque suspender es barato. En un mundo sin corrutinas, cada operación desplazada costaba un hilo, y como los hilos son caros aparecían los pools compartidos, las colas propias, los callbacks para volver al principal y el ejército de bugs que consiste en tocar la vista desde el hilo equivocado. Con suspensión, continuaciones y un dispatcher que decide dónde reanudar, ese aparato entero se reduce a nombrar la naturaleza de cada tramo de código: esto espera, esto calcula, esto dibuja. Y esa es la verdadera lección, que sobrevive a Kotlin y a Android: los despachadores no son una utilidad de rendimiento sino una forma de declarar en el propio texto a qué clase pertenece cada trozo de tu programa. Cuando esa declaración es precisa, la fluidez de la interfaz deja de ser el resultado de vigilar y pasa a ser una consecuencia del diseño.
- Toma una pantalla tuya y clasifica cada operación en esperar, calcular o dibujar. Asigna un dispatcher a cada una y justifica por qué ninguna clasificación depende de la velocidad.
- Provoca un congelamiento a propósito: bloquea el hilo principal dentro de un
intentdurante tres segundos y observa el comportamiento de la interfaz. Después arréglalo moviendo el cambio de dispatcher a la capa correcta. - Escribe una función de repositorio que sea segura desde el hilo principal y explica por qué envolver la llamada desde el llamante sería una solución peor.
- Mide un ordenamiento de cien mil elementos en
IOy enDefaultcon varias tareas simultáneas. Explica el resultado en términos de núcleos frente a hilos. - Sustituye los despachadores literales de una clase por unos inyectados y escribe un test con el planificador de pruebas que ya no dependa de esperas reales.