wandres.dev
CORRUTINAS II · concurrencia estructurada

coroutineScope y supervisorScope: elegir la propagación

Los dos constructores de ámbito local se diferencian en una sola cosa y esa cosa lo decide todo. Esta lección construye el ámbito local como herramienta de composición, contrasta la propagación en cadena del fallo con la contención que ofrece un supervisor, explica dónde se instala realmente un CoroutineExceptionHandler y por qué en la mayoría de los sitios donde se coloca no llega a ejecutarse nunca, y ofrece un criterio de elección basado en si las tareas hermanas comparten o no un destino común.

⏱ 20 min

Todo lo estudiado en este nivel converge en una pregunta de diseño que hay que responder cada vez que se escribe una función concurrente, y que es más de dominio que de programación: si una de estas tareas fracasa, las demás quedan sin sentido o siguen valiendo lo que valían. La respuesta no la puede dar la biblioteca porque depende de qué signifiquen las tareas; lo que la biblioteca hace es ofrecer dos constructores casi idénticos, uno para cada respuesta, y garantizar que la elección quede escrita en el código para siempre. Un ámbito construido con coroutineScope implementa la primera respuesta y trata al conjunto como una operación indivisible: si una pieza falla, la operación entera ha fallado y no tiene sentido gastar más. Un ámbito construido con supervisorScope implementa la segunda y trata a las hijas como intentos independientes bajo un mismo techo. Aprender a distinguirlas es lo que separa un sistema que se derrumba entero por un error periférico de otro que degrada con elegancia.

🎯 Al terminar esta lección sabrás
  • Construir ámbitos locales con coroutineScope y describir las dos garantías que ofrece al retornar.
  • Contrastar la propagación del fallo en un ámbito corriente y bajo un supervisor, en ambos sentidos del árbol.
  • Instalar un CoroutineExceptionHandler en el único lugar donde efectivamente se ejecuta.
  • Elegir entre ambos ámbitos a partir de si las tareas hermanas comparten destino.

El ámbito local como unidad de composición

La función coroutineScope crea un ámbito hijo del contexto actual, ejecuta su bloque y no retorna hasta que ese bloque y todas las corrutinas lanzadas en él han terminado. Es el mecanismo que convierte una función suspend corriente en una operación internamente concurrente sin que sus llamadores tengan que enterarse: desde fuera se sigue viendo una llamada que empieza, tarda y acaba.

suspend fun informe(id: Id): Informe = coroutineScope {
    val ventas = async { repo.ventas(id) }
    val costes = async { repo.costes(id) }
    Informe(ventas.await(), costes.await())
}

Conviene enunciar sus dos garantías por separado porque se usan en momentos distintos. La primera es de terminación: al salir del bloque, ninguna corrutina creada dentro sigue viva, así que se pueden liberar recursos, cerrar transacciones y medir tiempos con la certeza de que no hay trabajo pendiente. La segunda es de cancelación: si quien llamó cancela la operación, la cancelación baja al ámbito y de ahí a todas sus hijas, sin que haya que propagarla a mano.

💡
Prefiere el ámbito local a guardar un ámbito en una propiedad

Un ámbito guardado como propiedad de una clase solo se justifica cuando el trabajo debe sobrevivir a la llamada que lo inicia, típicamente porque pertenece al ciclo de vida de un componente. En cualquier otro caso, coroutineScope dentro de una función suspend es superior: no hay que acordarse de cancelarlo, hereda el despachador y la cancelación de quien llama, y hace que la firma diga la verdad sobre cuánto dura la operación.

La diferencia está en el sentido del fallo

Ambos constructores propagan la cancelación hacia abajo de forma idéntica: cancelar el ámbito cancela a todas las hijas, con supervisor o sin él. La diferencia está exclusivamente en el sentido contrario. En un coroutineScope, el fallo de una hija sube al ámbito, que lo asume, cancela a las hermanas y relanza la excepción en el punto de la llamada. En un supervisorScope, el fallo de una hija se queda en esa hija: las hermanas no se enteran y el bloque sigue su curso.

flowchart TD
A[Hija falla] --> B{Tipo de ambito}
B -- coroutineScope --> C[El ambito asume el fallo]
C --> D[Cancela a las hermanas]
D --> E[Relanza en el punto de llamada]
B -- supervisorScope --> F[El ambito ignora el fallo]
F --> G[Las hermanas continuan]
G --> H[El error se atiende en la propia hija]
// Todo o nada: sin costes no hay informe
suspend fun informe(id: Id) = coroutineScope {
    val ventas = async { repo.ventas(id) }
    val costes = async { repo.costes(id) }
    Informe(ventas.await(), costes.await())
}

