wandres.dev
CORRUTINAS II · concurrencia estructurada

Job y la jerarquía: estados, padres e hijos

Todo lo que la concurrencia estructurada promete está implementado en un único objeto. Esta lección estudia el Job como máquina de estados con seis situaciones posibles y transiciones irreversibles, la forma exacta en que un hijo se registra en su padre al nacer, el significado preciso de join frente a esperar un valor, el estado intermedio de completándose que casi nadie conoce y que explica muchas esperas aparentemente inexplicables, y la secuencia completa de lo que ocurre en el árbol cuando un descendiente falla.

⏱ 19 min

Detrás del vocabulario de padres, hijos y ámbitos hay un único objeto que lo sostiene todo, y conviene conocerlo con precisión porque la mayoría de los comportamientos que sorprenden en corrutinas son consecuencias exactas de su máquina de estados. Un Job no representa la ejecución de un cálculo ni el hilo donde ocurre ni el valor que produce: representa el ciclo de vida de una unidad de trabajo cancelable y su lugar en un árbol. Es deliberadamente pobre en información, porque no expone resultado alguno, y deliberadamente rico en relaciones, porque conoce a su padre, a sus hijos y el estado en que se encuentra el conjunto. Esa asimetría es intencionada: al separar el ciclo de vida del valor calculado, Kotlin consigue que la misma maquinaria gobierne una corrutina que devuelve algo, una que no devuelve nada, un ámbito entero y hasta un objeto que nunca ejecutó código.

🎯 Al terminar esta lección sabrás
  • Enumerar los estados de un Job y explicar qué transiciones son posibles y cuáles irreversibles.
  • Describir el registro de un hijo en su padre y las consecuencias de que ese registro exista.
  • Distinguir con precisión entre join, await y la simple lectura de isActive.
  • Trazar la secuencia completa de propagación cuando un hijo falla o cuando el padre se cancela.

La máquina de estados

Un Job atraviesa como máximo seis situaciones y solo avanza en un sentido: no existe forma de reactivar uno terminado. Su interfaz pública expone tres propiedades booleanas, isActive, isCompleted e isCancelled, cuya combinación identifica el estado real, y esa aparente redundancia esconde precisamente los estados intermedios que más confusión provocan.

flowchart LR
A[Nuevo] --> B[Activo]
B --> C[Completandose]
C --> D[Completado]
B --> E[Cancelandose]
C --> E
E --> F[Cancelado]

Un Job nace activo salvo que se cree con arranque diferido, en cuyo caso permanece en estado nuevo hasta que alguien lo inicia. Mientras está activo ejecuta su cuerpo y admite hijos nuevos. Cuando el cuerpo termina no pasa directamente a completado: entra en el estado de completándose, en el que ya no acepta hijos nuevos pero sigue esperando a los que tenga vivos. Solo cuando el último descendiente concluye alcanza el estado completado, que es terminal. Las dos situaciones de la rama inferior son la versión fallida de lo mismo: cancelándose mientras propaga la cancelación hacia abajo y espera a que sus hijos la acaten, y cancelado cuando el árbol entero ha dejado de ejecutar.

val padre = Job()
val hija = CoroutineScope(padre).launch {
    delay(1_000)
}

println(padre.isActive)     // true
println(padre.children.count())   // 1
hija.join()                 // suspende hasta que la hija termina
ℹ️
El estado de completándose explica casi todas las esperas sorprendentes

Si una corrutina parece no terminar aunque su última línea se ejecutó hace rato, casi siempre está en el estado de completándose con algún descendiente vivo del que su autor no era consciente. Puede ser un hijo lanzado dentro de un bloque anidado, una colección que se sigue emitiendo o una tarea creada en un invokeOnCompletion. Inspeccionar children de forma recursiva es el diagnóstico directo, y comprobar que isCompleted es falso mientras isActive también lo es confirma que se está exactamente en ese estado intermedio.

Padre e hijo: el registro que ocurre al nacer

Cuando se lanza una corrutina, su contexto se construye combinando el contexto del ámbito con los elementos que se le pasen, y el Job recién creado toma como padre el Job presente en el contexto heredado. Ese enlace se establece en el instante de la creación, es bidireccional y no se puede alterar después: el hijo guarda una referencia a su padre y el padre añade el hijo a su lista interna. Todo lo demás se sigue de ahí.

suspend fun ejemplo() = coroutineScope {          // este es el padre
    val a = launch { trabajoLargo() }             // hija registrada
    val b = launch { otroTrabajo() }              // hija registrada

    a.join()                    // espera solo a la hija a
    println("a termino, b puede seguir viva")
}                               // el bloque no retorna hasta que b tambien termina

