wandres.dev
CORRUTINAS III · contexto y dispatchers

CoroutineContext: un mapa indexado por tipo

El contexto de una corrutina no es una bolsa de opciones sino una estructura de datos con leyes propias: un conjunto inmutable indexado por claves que son tipos, donde cada elemento aparece una sola vez, un operador de suma que resuelve las colisiones por la derecha y no es conmutativo, y una regla de herencia que fabrica el contexto de cada hijo combinando el del padre, los argumentos del constructor y un Job recién creado.

⏱ 20 min

El contexto es probablemente la pieza de las corrutinas que más gente usa a diario sin haber mirado nunca dentro. Se pasa un dispatcher a un launch, se añade un CoroutineName para que los registros se lean, se recuerda vagamente que ahí vive un Job, y el conjunto funciona. Funciona hasta el día en que una excepción no llega al manejador que habías instalado, o en que un hijo hereda un dispatcher que no esperabas, o en que dos sumas escritas en distinto orden producen comportamientos distintos. Todos esos casos dejan de ser misteriosos en cuanto se acepta un hecho estructural: CoroutineContext no es configuración, es una colección inmutable indexada por tipo, con un álgebra propia y una regla de herencia estricta que se aplica en cada creación de corrutina sin excepciones. Esta lección desmonta esa estructura hasta que sus consecuencias se vuelven predecibles antes de ejecutar el programa.

🎯 Al terminar esta lección sabrás
  • Describir CoroutineContext como conjunto indexado por claves de tipo y explicar por qué un elemento es a la vez clave y colección.
  • Identificar los elementos canónicos que viajan en el contexto y la responsabilidad exacta de cada uno.
  • Aplicar el operador de suma sabiendo qué ocurre cuando dos elementos comparten clave y por qué el orden importa.
  • Reconstruir el contexto que recibe una corrutina hija a partir del contexto del padre y de los argumentos del constructor.

Un conjunto que se comporta como un mapa

La interfaz declara cuatro operaciones y ninguna de ellas es accidental. Hay una lectura indexada, un plegado que permite recorrer todos los elementos, una suma que combina dos contextos y una resta que elimina el elemento asociado a una clave. Lo llamativo es la firma de la lectura: recibe una clave paramétrica y devuelve un valor del mismo parámetro de tipo. Eso significa que el contexto es un mapa heterogéneo con tipado estático, algo que un mapa normal no puede ofrecer porque perdería el tipo del valor al recuperarlo.

public interface CoroutineContext {
    public operator fun <E : Element> get(key: Key<E>): E?
    public fun <R> fold(initial: R, operation: (R, Element) -> R): R
    public operator fun plus(context: CoroutineContext): CoroutineContext
    public fun minusKey(key: Key<*>): CoroutineContext

    public interface Key<E : Element>

    public interface Element : CoroutineContext {
        public val key: Key<*>
    }
}

La segunda sutileza es que Element extiende CoroutineContext. Un elemento suelto es también un contexto de un solo miembro, y por eso se puede pasar un dispatcher allí donde se espera un contexto completo sin envolverlo en nada. Esta autorreferencia elimina toda distinción entre el contenedor y lo contenido, y es lo que permite que el operador de suma acepte indistintamente elementos y combinaciones. El contexto vacío es un objeto propio, EmptyCoroutineContext, que actúa como elemento neutro de esa suma.

flowchart LR
A[CoroutineContext] --> B[EmptyCoroutineContext]
A --> C[Element]
A --> D[CombinedContext]
C --> E[Job]
C --> F[ContinuationInterceptor]
C --> G[CoroutineName]
C --> H[CoroutineExceptionHandler]
D --> C
D --> D

La tercera sutileza es la clave. Cada elemento declara una propiedad key cuyo valor es, por convención universal en la librería, el companion object de su propia clase. La clave no es una cadena ni un identificador arbitrario: es un objeto único ligado al tipo, y de ahí se sigue la invariante central de toda la estructura. En un contexto no puede haber dos dispatchers ni dos nombres ni dos manejadores, porque comparten clave. Un contexto no es una lista de opciones acumuladas sino un conjunto donde cada categoría aparece a lo sumo una vez.

Los cuatro elementos que casi siempre viajan

🧬

`Job`

El nodo del árbol de concurrencia estructurada. Guarda el estado de la corrutina, la referencia al padre y la lista de hijos, y es quien propaga la cancelación en ambos sentidos.

🧵

El dispatcher

Su clave real es ContinuationInterceptor, heredada de la librería estándar. Decide en qué hilo se reanuda cada continuación interceptando la ejecución en cada punto de suspensión.

