Los dispatchers y la forma de sus pools
Un dispatcher no es un hilo sino una política de despacho: quién decide dónde se reanuda cada continuación. Esta lección recorre el pool de cómputo dimensionado por núcleos, el pool elástico de entrada y salida que comparte hilos con el anterior, el dispatcher no confinado que reanuda donde caiga, y los dispatchers de un solo hilo como alternativa a los cerrojos.
La pregunta que casi todo el mundo se hace mal es en qué hilo corre mi corrutina. Está mal formulada porque presupone que una corrutina tiene un hilo, y no lo tiene: tiene una secuencia de reanudaciones, cada una de las cuales ocurre en algún hilo que puede no ser el mismo que la anterior. Quien decide ese dónde en cada reanudación es el dispatcher, y su trabajo consiste en recibir un bloque de trabajo y colocarlo en la cola de ejecución adecuada. Cambiar de dispatcher no cambia lo que hace el código sino la infraestructura sobre la que corre, y elegir mal esa infraestructura produce las dos patologías clásicas de todo sistema concurrente: hilos ociosos mientras hay trabajo pendiente, o hilos peleándose por unos núcleos que ya estaban saturados. Esta lección explica exactamente qué hay debajo de cada uno de los dispatchers estándar para que la elección deje de ser folclore.
- Definir un dispatcher como interceptor de continuaciones y distinguir despacho de ejecución.
- Justificar el dimensionado del pool de cómputo y por qué añadirle hilos casi nunca acelera nada.
- Explicar la elasticidad del dispatcher de entrada y salida y su relación con el pool compartido.
- Elegir entre confinamiento a un solo hilo, paralelismo limitado y despacho no confinado según el problema.
Un dispatcher es una política, no un hilo
Todo dispatcher implementa ContinuationInterceptor y expone en el fondo una única operación relevante: recibir un bloque ejecutable junto con el contexto y decidir cuándo y dónde se ejecuta. Hay además una consulta previa que pregunta si hace falta despachar; si la respuesta es negativa, la continuación se reanuda en el mismo hilo sin pasar por ninguna cola, y ese camino rápido es lo que hace que una función suspendida que no cambia de dispatcher sea casi gratis.
public abstract class CoroutineDispatcher : AbstractCoroutineContextElement(ContinuationInterceptor),
ContinuationInterceptor {
public open fun isDispatchNeeded(context: CoroutineContext): Boolean = true
public abstract fun dispatch(context: CoroutineContext, block: Runnable)
}
Cualquier ejecutor de Java se convierte en dispatcher con una llamada, y esa puerta es la vía normal para integrar corrutinas con la infraestructura de hilos que ya existe en un sistema heredado. La conversión no copia nada: envuelve el ejecutor y delega en él cada despacho, de modo que las políticas de cola, rechazo y dimensionado siguen siendo las del ejecutor original.
val propio = Executors.newFixedThreadPool(4).asCoroutineDispatcher()
// propio.close() cuando el ciclo de vida termine: el pool no se cierra solo
flowchart LR
A[Punto de suspension] --> B[Continuacion]
B --> C{isDispatchNeeded}
C -->|no| D[Reanuda en el hilo actual]
C -->|si| E[Cola del dispatcher]
E --> F[Hilo trabajador libre]
F --> G[Reanuda la continuacion]El dispatcher de cómputo y el número de núcleos
El dispatcher por defecto respalda todo lo que no declare otra cosa y está pensado para trabajo que consume procesador. Su paralelismo es el número de núcleos disponibles, con un mínimo de dos para que un sistema de un solo núcleo no se bloquee a sí mismo, y ese valor se puede fijar por propiedad de sistema pero rara vez debería tocarse. El razonamiento es aritmético y no admite opinión: si el trabajo satura el procesador, tener más hilos que núcleos no aumenta el trabajo realizado por segundo, solo reparte el mismo trabajo entre más contextos y añade cambios de contexto, presión sobre las cachés y latencia de planificación.
// Correcto: trabajo que realmente consume procesador
val hash = withContext(Dispatchers.Default) {
calcularResumenCriptografico(bloqueDeUnMega)
}
// Vista con paralelismo acotado sobre el mismo pool
val limitado = Dispatchers.Default.limitedParallelism(2)
Hay una excepción que confirma el criterio y conviene nombrarla. Si el trabajo de cómputo es largo y no contiene ningún punto de suspensión, ocupará un hilo entero hasta terminar y no habrá manera de intercalar nada, porque la librería no interrumpe a nadie por su cuenta. La solución no es añadir hilos sino trocear el trabajo e insertar puntos de cesión explícitos, de modo que el planificador de corrutinas recupere el control periódicamente y las tareas cortas no queden detrás de una larga.
La operación de paralelismo limitado merece una explicación porque se malinterpreta con frecuencia. No crea hilos nuevos ni un pool nuevo: crea una vista sobre el pool subyacente que garantiza que como máximo el número indicado de tareas de esa vista se ejecutan a la vez. Sirve para acotar el acceso a un recurso escaso, para serializar operaciones sobre una estructura no segura o para impedir que una parte del sistema monopolice el pool. Sobre el dispatcher de cómputo, esa cuota se descuenta del paralelismo total; sobre el de entrada y salida, el comportamiento es distinto y es justo lo que se explica a continuación.
Entrada y salida: elasticidad sobre un pool compartido
El dispatcher de entrada y salida existe porque hay operaciones que no consumen procesador sino tiempo: leer de un disco, esperar una respuesta de red, llamar a un controlador de base de datos que bloquea. Durante esa espera el hilo no hace nada útil, así que el criterio de dimensionado por núcleos deja de aplicarse y lo razonable es tener bastantes más hilos que núcleos. Su límite por defecto es sesenta y cuatro o el número de núcleos si este fuera mayor, y también es configurable por propiedad de sistema.
El detalle que casi nadie conoce es que no se trata de dos pools independientes. Ambos dispatchers son vistas sobre el mismo conjunto de hilos trabajadores; lo que cambia es la cuota que cada vista puede ocupar. La consecuencia inmediata y muy agradable es que saltar entre cómputo y entrada y salida puede resolverse sin cambiar de hilo cuando el trabajador actual está disponible, evitando el coste real del despacho. La consecuencia incómoda es que un abuso del dispatcher de entrada y salida degrada al de cómputo, porque los hilos salen del mismo sitio.
De ahí se sigue una asimetría poco conocida en la operación de paralelismo limitado. Aplicada sobre el pool de cómputo, la cuota se descuenta del total: pedir dos de ocho deja seis para el resto del proceso. Aplicada sobre el de entrada y salida, la vista obtiene su propia cuota independiente y puede hacer crecer el número de hilos por encima del límite general, precisamente porque se asume que esos hilos pasarán la mayor parte del tiempo esperando y no compitiendo por procesador. Esa diferencia convierte al segundo caso en la herramienta idónea para modelar recursos externos y al primero en una forma de racionar cómputo.
Cómputo
Paralelismo igual al número de núcleos. Para trabajo que quema procesador: serialización, compresión, análisis sintáctico, cálculo puro sobre estructuras en memoria.
Entrada y salida
Hasta sesenta y cuatro hilos por defecto sobre el pool compartido. Para llamadas que esperan: ficheros, red bloqueante, controladores que no ofrecen versión suspendida.
Paralelismo limitado sobre entrada y salida
Crea una vista con su propia cuota que no consume la cuota general, y puede hacer crecer el pool por encima de sesenta y cuatro. Es la herramienta correcta para dimensionar por recurso.
Principal
Lo aportan los entornos con hilo de interfaz. Su variante inmediata evita el despacho cuando ya estás en ese hilo, y con ello un parpadeo de un fotograma.
// Un cliente de base de datos con diez conexiones no gana nada
// con mas de diez hilos esperando sobre el
private val dispatcherBd = Dispatchers.IO.limitedParallelism(10)
suspend fun buscarUsuario(id: Long): Usuario = withContext(dispatcherBd) {
conexion.prepareStatement("select ...").use { /* llamada bloqueante */ }
}
Usar el dispatcher de entrada y salida genérico para todo equivale a dejar que cualquier subsistema consuma hasta sesenta y cuatro hilos. Si el cuello de botella real es un pool de conexiones de diez, las cincuenta y cuatro tareas restantes solo sirven para acumular latencia y para ocultar el punto de saturación. Una vista con paralelismo limitado por cada recurso externo convierte esa cola invisible en una cola explícita y medible.
No confinado y confinamiento a un solo hilo
Los dos dispatchers que quedan por revisar ocupan los extremos opuestos del espectro de control. Uno renuncia por completo a decidir dónde se ejecuta el trabajo; el otro decide con tal precisión que la decisión pasa a ser un mecanismo de sincronización. Entre ambos no hay término medio, y confundirlos es fácil porque los dos evitan el pool general por razones que no se parecen en nada.
El dispatcher no confinado es el que más se malinterpreta porque su nombre sugiere libertad y lo que ofrece es ausencia de política. Arranca la corrutina en el hilo que la lanzó, sin despachar, y tras cada suspensión la reanuda en el hilo que haya completado la operación que la suspendió. No hay pool, no hay cola y no hay garantía alguna sobre dónde se ejecutará la siguiente línea. Sus usos legítimos son estrechos: pruebas donde se quiere ejecución inmediata y determinista hasta la primera suspensión, y operadores de librería que deben ser transparentes respecto al hilo. En código de aplicación es casi siempre un error, porque introduce dependencias ocultas sobre qué hilo completó una operación ajena.
runBlocking {
launch(Dispatchers.Unconfined) {
println(Thread.currentThread().name) // el hilo que lanzo
delay(10)
println(Thread.currentThread().name) // el hilo del temporizador
}
}
Hay un matiz que conviene no perder: ese dispatcher tampoco garantiza ausencia de despacho. Lo que garantiza es que no lo introduce él, y por tanto la corrutina queda a merced de quien la reanude. Si la operación que la suspendió se completa en un hilo de temporizador, la continuación seguirá allí; si se completa en un hilo de red, allí seguirá. Ejecutar trabajo largo en esa continuación significa secuestrar un hilo que pertenece a otro subsistema, y esa es la razón profunda de que su uso quede reservado a bloques triviales.
En el extremo opuesto está el confinamiento a un solo hilo, que es una herramienta de sincronización disfrazada de dispatcher. Si todo el acceso a un estado mutable ocurre en un único hilo, no hace falta ningún cerrojo ni ninguna estructura atómica: la exclusión mutua la garantiza la propia serialización de la cola. Durante años se obtenía con un constructor específico que creaba un hilo dedicado y obligaba a cerrarlo a mano; hoy la forma recomendada es una vista de paralelismo uno sobre un pool existente, que no reserva ningún hilo en exclusiva y no necesita cierre.
class RegistroDeEventos {
private val confinado = Dispatchers.Default.limitedParallelism(1)
private var contador = 0 // sin volatile, sin cerrojo
suspend fun anotar() = withContext(confinado) { contador++ }
suspend fun total(): Int = withContext(confinado) { contador }
}
La tentación al aprender esto es reducirlo a una tabla de dos filas, cómputo para calcular y entrada y salida para esperar, y esa tabla acierta en el caso fácil y falla exactamente donde el sistema se cae. Lo que un dispatcher expresa de verdad no es una categoría de trabajo sino una decisión sobre un recurso compartido y finito, que son los hilos del proceso, y toda decisión sobre un recurso finito es una decisión sobre quién espera cuando no hay suficiente. Cuando eliges el pool de cómputo estás afirmando que tu tarea competirá por procesador con todas las demás tareas de cómputo del proceso y que ese reparto equitativo te parece bien; si además tu tarea es larga y no tiene puntos de suspensión, estás afirmando algo más fuerte y probablemente falso, que es que puede ocupar uno de los pocos hilos disponibles sin ceder. Cuando eliges el pool de entrada y salida estás afirmando que tu tarea pasará la mayor parte del tiempo esperando y que por tanto ocupar un hilo sin usarlo es aceptable, lo cual es cierto siempre que el número de tareas simultáneas sea menor que la cuota; en cuanto la supera, has convertido una espera externa en una cola interna invisible que no aparece en ninguna métrica y que hace que la latencia se dispare sin que ningún recurso parezca saturado. Y cuando eliges una vista con paralelismo limitado estás haciendo lo único que un ingeniero debería hacer por defecto: declarar explícitamente cuántas operaciones simultáneas tolera el recurso real que hay al otro lado, sea un pool de conexiones, una API con límite de peticiones o una estructura de datos que no admite acceso concurrente. Esa declaración convierte un problema de rendimiento difuso en un número que se puede escribir, discutir en una revisión y ajustar con datos. El confinamiento a un solo hilo lleva esa misma idea al extremo y a cambio regala la exclusión mutua sin cerrojos, que es la forma más barata de eliminar una familia entera de errores. El corolario es incómodo pero claro: el dispatcher no es un detalle de implementación que se copia del ejemplo anterior, es parte del contrato de rendimiento de tu componente, y merece la misma deliberación que el tamaño de un pool o el tiempo de espera de una petición.
- Imprime el nombre del hilo al principio y al final de una corrutina lanzada en cada uno de los cuatro dispatchers y explica cada resultado.
- Lanza mil tareas de cómputo puro en el pool por defecto y repítelo con una vista de paralelismo uno; compara el tiempo total y razona la diferencia.
- Sustituye un objeto sincronizado con cerrojo por una clase confinada a un solo hilo y argumenta qué garantía se mantiene y cuál cambia.
- Crea una vista de paralelismo limitado del tamaño de tu pool de conexiones y mide la latencia con y sin ella bajo carga.
- Ejecuta una corrutina no confinada con dos suspensiones distintas y documenta en qué hilo se reanuda cada una y por qué.