El detalle que distingue a quien entiende el modelo es que join no cancela ni desregistra nada: solo suspende a quien lo invoca hasta que el Job observado alcanza un estado terminal, sea completado o cancelado. Es una espera pasiva. Por eso el ejemplo anterior espera a la hija a de forma explícita y a la hija b de forma implícita, porque el cierre del bloque de coroutineScope es en realidad la espera estructural de todos los hijos registrados.

Conviene además separar tres esperas que se confunden. join espera la terminación sin producir valor y sin relanzar el fallo en quien espera. await, que solo existe sobre un Deferred, espera la terminación y devuelve el valor o relanza la excepción. Y leer isActive no espera nada: es una consulta instantánea cuyo resultado puede haber caducado en la línea siguiente, útil para decidir si merece la pena seguir trabajando y nunca para sincronizar.

val h = scope.launch { puedeFallar() }

h.join()                    // no lanza nada aunque la corrutina fallara
println(h.isCancelled)      // asi se averigua si termino mal

// invokeOnCompletion recibe la causa, o null si termino bien
h.invokeOnCompletion { causa ->
    if (causa != null) registro.aviso("termino con causa", causa)
}

La función invokeOnCompletion completa el cuadro y merece conocerse porque es el único observador de terminación que no suspende: registra una devolución de llamada que el Job invoca al alcanzar un estado terminal, entregando la causa cuando la hubo. Su valor está en el código de infraestructura que necesita reaccionar a la muerte de un trabajo sin ser a su vez una corrutina, por ejemplo para liberar un recurso compartido o para actualizar un contador de tareas vivas. Su peligro es que se ejecuta en el hilo que provocó la terminación, sea cual sea, así que el cuerpo debe ser breve y no bloquear.

Qué le pasa al padre cuando un hijo falla

La secuencia es determinista y merece memorizarse porque explica todos los comportamientos de propagación que se estudiarán en las lecciones siguientes. Cuando el cuerpo de un hijo lanza una excepción que no es de cancelación, ocurre lo siguiente y en este orden.

🌳

1. El hijo pasa a cancelándose

Su propio subárbol recibe la cancelación y el hijo guarda la excepción como causa de su terminación anómala.

⬆️

2. El fallo sube al padre

El hijo notifica al padre, que decide. Un Job corriente asume el fallo como propio; un SupervisorJob lo ignora y deja al hijo morir solo.

⬇️

3. El padre cancela a los hermanos

Al asumir el fallo, el padre entra en cancelándose y propaga la cancelación a todos sus otros hijos, que a su vez la propagan hacia abajo.

🏁

4. El árbol termina y relanza

Cuando todos los descendientes han acatado, el padre alcanza estado terminal y expone la excepción original a quien lo espere o a su propio padre.

Hay una asimetría fundamental en este mecanismo y no entenderla produce diseños incorrectos. La cancelación se propaga hacia abajo siempre, en toda la descendencia y sin excepción. El fallo, en cambio, se propaga hacia arriba y esa subida es la que se puede interceptar: un SupervisorJob no impide que un hijo muera, impide que su muerte contamine al padre y por tanto a los hermanos. Dicho de otro modo, un supervisor no es un padre más tolerante con sus hijos, es un padre que no se deja arrastrar por ellos.

// El fallo de una hija cancela a la otra y al ambito entero
runBlocking {
    launch {
        delay(50)
        error("fallo temprano")
    }
    launch {
        try {
            delay(5_000)
        } finally {
            println("cancelada por culpa de mi hermana")
        }
    }
}

Queda una consecuencia poco intuitiva que conviene anticipar. Como el fallo sube y el padre lo asume, el Job de un ámbito de larga vida queda destruido de forma permanente en cuanto una sola de sus tareas lanza una excepción no controlada; el ámbito sigue existiendo como objeto, pero cualquier launch posterior no ejecutará nada porque su Job nace ya cancelado. Es el modo más común de que un componente deje de responder sin registrar ningún error visible, y la razón por la que los ámbitos duraderos casi siempre se construyen con un supervisor.

Un Job sin cuerpo: poseer sin ejecutar

Hasta aquí todo Job ha aparecido como subproducto de lanzar una corrutina, pero se pueden construir directamente y esa posibilidad revela para qué sirve realmente el tipo. Un Job creado a mano no ejecuta código alguno: nace activo, admite hijos y permanece así hasta que alguien lo cancela o lo completa. Es puro ciclo de vida, un asa para poseer trabajo ajeno.