// Independientes: que fallen las que fallen, las demas valen
suspend fun refrescarTodo(ids: List<Id>) = supervisorScope {
    ids.forEach { id ->
        launch {
            runCatching { repo.refrescar(id) }
                .onFailure { registro.aviso(id, it) }
        }
    }
}

Hay tres precisiones sobre el supervisor que evitan la mayoría de los malentendidos. La primera es que el supervisor no atrapa el error: si la hija no lo trata, se pierde salvo que llegue a un manejador, y por eso bajo supervisor el tratamiento del error debe escribirse dentro de cada hija. La segunda es que la asimetría solo aplica a la relación directa con el ámbito supervisor; dentro de una hija que a su vez lanza nietas, el comportamiento vuelve a ser el corriente y un nieto que falle sí cancelará a sus hermanos. Y la tercera es que si el fallo ocurre en el propio bloque del supervisorScope, y no en una hija, el ámbito falla igual y cancela a todas, porque ese código es el ámbito mismo y no un subordinado.

Hay además un patrón intermedio que resuelve muchos casos sin necesidad de supervisor y que conviene considerar antes de recurrir a él: mantener el ámbito corriente y hacer que cada tarea capture su propio fallo devolviendo un resultado que lo represente. Con eso el árbol sigue siendo estrictamente estructurado, ninguna excepción escapa y la decisión sobre qué hacer con los fallos parciales se toma en un único sitio, con todos los resultados a la vista.

suspend fun consultarTodos(ids: List<Id>): List<Result<Dato>> = coroutineScope {
    ids.map { id ->
        async { runCatching { repo.consultar(id) } }
    }.awaitAll()
}

La diferencia entre esta versión y el supervisor no es de comportamiento observable sino de dónde vive la política. Aquí el fallo deja de ser una excepción y pasa a ser un dato, de modo que el sistema de tipos obliga a quien reciba la lista a decidir qué hace con los elementos fallidos; con un supervisor, en cambio, el fallo sigue siendo una excepción atendida dentro de cada hija y quien llama no se entera de que hubo problemas. La primera forma es preferible cuando el llamador tiene algo que decidir; la segunda, cuando las tareas son efectos y no hay nada que devolver.

Dónde se atiende el error de verdad

El elemento de contexto CoroutineExceptionHandler es el último recurso del sistema: se invoca cuando una excepción llega al final del árbol sin que nadie la haya tratado. Su regla de activación es estricta y no coincide con lo que la mayoría supone al colocarlo, de ahí que sea frecuente encontrarlo instalado en sitios donde no se ejecutará jamás.

Se ejecuta únicamente en la corrutina raíz de un ámbito, es decir, en una creada directamente sobre un CoroutineScope con launch, y solo cuando el fallo no tiene ya otro destino. No se ejecuta en una corrutina hija de un coroutineScope, porque su fallo sube al padre y termina relanzándose en el punto de la llamada, que es donde corresponde tratarlo con un try. Tampoco se ejecuta para un async, porque su excepción pertenece al valor y se entrega en el await. Y en un supervisorScope, una hija lanzada con launch sí cuenta como raíz a estos efectos, porque su fallo no sube a ninguna parte.

val manejador = CoroutineExceptionHandler { _, e ->
    registro.error("fallo no tratado", e)
}

val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + manejador)

scope.launch { puedeFallar() }        // el manejador si actua aqui

suspend fun operacion() = coroutineScope {
    launch(manejador) { puedeFallar() }   // inutil: el fallo sube al padre
}
⚠️
Instalarlo en una hija no sirve de nada

Pasar un manejador a un launch que es hijo de un coroutineScope no cambia el comportamiento: la excepción sube igualmente, cancela a las hermanas y se relanza donde se esperaba el ámbito. El manejador se ignora porque no es raíz. La consecuencia práctica es que el error, en ese caso, se trata con un try alrededor de la llamada a la función suspend, y el manejador se reserva para el ámbito de larga vida del componente, donde sí es la última red.

Un criterio de elección

La pregunta que decide es si las hermanas comparten destino, y tiene traducción directa desde el dominio. Si el resultado de la operación es una composición de todas las partes, de modo que la ausencia de una la invalida, el destino es común y corresponde coroutineScope; ahorrar el trabajo de las hermanas cuando ya se sabe que el conjunto ha fracasado no es solo una optimización, es la única semántica coherente. Si cada tarea produce un efecto o un valor que se entrega por separado, y el usuario del sistema aceptaría con naturalidad que tres de cinco funcionaran, entonces el destino es individual y corresponde supervisorScope.

🌳

`coroutineScope`