🏷️

`CoroutineName`

Una etiqueta de diagnóstico sin efecto sobre la ejecución. Aparece en los nombres de hilo cuando el modo de depuración está activo y en los volcados de la librería.

🛟

`CoroutineExceptionHandler`

El último recurso ante una excepción no capturada. Solo tiene efecto en la raíz de una jerarquía, nunca en un hijo, y esa asimetría confunde a casi todo el mundo la primera vez.

Leer cualquiera de ellos desde dentro de una corrutina es una consulta ordinaria sobre el mapa, y la propiedad coroutineContext está disponible en toda función suspendida sin necesidad de recibirla como parámetro, porque el compilador la obtiene de la continuación oculta que ya está pasando.

suspend fun inspeccionar() {
    val ctx = coroutineContext
    println(ctx[CoroutineName]?.name)              // la clave es el companion
    println(ctx[Job]?.isActive)
    println(ctx[ContinuationInterceptor])          // aqui aparece el dispatcher

    // Plegado sobre todos los elementos presentes
    val cuantos = ctx.fold(0) { acc, _ -> acc + 1 }
    println("elementos en el contexto: $cuantos")
}

Que la clave del dispatcher sea ContinuationInterceptor y no algo llamado dispatcher no es una curiosidad histórica sino una decisión de capas. La interceptación de continuaciones pertenece al lenguaje y vive en la librería estándar; el despacho a hilos pertenece a la librería de corrutinas y se implementa como un caso particular de esa interceptación. Por eso un dispatcher personalizado no tiene que inventar ninguna clave: hereda la que ya existe, y automáticamente desplaza a cualquier otro dispatcher del contexto al sumarse.

La suma y sus leyes

El operador de suma toma dos contextos y devuelve uno nuevo, siempre nuevo, porque toda la estructura es inmutable. La regla de resolución es la única que hay que memorizar y se enuncia en una línea: cuando ambos lados contienen un elemento con la misma clave, gana el de la derecha. De ahí se sigue que la suma es asociativa pero no conmutativa, y que escribir las sumas en el orden equivocado produce silenciosamente un comportamiento distinto sin ningún aviso del compilador.

val base = Dispatchers.Default + CoroutineName("base")
val ajustado = base + Dispatchers.IO           // gana IO, el nombre sobrevive
val invertido = Dispatchers.IO + base          // gana Default, el nombre sigue igual

val sinNombre = ajustado.minusKey(CoroutineName)
val neutro = EmptyCoroutineContext + ajustado  // identico a ajustado

Hay una excepción de la que casi nadie se acuerda y que es la única regla especial de todo el álgebra: cuando el lado derecho contiene un manejador de excepciones y el izquierdo un interceptor, o cualquier otra combinación, la suma sigue la regla general; pero al sumar un contexto que contiene un Job con otro que también lo contiene, el resultado conserva únicamente el de la derecha y con ello se rompe el vínculo de paternidad. Ese es el mecanismo exacto por el que pasar un Job explícito convierte un hijo en una raíz, y conviene verlo como un caso particular de la regla y no como una anomalía.

Internamente, sumar dos elementos produce un CombinedContext, una lista enlazada donde cada nodo guarda un elemento y el resto. La consulta recorre esa cadena comparando claves por identidad. La consecuencia práctica es que un contexto con cuatro elementos se lee en cuatro comparaciones de referencia, un coste despreciable frente a cualquier suspensión, y que sumar contextos dentro de un bucle caliente asigna un objeto por nodo, un coste que sí conviene evitar construyendo el contexto una vez fuera.

💡
El orden de la suma decide quién gana

Si mantienes una plantilla de contexto en una propiedad y luego la especializas por llamada, escribe siempre la especialización a la derecha. Al revés la plantilla pisará el ajuste y el error no se manifestará como fallo sino como una corrutina ejecutándose en el dispatcher equivocado, que es exactamente la clase de defecto que sobrevive a la revisión de código y aparece bajo carga.

La herencia del padre al hijo

Cada constructor de corrutinas recibe un contexto opcional, y lo que acaba ejecutándose no es ese contexto sino el resultado de una fórmula fija. La librería toma el contexto del ámbito padre, le suma el contexto que has pasado como argumento y le suma un Job nuevo creado para esta corrutina, cuyo padre es el Job que venía heredado. Si tras ese cálculo no hay ningún interceptor, se añade el dispatcher por defecto.

val padre = CoroutineScope(Dispatchers.Default + CoroutineName("padre"))