val raiz = SupervisorJob()
val ambito = CoroutineScope(raiz + Dispatchers.Default)

// Un subarbol propio que se puede matar sin tocar el resto
val tanda = Job(parent = raiz)
val ambitoTanda = CoroutineScope(tanda + Dispatchers.Default)
repeat(10) { i -> ambitoTanda.launch { procesar(i) } }

tanda.cancel()     // mata las diez, deja intacto el resto del ambito

El parámetro de padre en el constructor merece atención porque es la única forma manual de enganchar un nodo al árbol y hace posible una técnica muy útil: crear subárboles con vida propia dentro de un ámbito mayor. Con ella se pueden agrupar tareas por criterio de negocio, por ejemplo todas las de una pantalla o todas las de un identificador, y cancelarlas en bloque sin afectar a las demás, manteniendo al mismo tiempo la garantía de que si muere la raíz mueren todas.

Existe además el caso simétrico, un objeto que representa un valor futuro sin corrutina que lo calcule. Un CompletableDeferred se crea vacío y alguien lo completa desde fuera con un valor o con una excepción, lo que lo convierte en el puente idiomático entre una API de devoluciones de llamada y el mundo de las corrutinas: quien espera escribe un await corriente, y el adaptador se limita a completar el objeto cuando la llamada externa responda. Como sigue siendo un Job, participa en el árbol y se cancela con él, que es exactamente lo que a un puente hecho a mano con promesas le faltaría.

💡
Nunca construyas un `Job` que no pienses cancelar

Un Job creado a mano y guardado en una propiedad es una promesa de que alguien lo cancelará; si esa promesa no se cumple, el objeto retiene a todos sus hijos y el efecto es el mismo que el de un ámbito global, con el agravante de que ahora parece estructurado. La comprobación es la misma de siempre y cabe en una línea: por cada construcción de un Job debe existir en el código un punto identificable donde se le invoca la cancelación.

Separar el ciclo de vida del resultado es lo que permite que un único mecanismo gobierne todo el sistema

La decisión de diseño más elegante de esta parte de la biblioteca es una que pasa desapercibida porque consiste en algo que no está: un Job no tiene valor. Podría haberlo tenido, y de hecho la tradición previa de promesas y futuros en la mayoría de plataformas mezcla ambas cosas en un solo objeto que representa a la vez la tarea en curso y el resultado que producirá. Esa mezcla parece cómoda hasta que se intenta construir jerarquías con ella, momento en el que aparece la pregunta imposible de qué valor tiene el nodo intermedio de un árbol cuyas hojas devuelven cosas de tipos distintos, y la respuesta habitual consiste en inventar futuros de tipo vacío, envolturas y conversiones que existen únicamente para tapar la incoherencia. Kotlin corta el nudo separando los dos conceptos en dos capas: Job modela exclusivamente el ciclo de vida cancelable y la pertenencia al árbol, y Deferred añade encima el valor cuando hace falta, siendo un subtipo y no un objeto distinto. De esa separación se sigue todo lo demás con una economía notable. El ámbito puede tener un Job sin que nadie pregunte qué devuelve un ámbito. Un supervisor puede ser un Job sin cuerpo que nunca ejecuta código y sirve solo para poseer. La cancelación puede definirse una sola vez, sobre el ciclo de vida, y valer igual para la corrutina que calcula un entero y para la que no calcula nada. Y la relación padre e hijo puede ser universal, porque une ciclos de vida y no resultados, de modo que un padre puede esperar a hijos heterogéneos sin que sus tipos tengan que unificarse en ninguna parte. La lección de fondo trasciende las corrutinas: cuando dos conceptos distintos se empaquetan en un mismo tipo por comodidad, la deuda no se paga en la API que los junta sino en todas las construcciones que después necesitan solo uno de los dos y se ven obligadas a fabricar el otro.

⚔️ Instrumenta la jerarquía
  1. Crea un ámbito con dos hijas de distinta duración y ve imprimiendo isActive, isCompleted e isCancelled del padre en varios instantes. Identifica el estado de completándose.
  2. Escribe una función recursiva que recorra children e imprima el árbol de corrutinas vivas con su profundidad.
  3. Provoca un fallo en una hija y registra con invokeOnCompletion el orden real en que terminan padre, hermana y nieta. Contrástalo con la secuencia de cuatro pasos de esta lección.
  4. Demuestra con código que join sobre una hija fallida no relanza la excepción en quien espera, y explica dónde aparece entonces.
  5. Construye un ámbito de larga vida con Job corriente, hazlo fallar una vez y comprueba que los launch posteriores no ejecutan nada. Repite con SupervisorJob.