Todo o nada. El fallo de una hija cancela a las hermanas y se relanza en el punto de la llamada. Es la elección por defecto dentro de una operación.

🛡️

`supervisorScope`

Fallos aislados. Cada hija responde de lo suyo y las hermanas continúan. Exige tratar el error dentro de cada una.

🏗️

`SupervisorJob` en un ámbito propio

La misma contención, pero en un ámbito de larga vida guardado como propiedad. Es el patrón de pantallas, sesiones y servidores.

Conviene distinguir también entre el constructor y el elemento de contexto, porque se confunden y no son intercambiables. La función supervisorScope crea un ámbito local que dura lo que dure su bloque y espera a sus hijas antes de retornar, igual que su hermano corriente; es la herramienta para una operación concreta. El elemento SupervisorJob se usa al construir un CoroutineScope que se guarda como propiedad y vive mientras viva el componente, sin esperar a nadie porque nunca retorna. Usar el segundo dentro de una función suspend para simular el primero es un error frecuente y sus consecuencias son las ya conocidas: se pierde la espera y las tareas quedan huérfanas.

// Correcto para una operacion: ambito local que espera
suspend fun sincronizar(ids: List<Id>) = supervisorScope {
    ids.forEach { id -> launch { intentar(id) } }
}

// Correcto para un componente: ambito de larga vida que se cancela a mano
class Sesion {
    private val ambito = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    fun cerrar() = ambito.cancel()
}

Hay dos señales fiables de que la elección se ha hecho mal. Si al usar un supervisor aparecen bloques try vacíos o registros que nadie lee, es probable que las tareas sí compartieran destino y que se esté ocultando un fallo real en lugar de tolerarlo. Y si con un ámbito corriente una tarea marginal, como enviar una métrica, derriba una operación entera de negocio, la respuesta correcta no es cambiar el ámbito sino sacar esa tarea de la operación y darle un ámbito propio, porque nunca perteneció al mismo destino.

La política de fallo es una decisión de dominio y el mérito del diseño es obligar a escribirla

Conviene cerrar el nivel señalando qué es lo que realmente se ha aprendido, porque no son dos funciones sino una forma de pensar los sistemas que sobrevive al lenguaje que se use para escribirlos. Toda operación compuesta por partes concurrentes lleva dentro una política implícita sobre qué significa que una parte fracase, y esa política existe se escriba o no: en un sistema que no la haya decidido, la respuesta la dan el orden accidental de las llamadas, la posición fortuita de un bloque de captura o el hilo en que casualmente estalló el error, con el resultado de que la misma operación se comporta de manera distinta en circunstancias que nadie modeló. Las dos formas de árbol que ofrece Kotlin no son características de una biblioteca de concurrencia sino los dos únicos comportamientos coherentes que puede tener un conjunto de tareas ante el fracaso de una de ellas, y son exactamente los dos que las arquitecturas tolerantes a fallos llevan describiendo desde hace décadas bajo nombres como árbol de supervisión o estrategia uno para todos frente a uno para uno. Lo específico y valioso del diseño de Kotlin es que no deja la política en la documentación ni en la disciplina del equipo: la convierte en una palabra que aparece en el código, en el punto exacto donde nace la concurrencia, de modo que quien revisa el archivo dentro de tres años puede leer qué se decidió y discutirlo con conocimiento. Esa es la conquista real de la concurrencia estructurada, y explica por qué el nivel empezó con un argumento sobre el salto incondicional: en ambos casos lo que se gana no es potencia, porque todo lo que se puede escribir con estructura se podía escribir sin ella, sino la capacidad de mirar un fragmento de programa y saber qué hace, incluido lo que hace cuando algo va mal. La pregunta con la que conviene quedarse es la que hay que responder antes de escribir cualquier bloque concurrente, y no la responde el compilador: si esto que estoy lanzando fracasa, quién más debería enterarse y quién debería seguir como si nada.

⚔️ Decide la política y escríbela
  1. Coge una operación tuya con varias llamadas paralelas y decide si sus partes comparten destino. Escribe la justificación en una frase y elige el ámbito acorde.
  2. Implementa la misma función con coroutineScope y con supervisorScope, haz fallar una hija y compara qué queda ejecutándose y qué se relanza.
  3. Instala un CoroutineExceptionHandler en una hija de un coroutineScope, comprueba que no se ejecuta y muévelo al lugar donde sí actúa.
  4. Provoca un fallo en el propio bloque de un supervisorScope, no en una hija, y explica por qué esta vez todas quedan canceladas.
  5. Busca una tarea marginal que pueda derribar una operación de negocio y decide si corresponde aislarla con un supervisor o sacarla a un ámbito propio. Argumenta la diferencia.