Contexto y dispatchers de corrutinas: withContext(Dispatchers.IO)
Una función suspend es una promesa de no bloquear: cúmplela aparcando las llamadas bloqueantes en Dispatchers.IO.
Cada corrutina se ejecuta dentro de un CoroutineContext: una bolsa inmutable, parecida a un conjunto, de elementos indexados por clave. El elemento que más tocarás es el ContinuationInterceptor, más conocido como dispatcher: decide qué hilo (o pool de hilos) reanuda realmente tu corrutina tras cada punto de suspensión. Otros elementos viajan en el mismo contexto: el Job que controla la cancelación, el CoroutineName para depurar y cualquier CoroutineExceptionHandler. Como el contexto es un mapa persistente, compones elementos con el operador + (por ejemplo Dispatchers.IO + CoroutineName("report-export")), y cada nuevo elemento reemplaza al anterior con la misma clave.
La librerÃa kotlinx.coroutines incluye tres dispatchers estándar, y elegir el correcto es todo el juego. Dispatchers.Default es un pool ligado a CPU dimensionado según el número de núcleos; úsalo para parsear, ordenar, hashear y otros cálculos. Dispatchers.IO es un pool mucho mayor y elástico (64 hilos por defecto, que crece bajo demanda) diseñado para absorber hilos atascados esperando E/S bloqueante. Dispatchers.Main es el único hilo de UI en Android — y, fundamentalmente, en una aplicación Spring WebFlux / Netty la petición se sirve en un pequeño pool de event-loop que se comporta igual: solo hay un puñado de hilos y nunca deben quedarse ociosos esperando.
Por eso nunca debes bloquear el event loop. Una función suspend establece un contrato implÃcito: se suspenderá (liberando el hilo) en lugar de aparcarlo. En el momento en que llamas a algo bloqueante — una consulta JDBC, RestTemplate, Thread.sleep, una lectura de InputStream o cualquier SDK sÃncrono heredado — desde una corrutina que corre en los hilos del event-loop de Netty, robas uno de esos pocos hilos y lo dejas secuestrado. Bloquea suficientes y el servidor entero deja de aceptar peticiones: la latencia se dispara, los health checks fallan y el servicio parece muerto aunque la CPU esté ociosa. El sÃntoma es brutal y no local, lo que lo hace difÃcil de diagnosticar después.
La solución es withContext(Dispatchers.IO) { ... }. Cambia la corrutina a un hilo del pool de IO, ejecuta el bloque allà y vuelve al dispatcher original cuando el bloque devuelve su valor. Es una función suspend, asà que el sitio de la llamada se lee como código secuencial normal y no hay anidamiento de callbacks. Envuelve exactamente — y solo — la llamada bloqueante; mantén la lógica suspend circundante en el dispatcher que ya estuviera usando. Piensa en withContext como un desvÃo preciso y acotado, no como un cambio permanente de hilo.
Un error común es recurrir a launch o async para "sacar el trabajo del hilo". Esos builders inician una nueva corrutina para concurrencia; withContext no lo hace: suspende la corrutina actual y devuelve un único valor, que es justo lo que quieres cuando solo necesitas ejecutar un paso bloqueante en otro hilo. Usa launch/async cuando realmente quieras que las cosas corran concurrentemente, y withContext cuando solo necesites reubicar trabajo secuencial. Recuerda también que envolver una llamada bloqueante no la hace cancelable: acompaña las operaciones bloqueantes largas con timeouts (por ejemplo withTimeout) y prefiere clientes verdaderamente suspendibles (R2DBC, el WebClient reactivo, métodos de repositorio suspend) cuando existan.
Dispatchers.IO tiene otro truco que vale la pena conocer: limitedParallelism. Llamar a Dispatchers.IO.limitedParallelism(n) recorta una vista del mismo pool compartido que usará como máximo n hilos, sin crear un pool nuevo. Esta es la forma idiomática de limitar la concurrencia contra un componente frágil aguas abajo — digamos un pool de conexiones de base de datos de 10 — para que una ráfaga de corrutinas no abra más llamadas bloqueantes de las que el recurso puede servir. Es mucho mejor que crear tu propio Executors.newFixedThreadPool y olvidar apagarlo.
Los elementos de contexto se propagan automáticamente a las corrutinas hijas, y esa herencia es lo que hace agradable la concurrencia estructurada. Cuando lanzas una hija dentro de un scope, la hija hereda el contexto del padre y solo sobrescribe los elementos que pasas explÃcitamente al builder. AsÃ, un CoroutineName o un elemento de contexto personalizado (como uno que lleve un tenant id o un valor de trace/MDC) puesto en el padre fluye hacia cada hija salvo que se sobrescriba. El Job es el único elemento que siempre se reemplaza: cada nueva corrutina recibe un Job hijo nuevo enlazado al padre, que es exactamente cómo se propagan la cancelación y los fallos por el árbol.
Llevándolo a Spring: mantén tus controladores y funciones suspend de servicio en el dispatcher de petición por defecto, haz toda la E/S reactiva/suspendible directamente, y recurre a withContext(Dispatchers.IO) como un envoltorio bien acotado solo alrededor de llamadas heredadas genuinamente bloqueantes que no puedas evitar. Transporta datos transversales (request id, tenant, locale) en elementos de contexto personalizados para que sobrevivan a los cambios de dispatcher, y limita la presión sobre los recursos compartidos con limitedParallelism. Haz eso y un único hilo de event-loop de Netty podrá servir miles de peticiones concurrentes sin quedar nunca secuestrado por una llamada bloqueante.
// legacy, synchronous SDK we cannot replaceclass LegacyPaymentClient {fun charge(orderId: String): Receipt { /* blocking HTTP/JDBC under the hood */ }}class PaymentService(private val client: LegacyPaymentClient) {// WRONG: blocks the Netty event-loop thread that is serving the requestsuspend fun chargeBad(orderId: String): Receipt =client.charge(orderId)// RIGHT: scoped detour onto the elastic IO pool, then backsuspend fun charge(orderId: String): Receipt =withContext(Dispatchers.IO) {client.charge(orderId) // blocks an IO thread, not the event loop}}
WRONG vs RIGHT: blocking a suspend function vs. parking the blocking call on Dispatchers.IO. The legacy client is synchronous; only the blocking line belongs on IO.
import kotlinx.coroutines.*fun blockingLookup(id: Int): String {Thread.sleep(200) // simulates a blocking I/O callreturn "user-$id"}suspend fun loadUser(id: Int): String =withTimeout(1_000) {withContext(Dispatchers.IO + CoroutineName("user-lookup")) {val thread = Thread.currentThread().nameprintln("running on: " + thread)blockingLookup(id)}}fun main() = runBlocking {println("caller on: " + Thread.currentThread().name)println(loadUser(42))}
withContext returns a single value and switches back automatically. Here it changes BOTH the dispatcher and adds a CoroutineName for readable thread dumps; withTimeout keeps a blocking call from hanging forever.
Arena IDEimport kotlinx.coroutines.*// at most 10 concurrent blocking calls, sharing the global IO poolval dbDispatcher = Dispatchers.IO.limitedParallelism(10)fun blockingQuery(i: Int): Int {Thread.sleep(100)return i * i}fun main() = runBlocking {val results = (1..100).map { i ->async(dbDispatcher) { blockingQuery(i) }}.awaitAll()println("sum = " + results.sum())}
Capping concurrency against a fragile resource: limitedParallelism views the shared IO pool but allows at most 10 threads, so 100 coroutines cannot open more than 10 blocking DB calls at once.
Arena IDEimport kotlinx.coroutines.*import kotlin.coroutines.AbstractCoroutineContextElementimport kotlin.coroutines.CoroutineContextclass RequestId(val value: String) :AbstractCoroutineContextElement(RequestId) {companion object Key : CoroutineContext.Key<RequestId>}fun main() = runBlocking {withContext(RequestId("req-123") + CoroutineName("handler")) {launch(Dispatchers.IO) { // inherits RequestId, overrides only the dispatcherval id = coroutineContext[RequestId]?.valueprintln("child sees request id: " + id)}}}
Custom context elements propagate to children and survive dispatcher switches — ideal for a request/tenant id. The child launched on Dispatchers.IO still sees the same RequestId.
Arena IDE🧠Comprueba tu comprensión
0/1 · 0/1 answered1. In a Spring WebFlux service, why is calling a blocking JDBC query directly inside a suspend function (with no dispatcher switch) dangerous?