Dispatchers y withContext
Al terminar: Elegir entre Main, IO y Default y cambiar de hilo con withContext.
Al terminar esta lección, guardar el torneo en un fichero JSON y calcular la tabla de posiciones ocurrirán fuera del hilo de la pantalla (el que atiende la pantalla y el teclado; en Android se le llama «hilo principal»), cada cosa en el grupo de hilos que le corresponde, y las dos a la vez. Verás en consola el nombre del hilo en que corre cada bloque y qué mensaje da Kotlin cuando pides un hilo que no existe.
Importa porque dónde corre el código decide si un programa responde o se queda congelado. Hoy la consola no lo notaría; en la app Android, un cálculo largo en el hilo equivocado congela la pantalla. Temas: Dispatchers Main, IO y Default, y withContext.
Quién decide en qué hilo corre una corrutina
Una corrutina no es un hilo (lo viste en Corrutinas y suspend): es trabajo que se puede pausar. Pero para avanzar necesita un hilo del sistema que la ejecute. Quien elige ese hilo es el dispatcher («despachador»): un elemento del contexto de la corrutina que reparte el trabajo entre hilos.
Lo nuevo es que un dispatcher se puede sustituir: launch(Dispatchers.Default) y withContext(Dispatchers.Default) cambian solo el dispatcher y dejan lo demás del contexto como estaba.
| Dispatcher | Para qué | Cuántos hilos |
|---|---|---|
Dispatchers.Default | Calcular: ordenar, filtrar, simular | Tantos como núcleos del procesador (un núcleo es cada unidad del procesador capaz de calcular por su cuenta) |
Dispatchers.IO | Esperar: disco, red, base de datos | Hasta 64, o más si tu procesador tiene más núcleos |
Dispatchers.Main | Dibujar la interfaz | Uno solo, el de la pantalla |
Ojo con dos «main» distintos. El hilo que se llama main en tu consola es solo el hilo con el que arranca el programa: no es Dispatchers.Main. Dispatchers.Main es el hilo de la pantalla, y solo existe donde hay una interfaz gráfica que lo aporte (Android, una ventana de escritorio). En una consola no hay, y lo comprobarás con un error real más abajo.
Concurrencia y paralelismo, ahora con hilos de verdad
En launch y async viste que, dentro de runBlocking, hay un solo hilo: las corrutinas se turnan (concurrencia). Dispatchers.Default cambia eso: con 11 núcleos, hasta 11 corrutinas pueden calcular a la vez, cada una en su hilo y su núcleo (paralelismo real). En cambio, esperar con delay es concurrencia aunque haya muchos hilos: ninguna corrutina ocupa hilo mientras espera, y por eso 10.000 pueden esperar a la vez.
withContext: cambiar de hilo sin cambiar de forma de escribir
withContext(dispatcher) { … } ejecuta el bloque en ese dispatcher, espera a que termine, devuelve su resultado y vuelve al dispatcher de origen. Para quien lee el código sigue siendo una función normal, de arriba abajo.
import kotlinx.coroutines.*
suspend fun demo() {
// Thread.currentThread().name es el nombre del hilo que ejecuta esta línea
println("Empiezo en: ${Thread.currentThread().name}") // main
val texto = withContext(Dispatchers.IO) { // pasa al grupo de hilos de disco y red y espera
// startsWith(«…») dice si un texto empieza por esas letras: aquí, si el hilo es del grupo compartido
println("Dentro, en un worker: ${Thread.currentThread().name.startsWith("DefaultDispatcher-worker")}")
"leído del disco" // lo último del bloque es lo que devuelve withContext
}
println("Vuelvo a: ${Thread.currentThread().name} con «$texto»") // main
}
fun main() = runBlocking { // runBlocking corre en el hilo de arranque, que se llama «main»
demo()
}Al ejecutarlo, imprime:
Empiezo en: main
Dentro, en un worker: true
Vuelvo a: main con «leído del disco»En el Kotlin Playground de la web el hilo puede tener otro nombre; lo que importa es que el de «Empiezo en» y el de «Vuelvo a» sean el mismo.
Dos consecuencias que importan:
withContextes el patrón de las funciones «seguras para llamar desde cualquier sitio». Quien llama no tiene que saber que dentro se cambia de hilo.- Al volver, vuelves al dispatcher de origen aunque el bloque haya terminado en otro. En
runBlockingeso es el mismo hilo (main); si partías deDefault, vuelves aDefault, pero quizá a otro hilo de ese grupo. No hay que volver al hilo original a mano:withContextlo hace solo.
Error típico: bloquear un hilo de Default
Default tiene pocos hilos, tantos como núcleos. Si una corrutina bloquea su hilo (lo deja parado sin liberarlo), los demás encargos hacen cola. Thread.sleep bloquea; delay suspende y libera el hilo. Es la misma idea que viste en Corrutinas y suspend, ahora con 1.000 corrutinas en Default.
runBlocking {
repeat(1000) {
launch(Dispatchers.Default) { Thread.sleep(100) } // el hilo se queda parado
}
}runBlocking {
repeat(1000) {
launch(Dispatchers.Default) { delay(100) } // el hilo se libera mientras espera
}
}Medido en un Mac de 11 núcleos (las cifras varían de una ejecución a otra y dependen de tu máquina, sobre todo de cuántos núcleos tenga):
| Versión | 1.000 corrutinas de 100 ms |
|---|---|
Thread.sleep(100) en Default | 9.460 ms |
delay(100) en Default | 133 ms |
Con Thread.sleep, solo 11 corrutinas avanzan a la vez (una por hilo, en paralelo) y las demás esperan turno. Con delay, las 1.000 esperan a la vez, en concurrencia, porque ninguna ocupa hilo. Una función bloqueante es la que deja su hilo parado hasta que termina. Si de verdad hay que llamar a una (leer un fichero, una llamada de red antigua), no se hace en Default: se envuelve en withContext(Dispatchers.IO), que admite muchos más hilos parados a la vez.
Reto · Ejercicio: comprobar el hilo
En el Kotlin Playground, fuera de tu proyecto, que ya trae la librería de corrutinas.
Guarda el hilo en el que empieza el programa. Pasa un momento a Dispatchers.Default y comprueba dos cosas: que el nombre del hilo empieza por «DefaultDispatcher-worker» (con startsWith, como en el ejemplo de arriba) y que es un hilo distinto del inicial. Al volver, comprueba que estás otra vez en el hilo inicial. Por último, escribe una función que simule una lectura de disco antigua: por dentro deja su hilo parado 200 ms y devuelve el texto «leído», pero quien la llama no debe quedarse con su hilo bloqueado. Imprime lo que devuelve. La consola debe mostrar:
Default corre en un worker: true
Default es otro hilo: true
Tras salir, el mismo hilo del principio: true
leídoPara pensar: ¿cambia de hilo si, estando en Default, pasas a IO?
Ver solución
import kotlinx.coroutines.*
fun hilo() = Thread.currentThread()
// La función bloqueante se envuelve en IO: quien la llame no tiene que saberlo.
suspend fun leerLento(): String = withContext(Dispatchers.IO) {
Thread.sleep(200)
"leído"
}
fun main() = runBlocking {
val inicial = hilo() // el hilo en el que empieza runBlocking
withContext(Dispatchers.Default) {
println("Default corre en un worker: ${hilo().name.startsWith("DefaultDispatcher-worker")}")
println("Default es otro hilo: ${hilo() !== inicial}")
}
println("Tras salir, el mismo hilo del principio: ${hilo() === inicial}")
println(leerLento())
}Respuesta a «para pensar»: normalmente no. IO comparte hilos con Default, y la documentación de Dispatchers.IO (la cita de arriba) dice que entrar en IO estando en Default no suele provocar un cambio real de hilo. En 1.000 de 1.000 pruebas siguió en el mismo hilo. Es el motivo por el que envolver en IO una función que ya corre en Default es barato.
Paso · Guardar en IO y calcular en Default
Objetivo. Crear SegundoPlano.kt con tres funciones que guardan el torneo, lo cargan y calculan la tabla fuera del hilo que las llama, más cerrarJornada, que hace las dos cosas a la vez. Al terminar, ./gradlew :torneo:run imprime un bloque nuevo de siete líneas: el título, cuatro con nombres de hilo, el guardado y el mensaje de Dispatchers.Main.
Esta lección no instala nada: la librería de corrutinas que añadiste en Corrutinas y suspend ya trae los tres dispatchers. Guardar y cargar usan lo que escribiste en Guardado.kt en Marcador (guardar y cargarTorneo).
Qué hacer en cada fichero (el código completo está más abajo):
- Crea
SegundoPlano.kt. Entorneo → kotlin+java, clic derecho sobre el paquetecom.sebastiannarvaez.marcador.torneo→ New → Kotlin Class/File → File → escribeSegundoPlanoy pulsa Intro. Se abre con solo la líneapackage. - Abre
Ejemplo.kt(doble clic en su nodo, dentro del mismo paquete). Dentro deobject Ejemplo, justo antes de la llave}que lo cierra (la última del fichero), añadetorneoConPartidos(): Torneo, que devuelve unTorneonuevo, llamado «Copa Barrio», con los cuatro equipos queEjemploya define (rayo,toros,lobosyaguilas) y los partidosrayoContraTorosylobosContraAguilasya registrados. - Abre
Main.kty haz tres cambios:- Dos
importnuevos (kotlinx.coroutines.withContextyjava.io.File), debajo de los que ya hay arriba. Si prefieres, escribe el nombre en el código y pulsa Option+Enter sobre la palabra en rojo: Android Studio añade elimportpor ti. - La llamada
demoDispatchers(), al final del bloquerunBlockingdemain, trasdemoTransmisorPropio(), y delante de ella elprintlndel título. - La función
demoDispatchers(), justo debajo de la llave que cierramain. Usatry/catchy norunCatching, y el porqué se explica más abajo.
- Dos
- El test se copia con el botón, al final.
torneo.json lo escribe el programa al ejecutarse: no lo creas tú.
Tres detalles antes de escribir:
partidosya devuelve una copia (toList(), de Inmutabilidad y referencias), así queval copiaDePartidos = partidosguarda una lista que nadie más modifica.Torneoes mutable y no está preparado para que otro hilo lo lea mientras alguien registra un partido; al otro hilo solo viaja esa copia.cerrarJornadausa dosasync(launch y async): guardar (enIO) y calcular (enDefault) arrancan a la vez, y como son hilos distintos pueden avanzar en paralelo real.getOrThrow()(Result: errores como valores) lanza el error si guardar falló.- La demo usa
try/catchconIllegalStateException, norunCatching:runCatchingatraparía también laCancellationException, justo lo que Alcance, Job y cancelación enseña a evitar. Y las líneas «IO corre en» y «Default corre en» no salen de las funciones deSegundoPlano.kt(guardar no debe escribir en pantalla), sino de doswithContextquedemoDispatchersabre aparte solo para enseñar el nombre del hilo de cada dispatcher.
Piezas nuevas de este paso: una función de extensión suspend con withContext (tal como en el ejemplo de arriba) y dos async en dispatchers distintos. Lo demás ya lo has usado: Thread.currentThread().name, getOrThrow(), try/catch de un tipo concreto con e.message (Excepciones: try, catch y require, «Varios catch»), !== para saber que dos referencias no son el mismo objeto (data class e igualdad) y fichero.absolutePath, la ruta completa de un fichero.
Intenta escribirlo tú, en este orden:
- En
SegundoPlano.kt, tres funciones que se pueden llamar desde cualquier hilo:guardarEnSegundoPlano(fichero)guarda el torneo enIO;cargarTorneoEnSegundoPlano(fichero)lo carga también ahí; ytablaEnSegundoPlano()calcula la tabla enDefault, trabajando sobre una copia de los partidos, los equipos y el reglamento; la tabla se calcula como en f1, conpartidos.tablaDePosiciones(equipos, reglas). cerrarJornada(fichero)guarda y calcula la tabla a la vez; si guardar falla, lanza el error, y si no, devuelve la tabla.- En
Ejemplo.kt,torneoConPartidos()devuelve un torneo «Copa Barrio» con los cuatro equipos del ejemplo y sus dos partidos ya registrados. - En
Main.kt,demoDispatchers()dice en qué hilo empieza, cierra la jornada entorneo.json, muestra dónde guardó y quién lidera, dice en qué hilo vuelve e intenta usar el dispatcherMain, que en un programa de consola no existe, mostrando el error sin que el programa se caiga. Para mostrar el nombre del hilo de cada dispatcher, abre un momentoIOyDefaultsolo con ese fin.
Tras las demos anteriores, debe verse (el número del worker cambia de una ejecución a otra, y «IO» y «Default» pueden salir en el orden contrario):
== guardar en IO y calcular la tabla en Default ==
Empiezo en: main
IO corre en: DefaultDispatcher-worker-2
Default corre en: DefaultDispatcher-worker-2
Guardado en …/torneo.json; líder: Rayo FC con 3 puntos
Vuelvo a: main
Dispatchers.Main: Module with the Main dispatcher is missing. Add dependency providing the Main dispatcher, e.g. 'kotlinx-coroutines-android' and ensure it has the same version as 'kotlinx-coroutines-core'Cuando termines, abre el bloque y compara.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.torneo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import java.io.File
// Un Dispatcher decide EN QUÉ HILOS corre una corrutina:
// Dispatchers.Default -> tantos hilos como núcleos; para CALCULAR.
// Dispatchers.IO -> muchos hilos, pensado para ESPERAR (disco, red).
// Dispatchers.Main -> el hilo de la interfaz. Solo existe donde hay UI
// (Android, Swing…): en una consola no lo hay.
//
// `withContext(dispatcher) { ... }` cambia de hilo durante el bloque, espera a
// que acabe y devuelve su resultado. La función que lo llama sigue siendo
// secuencial para quien la lee.
// Escribir el JSON es esperar al disco: IO.
suspend fun Torneo.guardarEnSegundoPlano(fichero: File): Result<Unit> =
withContext(Dispatchers.IO) { guardar(fichero) }
suspend fun cargarTorneoEnSegundoPlano(fichero: File): Result<Torneo> =
withContext(Dispatchers.IO) { cargarTorneo(fichero) }
// Calcular la tabla es CPU: Default. Se toma una copia de los partidos ANTES de
// cambiar de hilo: el Torneo es mutable y no está preparado para que otro hilo
// lo lea mientras alguien registra un partido.
suspend fun Torneo.tablaEnSegundoPlano(): List<FilaDePosicion> {
val copiaDePartidos = partidos
val equiposInscritos = equipos
val reglas = reglamento
return withContext(Dispatchers.Default) { copiaDePartidos.tablaDePosiciones(equiposInscritos, reglas) }
}
// Las dos cosas a la vez, cada una en su dispatcher. Si el guardado falla, se
// lanza el error (`getOrThrow`) al esperar el resultado.
suspend fun Torneo.cerrarJornada(fichero: File): List<FilaDePosicion> = coroutineScope {
val guardado = async { guardarEnSegundoPlano(fichero) }
val tabla = async { tablaEnSegundoPlano() }
guardado.await().getOrThrow()
tabla.await()
}En Ejemplo.kt, esta función va dentro de object Ejemplo. El resto del fichero no cambia.
// Un torneo nuevo cada vez (el Torneo es mutable) con los dos partidos ya jugados.
fun torneoConPartidos(): Torneo = Torneo("Copa Barrio", listOf(rayo, toros, lobos, aguilas)).apply {
registrar(rayoContraToros).getOrThrow()
registrar(lobosContraAguilas).getOrThrow()
}En Main.kt, los dos imports, la llamada al final del bloque runBlocking de main y la función demoDispatchers(). El resto del fichero no cambia.
import kotlinx.coroutines.withContext
import java.io.File println("== guardar en IO y calcular la tabla en Default ==")
demoDispatchers()Y esta es demoDispatchers, con try/catch en lugar de runCatching:
suspend fun demoDispatchers() {
val torneo = Ejemplo.torneoConPartidos()
val fichero = File("torneo.json")
println("Empiezo en: ${Thread.currentThread().name}")
withContext(Dispatchers.IO) { println("IO corre en: ${Thread.currentThread().name}") }
withContext(Dispatchers.Default) { println("Default corre en: ${Thread.currentThread().name}") }
val tabla = torneo.cerrarJornada(fichero)
println("Guardado en ${fichero.absolutePath}; líder: ${tabla.first().equipo.nombre} con ${tabla.first().puntos} puntos")
println("Vuelvo a: ${Thread.currentThread().name}")
// Dispatchers.Main en consola: no existe.
try {
withContext(Dispatchers.Main) { }
} catch (e: IllegalStateException) {
println("Dispatchers.Main: ${e.message}")
}
}El test: se copia con el botón
SegundoPlanoTest.kt (7 pruebas: ida y vuelta del JSON, la tabla, cerrarJornada y un guardado imposible) comprueba lo que acabas de escribir y no enseña dispatchers más allá de lo que ya has visto, así que se copia con el botón Copiar fichero.
- En
torneo → kotlin+java, clic derecho sobre el paquetecom.sebastiannarvaez.marcador.torneo (test)→ New → Kotlin Class/File → File → escribeSegundoPlanoTesty pulsa Intro. - Pulsa Copiar fichero en el bloque de abajo.
- En el editor, selecciona todo y pega: Cmd+A / Cmd+V. El texto copiado trae su propia línea
package.
Entre las siete hay una que comprueba que, en una consola, pedir Dispatchers.Main falla con IllegalStateException (es el error de la caja de arriba), y otra que comprueba, como en el ejercicio, que withContext(Dispatchers.Default) cambia de hilo y al salir vuelve al de antes.
Lo que has visto
IO y Default comparten el mismo grupo de hilos: por eso «IO» y «Default» pueden salir con el mismo worker. Y quien llama a cerrarJornada sigue escribiendo código en línea recta: no se ve que por dentro hay hilos.
Punto de control
- En la Terminal de Android Studio,
./gradlew :torneo:runtermina enBUILD SUCCESSFULy, tras las demos anteriores, imprime este bloque de siete líneas, con el título y la deDispatchers.Main(el número delworkercambia de una ejecución a otra, y «IO» y «Default» pueden salir en el orden contrario; si escribiste otro título, saldrá el tuyo):
== guardar en IO y calcular la tabla en Default ==
Empiezo en: main
IO corre en: DefaultDispatcher-worker-2
Default corre en: DefaultDispatcher-worker-2
Guardado en …/torneo.json; líder: Rayo FC con 3 puntos
Vuelvo a: main
Dispatchers.Main: Module with the Main dispatcher is missing. Add dependency providing the Main dispatcher, e.g. 'kotlinx-coroutines-android' and ensure it has the same version as 'kotlinx-coroutines-core'- Se sobrescribe
torneo.jsonen la carpetatorneo/del proyecto (ya lo escribió la demo de Guardado.kt en Marcador, que se lanza con--args=kotlin; Gradle ejecuta el programa desde la carpeta del módulo). La vista Android no lo enseña, perocat torneo/torneo.jsonen la Terminal imprime su contenido. ./gradlew :torneo:testtermina enBUILD SUCCESSFULcon 102 pruebas: las 95 que ya tenías más las 7 deSegundoPlanoTest.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Qué decide un dispatcher?
2.Vas a ordenar y filtrar mucha información que ya está en memoria. ¿Qué dispatcher encaja?
3.En el código de la lección,
withContext(Dispatchers.IO) { guardar(fichero) }se ejecuta en un hilo de IO. ¿Dónde sigue el código justo después de esa línea?4.En una medición de la lección (un Mac de 11 núcleos; en tu máquina las cifras serán otras), 1.000 corrutinas en
DefaultconThread.sleep(100)tardan mucho más que condelay(100). ¿Por qué?5.En una aplicación de consola ejecutas
withContext(Dispatchers.Main) { }. ¿Qué pasa?