withContext y el coste real del salto
Cambiar de dispatcher dentro de una función suspendida parece una operación gratuita y no lo es: hay una fusión de contextos, un punto de suspensión, una posible visita a una cola de trabajo y un cambio de hilo con todo lo que arrastra. Esta lección mide ese coste, explica el camino rápido que a veces lo evita, y deriva de ahí la regla de diseño más importante de toda la API suspendida.
Hay una función que aparece en casi todas las bases de código con corrutinas y que casi nadie ha leído por dentro. Se escribe para saltar a otro dispatcher, se asume que el salto es instantáneo y se reparte por el código con la misma alegría con la que se pone un registro de depuración. La realidad es que cada uso implica fusionar dos contextos, crear una corrutina interna, suspender la actual, posiblemente encolar trabajo en otro pool, esperar a que un hilo lo recoja y volver a hacer el camino inverso al terminar. Cuando eso ocurre una vez por petición, el coste es irrelevante. Cuando ocurre una vez por elemento dentro de un bucle de diez mil vueltas, el coste domina el perfil y la función que parecía optimizada resulta ser la más lenta del sistema. Esta lección convierte esa intuición en números y en una regla de diseño que ordena toda la API suspendida de un proyecto.
- Describir paso a paso qué hace
withContextdesde la fusión del contexto hasta la restauración del anterior. - Distinguir el camino rápido sin despacho del camino completo con cambio de hilo y estimar el coste de cada uno.
- Formular y aplicar la regla de que una función suspendida debe ser segura de llamar desde cualquier dispatcher.
- Reconocer los usos incorrectos: el salto dentro del bucle, el salto para paralelizar y el salto que no cancela.
Qué ocurre exactamente en cada llamada
La función recibe un contexto y un bloque, y su primer acto es calcular el contexto resultante sumando el actual con el recibido, con la regla de precedencia por la derecha que ya conocemos. A continuación compara ese contexto con el de partida. Si son el mismo objeto, ejecuta el bloque sin ceremonia alguna. Si difieren solo en elementos que no son el interceptor, crea una corrutina interna pero se reanuda sin despachar. Y si el interceptor ha cambiado, entra el camino completo: la corrutina actual se suspende, el bloque se encola en el dispatcher de destino y la reanudación posterior vuelve a encolarse en el dispatcher de origen.
suspend fun cargarPerfil(id: Long): Perfil {
val crudo = withContext(Dispatchers.IO) { // salto de ida
cliente.leerBloqueante(id) // se ejecuta en el pool de espera
} // salto de vuelta
return analizar(crudo) // de nuevo en el dispatcher del llamante
}
Tres propiedades del bloque conviene fijarlas ahora porque distinguen esta construcción de cualquier otra. La primera es que devuelve el valor de la última expresión, con tipo estático, lo que la convierte en una expresión y no en un procedimiento. La segunda es que es un punto de suspensión y por tanto un punto de cancelación: si el trabajo se cancela mientras el bloque está pendiente, la reanudación lanzará la excepción correspondiente. La tercera, y la más olvidada, es que el bloque hereda el resto del contexto, incluido el Job, de modo que sigue formando parte del árbol y sus fallos se propagan hacia arriba con normalidad.
flowchart TD
A[Llamada a withContext] --> B[Suma del contexto actual y el nuevo]
B --> C{Cambia el interceptor}
C -->|no| D[Ejecuta el bloque sin despachar]
C -->|si| E[Encola en el dispatcher destino]
E --> F[Un hilo del destino ejecuta el bloque]
F --> G[Encola la reanudacion en el origen]
G --> H[Devuelve el valor al llamante]
D --> HCuánto cuesta de verdad
Conviene separar el coste en tres partidas independientes porque solo una de ellas es evitable con un cambio local. La primera es la asignación de objetos: una corrutina interna, un contexto combinado y una continuación envuelta, todo ello en el orden de decenas de nanosegundos y despreciable salvo en bucles muy cerrados. La segunda es el paso por la cola del dispatcher de destino, que añade la latencia de planificación de la propia librería. La tercera, la cara, es el cambio de hilo real cuando el trabajo acaba ejecutándose en un núcleo distinto: se pierde la localidad de caché, se paga una barrera de memoria y se depende del planificador del sistema operativo para que despierte al trabajador.
// Catastrofico: dos saltos por elemento
suspend fun malo(ids: List<Long>): List<Perfil> =
ids.map { id -> withContext(Dispatchers.IO) { leerBloqueante(id) } }
// Correcto: dos saltos en total
suspend fun bueno(ids: List<Long>): List<Perfil> =
withContext(Dispatchers.IO) { ids.map { id -> leerBloqueante(id) } }
El orden de magnitud útil para razonar es el siguiente: un salto sin cambio de hilo cuesta cientos de nanosegundos, un salto con cambio de hilo cuesta unidades de microsegundos y una operación de red o de disco cuesta milisegundos. La conclusión no es que haya que evitar los saltos sino que hay que compararlos con lo que envuelven. Envolver una lectura de disco de cinco milisegundos en un salto de dos microsegundos es una decisión excelente. Envolver una operación aritmética de veinte nanosegundos en ese mismo salto multiplica su coste por cien.
Asignación
Corrutina interna, contexto combinado y continuación envuelta. Decenas de nanosegundos y presión sobre el recolector; solo importa en bucles muy cerrados.
Encolado
El bloque entra en la cola del dispatcher de destino y espera a que un trabajador lo recoja. Bajo carga, esta partida crece con la ocupación del pool.
Cambio de hilo
Pérdida de localidad de caché, barrera de memoria y dependencia del planificador del sistema. Es la partida cara y la única realmente impredecible.
Camino rápido
Si el contexto resultante coincide con el actual, no hay despacho ni cambio de hilo. Solo queda la maquinaria de la suspensión, que es casi gratuita.
Que exista ese camino rápido explica una observación desconcertante: dos programas idénticos salvo en el dispatcher de partida pueden diferir en un orden de magnitud aunque el número de saltos escritos sea el mismo. Cuando el llamante ya estaba donde el bloque quiere estar, la llamada se reduce a comprobar una identidad de referencias. Cuando no lo estaba, se paga entera. Por eso las mediciones microscópicas de esta función son tan poco fiables si no se replica el contexto real de producción: el banco de pruebas suele ejecutar todo en el mismo dispatcher y mide sistemáticamente el caso favorable.
La corrección casi nunca consiste en eliminar el cambio de dispatcher sino en moverlo hacia arriba hasta que envuelva el mayor bloque de trabajo homogéneo posible. Un salto por lote en lugar de un salto por elemento; un salto por petición en lugar de un salto por capa. Si al subirlo descubres que el bloque mezcla trabajo de espera con trabajo de cómputo, esa mezcla es la señal de que las dos responsabilidades deben separarse en dos funciones.
La regla de la seguridad desde cualquier sitio
De todo lo anterior se deriva un principio de diseño que ordena una base de código entera y que se enuncia así: quien llama a una función suspendida no debería tener que saber en qué dispatcher está para poder llamarla sin romper nada. Toda la responsabilidad de colocarse en el sitio adecuado pertenece a la función que hace el trabajo, nunca al llamante. Una función que lee de disco cambia ella misma al dispatcher de espera; una que comprime un megabyte cambia ella misma al de cómputo; una que solo orquesta a las dos no cambia nada.
// Mal: el contrato obliga al llamante a conocer un detalle interno
suspend fun leerFichero(ruta: String): String =
File(ruta).readText() // bloquea el dispatcher del llamante
// Bien: la funcion se hace responsable de donde corre
suspend fun leerFichero(ruta: String): String = withContext(Dispatchers.IO) {
File(ruta).readText()
}
Una función que solo orquesta no debe tocar el dispatcher, y esa abstinencia es tan importante como el cambio en las hojas. Cuando la capa de coordinación no menciona ningún dispatcher, se lee como código secuencial ordinario y su prueba no necesita hilos reales.
// Orquestacion pura: ningun cambio de contexto, cada dependencia se coloca sola
suspend fun publicar(id: Long): Resultado {
val perfil = repositorio.leer(id) // ella salta al pool de espera
val resumen = analizador.resumir(perfil) // ella salta al pool de computo
return publicador.enviar(resumen) // y ella tambien se coloca sola
}
Esta regla tiene tres corolarios prácticos. El primero es que ninguna función pública debería exigir un dispatcher concreto en su documentación, porque una exigencia así es imposible de verificar y se romperá en cuanto alguien la llame desde otra capa. El segundo es que el dispatcher debe inyectarse en lugar de escribirse literalmente, porque una llamada literal es intestable y obliga a las pruebas a usar hilos reales. El tercero es que una función que no bloquea y no calcula no debe tocar el dispatcher en absoluto: cada salto innecesario es coste puro sin beneficio.
class RepositorioFicheros(
private val despacho: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun leer(ruta: String): String = withContext(despacho) { File(ruta).readText() }
}
Los tres errores que se repiten
El primero es usar el cambio de contexto creyendo que paraleliza. No lo hace: el bloque se ejecuta y la llamada espera a que termine, exactamente igual que una llamada normal. La concurrencia se obtiene lanzando varias corrutinas y esperando sus resultados, y el dispatcher solo decide dónde corren esas corrutinas, no cuántas hay.
// Secuencial pese al cambio de dispatcher
val a = withContext(Dispatchers.IO) { pedirA() }
val b = withContext(Dispatchers.IO) { pedirB() }
// Concurrente de verdad
coroutineScope {
val da = async(Dispatchers.IO) { pedirA() }
val db = async(Dispatchers.IO) { pedirB() }
da.await() to db.await()
}
La distinción se ve mejor al recordar que este cambio de contexto no crea ninguna corrutina hermana: crea una corrutina interna que el llamante espera de forma directa, sin canal ni resultado diferido. La concurrencia necesita dos corrutinas vivas a la vez, y eso solo lo producen los constructores que lanzan trabajo y devuelven el control de inmediato.
El segundo error es olvidar que el bloque es cancelable y usarlo para tareas de limpieza. Si la corrutina ya está cancelada, cualquier suspensión dentro del bloque lanzará de inmediato y la limpieza no llegará a ejecutarse; para ese caso concreto existe un elemento de contexto que desactiva la cancelación y que solo debe usarse en bloques cortos y sin espera indefinida. El tercero es anidar saltos redundantes por capas, de manera que una petición atraviese cuatro cambios de dispatcher hasta llegar al trabajo real: el camino rápido evita el despacho cuando el destino coincide, pero no evita la creación de la corrutina interna ni el paso por la máquina de estados.
Lo que hay realmente en juego aquí no es rendimiento sino acoplamiento, y por eso esta lección importa mucho más de lo que su tema aparente sugiere. Una función suspendida que bloquea sin cambiar de dispatcher no tiene un defecto local que se pueda arreglar donde está: tiene un requisito no escrito que se propaga a todos sus llamantes presentes y futuros, y ese requisito no aparece en su firma, no lo verifica el compilador y no lo detecta ninguna prueba unitaria que la ejecute aislada. El primer llamante la usará desde el pool de espera y todo irá bien; el segundo la llamará desde una capa de dominio que corría en el pool de cómputo y ocupará uno de los pocos hilos disponibles; el tercero la llamará desde el hilo de interfaz y congelará la aplicación durante trescientos milisegundos, y el fallo se atribuirá a ese tercer llamante cuando la causa estaba escrita meses antes en un sitio que nadie va a mirar. Ese es el patrón de un fallo de diseño, no de un fallo de programación: el error se comete en un lugar y se manifiesta en otro. La regla de que toda función suspendida sea segura de llamar desde cualquier sitio corta ese patrón de raíz porque devuelve la decisión a quien tiene la información para tomarla, que es quien sabe si el trabajo bloquea o calcula, y libera a todos los demás de tener que saberlo. A cambio impone una disciplina que al principio parece pedante y que después resulta liberadora: mirar cada función suspendida y responder a una sola pregunta, qué hace este cuerpo cuando nadie lo mira, y colocar el cambio de dispatcher exactamente allí donde esa respuesta lo exija y en ningún otro sitio. Cuando esa disciplina se sostiene, ocurre algo que se nota enseguida en el día a día: las capas superiores dejan de mencionar dispatchers, la orquestación se lee como código secuencial ordinario, las pruebas dejan de necesitar hilos reales y el perfilador señala un puñado de saltos deliberados en vez de una neblina de microsegundos repartida por todas partes. Ese es el objetivo, y se alcanza tomando una decisión pequeña muchas veces, no una decisión grande una sola vez.
- Escribe la versión con salto por elemento y la versión con salto por lote sobre diez mil elementos y compara los tiempos.
- Instrumenta una función suspendida para que imprima el nombre del hilo antes, dentro y después del cambio de contexto, y llámala desde tres dispatchers distintos.
- Detecta en tu proyecto una función suspendida que bloquee sin cambiar de dispatcher y documenta a qué llamantes está imponiendo un requisito oculto.
- Convierte un dispatcher escrito literalmente en un parámetro con valor por defecto y escribe una prueba que lo sustituya por uno determinista.
- Cancela una corrutina mientras espera dentro de un cambio de contexto y demuestra qué ocurre con el bloque de limpieza posterior.