padre.launch(CoroutineName("hijo")) {
    // dispatcher: heredado del padre
    // nombre: sustituido por el argumento
    // Job: nuevo, con el Job del padre como progenitor
    println(coroutineContext[CoroutineName]?.name)
}

Nótese que las funciones suspendidas ordinarias no participan de esta fórmula. Una función marcada como suspendida no crea corrutina alguna: se ejecuta dentro de la que ya existe y ve exactamente su contexto. Solo los constructores de corrutinas y las funciones que cambian de contexto derivan uno nuevo, y esa distinción es la que hace predecible dónde corre cada línea.

La asimetría del Job es el punto que hay que interiorizar. Todos los elementos del contexto se heredan por referencia: el hijo comparte el mismo objeto dispatcher, el mismo nombre y el mismo manejador que el padre. El Job, en cambio, nunca se hereda; se hereda su posición en el árbol. Si se heredase el objeto, cancelar un hijo cancelaría al padre y a todos sus hermanos, y la concurrencia estructurada dejaría de existir. Por eso pasar explícitamente un Job como argumento a un constructor de corrutinas rompe el vínculo con el padre y crea una raíz nueva, un error frecuente que convierte una jerarquía cuidadosamente diseñada en un conjunto de tareas huérfanas.

flowchart TD
A[Contexto del ambito padre] --> D[Suma de los tres]
B[Contexto pasado como argumento] --> D
C[Job nuevo con padre asignado] --> D
D --> E{Hay interceptor}
E -->|si| F[Contexto final del hijo]
E -->|no| G[Se anade el dispatcher por defecto]
G --> F
El contexto es la prueba de que la concurrencia estructurada es una estructura de datos, no una convención de estilo

Hay una manera perezosa de contar todo esto que consiste en decir que el contexto lleva ajustes y que los hijos los heredan, y esa versión es cierta en el mismo sentido en que decir que un árbol binario lleva números es cierto: describe el contenido y pierde por completo la forma, que es donde reside todo el poder explicativo. Lo que hace CoroutineContext es tomar cuatro preocupaciones que en cualquier otro modelo de concurrencia viven separadas y sin relación entre sí, a saber la identidad y el ciclo de vida de la tarea, la política de ejecución, la etiqueta de diagnóstico y la política de errores, y representarlas como miembros de un mismo conjunto homogéneo que se compone con un operador algebraico. Esa unificación no es elegancia gratuita, es la que permite que exista una única regla de herencia en lugar de cuatro reglas independientes, y una regla única es una regla que se puede aprender, verificar y razonar. De ahí se deriva casi todo lo que parece magia en las corrutinas. Que cancelar un ámbito cancele todo lo que se lanzó desde él no es un comportamiento programado caso por caso, es la consecuencia de que cada hijo reciba un Job cuyo padre es el Job heredado del contexto. Que una función suspendida se ejecute en el dispatcher de quien la llamó, y no en uno propio, no es una decisión de diseño de esa función, es la consecuencia de que el interceptor viaje en el contexto y de que las funciones suspendidas no creen contexto nuevo. Que un manejador de excepciones instalado en un hijo no sirva de nada no es un defecto, es la consecuencia de que la propagación siga las aristas del árbol de trabajos hasta la raíz. Y que el orden de la suma importe no es una trampa del operador, es lo que hace que especializar un contexto sea una operación local y componible en lugar de una fusión con reglas ad hoc. Quien ve el contexto como una bolsa de opciones tendrá que memorizar decenas de comportamientos aparentemente arbitrarios y se equivocará en los casos límite. Quien lo ve como lo que es, un conjunto indexado por tipo con un monoide encima y una única regla de derivación en cada creación, deduce todos esos comportamientos sobre la marcha y deja de necesitar la documentación para predecir qué va a pasar.

⚔️ Reconstruye el contexto a mano
  1. Escribe una función suspendida que imprima el nombre, el interceptor y el identificador del Job actuales, y llámala desde tres ámbitos con contextos distintos.
  2. Construye dos contextos con los mismos elementos sumados en orden inverso y demuestra con una traza qué dispatcher gana en cada caso.
  3. Lanza una corrutina pasándole un Job explícito como argumento, cancela el ámbito padre y explica por qué la corrutina sobrevive.
  4. Usa fold para escribir una función que devuelva la lista de nombres de clase de todos los elementos presentes en un contexto arbitrario.
  5. Crea un elemento de contexto propio con su clave en el companion object, sumalo a un ámbito y comprueba que los nietos también lo ven.