Concurrencia estructurada: ninguna corrutina huérfana
Si la suspensión resolvió cómo esperar sin gastar hilos, queda el problema que hundió a la generación anterior de código asíncrono: quién es responsable del trabajo que lanzaste y cuándo termina. Esta lección reconstruye la concurrencia estructurada de Kotlin desde su pieza central, el Job, y muestra cómo cada corrutina se registra como hija del Job de su ámbito formando un árbol donde ningún nodo puede quedar suelto. Explica por qué un padre no completa hasta que completan sus hijos, por qué la cancelación desciende y el fallo asciende, y en qué se diferencian CoroutineScope como propiedad de un objeto con ciclo de vida y coroutineScope como ámbito local que suspende. Cierra situando viewModelScope y el container de Orbit dentro de ese árbol, y explicando por qué GlobalScope es la forma más limpia de romperlo todo.
La suspensión resolvió el problema del coste: esperar dejó de gastar hilos. Pero abrió otro más difícil, el mismo que hizo insoportable la programación con callbacks y con hilos crudos durante décadas. Si lanzar trabajo es tan barato que puedes hacerlo miles de veces, ¿quién responde por ese trabajo? ¿Quién sabe cuándo ha terminado todo? ¿Quién lo detiene cuando la pantalla que lo pidió ya no existe? La respuesta de Kotlin no es una función de limpieza ni una lista de suscripciones que recordar liberar: es una regla estructural sobre cómo se organiza el trabajo concurrente, tan vinculante como lo son las llaves en el flujo secuencial. Se llama concurrencia estructurada, y una vez la ves no puedes dejar de verla.
- Entender el
Jobcomo el nodo del árbol y ver cómo cada corrutina se registra en el de su ámbito. - Formular las dos leyes de la jerarquía: la cancelación desciende y el fallo asciende hasta la raíz.
- Distinguir
CoroutineScopecomo propiedad de un objeto decoroutineScopecomo ámbito local que suspende. - Situar
viewModelScope, elcontainerde Orbit yGlobalScopedentro de la estructura, y saber por qué el último la rompe.
El ámbito no es un permiso, es un padre
Al leer que launch y async necesitan un ámbito, la lectura natural es pensar que se trata de un permiso administrativo: algo que hay que tener a mano para poder lanzar. Es una lectura equivocada y conviene sustituirla cuanto antes. Un CoroutineScope no autoriza: adopta. Cada vez que lanzas una corrutina, esta se registra como hija del Job que vive dentro del contexto del ámbito, y el resultado es una relación de parentesco duradera con obligaciones en las dos direcciones.
Ese Job es el objeto que representa el ciclo de vida de una corrutina: sabe si está activa, si se completó, si fue cancelada, quién es su padre y qué hijos tiene. El árbol que forman todos los Job de tu aplicación es la estructura sobre la que descansa el resto de la lección. La consecuencia inmediata es que un padre no se considera completado cuando su propio cuerpo termina, sino cuando además han completado todos sus hijos. Esperar la descendencia no es algo que tú programes: está en la definición de terminar.
suspend fun cargarPantalla() = coroutineScope {
launch { repo.precalentarCache() } // hijo 1
launch { repo.registrarVisita() } // hijo 2
val datos = repo.perfil() // trabajo del propio padre
// el bloque no retorna aqui: espera tambien a los dos hijos
datos
}
Léelo despacio porque contradice la intuición heredada de otros lenguajes. La última expresión no termina la función: coroutineScope no retorna hasta que los dos launch han completado. No hay que recolectar identificadores de tarea, ni llamar a un método de espera, ni mantener una lista. La espera es una propiedad del ámbito, y por eso quien llama a cargarPantalla obtiene una garantía muy fuerte: cuando la llamada retorna, no queda absolutamente nada en marcha por debajo. El trabajo concurrente recupera la propiedad más valiosa del código secuencial, que es tener un principio y un final visibles en el propio texto.
La expresión concurrencia estructurada es un eco deliberado de la programación estructurada de los años sesenta, aquella discusión que acabó desterrando el salto incondicional. El argumento de entonces fue que un bloque debía tener una entrada y una salida para que se pudiera razonar localmente sobre él. Lanzar una tarea que sobrevive al bloque donde nació es exactamente el mismo pecado en la dimensión del tiempo: un salto fuera del alcance, invisible en el texto. Al obligar a que toda corrutina tenga padre y a que el padre espere, Kotlin restituye la propiedad perdida y las llaves vuelven a acotar de verdad lo que ocurre dentro.
Las dos leyes del árbol
La jerarquía sería una curiosidad si solo sirviera para esperar. Su poder viene de dos reglas de propagación que van en direcciones opuestas y que conviene memorizar juntas, porque explican casi todo el comportamiento que verás en las dos lecciones siguientes.
La primera: la cancelación desciende. Cancelar un Job cancela recursivamente a todos sus hijos, a los hijos de estos y así hasta las hojas. Un solo cancel sobre la raíz apaga un subárbol entero de trabajo, por profundo que sea, sin que nadie haya tenido que llevar un registro de las tareas vivas.
La segunda: el fallo asciende. Si una corrutina termina con una excepción no capturada, no muere sola: cancela a su padre, que a su vez cancela al resto de sus hijos y sigue propagando hacia arriba. La lógica es coherente con la idea de responsabilidad: si una parte del trabajo conjunto fracasó, el conjunto ya no puede dar un resultado válido y mantener vivas a las hermanas solo consume recursos para nada.
flowchart TD R[Job raiz del scope] --> A[Hijo A carga perfil] R --> B[Hijo B carga posts] B --> B1[Nieto B1 descarga avatar] B --> B2[Nieto B2 escribe cache] A -.->|si A falla el error asciende| R R -.->|y la cancelacion desciende a todos| B B -.-> B1 B -.-> B2 style R fill:#cba6f7,color:#11111b style A fill:#f38ba8,color:#11111b
Merece la pena notar que las dos direcciones no son arbitrarias sino complementarias. La cancelación desciende porque es una orden y las órdenes van de quien tiene autoridad a quien la ejecuta: el padre sabe que su resultado ya no hace falta, y por tanto ninguno de sus hijos tiene sentido. El fallo asciende porque es información y la información va de quien la descubre a quien puede actuar: el hijo se encontró con la excepción, pero no sabe qué otras ramas dependían de él, y el único que puede decidirlo es el padre. Cuando dudes sobre el comportamiento de un caso concreto, reconstruye la respuesta desde esta asimetría entre orden e información en lugar de intentar recordar la tabla.
Las dos leyes juntas producen el invariante que da título a la lección: ninguna corrutina queda huérfana. No hay forma de lanzar trabajo que no tenga padre —salvo saltándose el sistema a propósito, y a eso llegamos al final—, y como todo padre espera y propaga, no existe la tarea zombi que sigue consumiendo batería y memoria después de que su pantalla desapareciera. La clase de fuga más común del Android de hace diez años deja de ser un bug que evitar con disciplina y pasa a ser un estado imposible de representar.
Hay un matiz que confunde a mucha gente y merece decirse aparte: async no lanza su excepción en el momento del fallo, sino que la guarda en el Deferred y la relanza cuando llamas a await. Eso es lo que permite tratarla con un try alrededor del await. Pero atención: si el async cuelga de un ámbito normal, el fallo también cancela al padre por la segunda ley, aunque tú captures la excepción en el await. Capturar el error no deshace la cancelación estructural. Cuando de verdad quieras que un hijo pueda fracasar sin arrastrar a nadie, la herramienta correcta es supervisorScope, que es donde termina la última lección del nivel.
Anatomía de un Job
Conviene mirar de cerca el objeto que sostiene toda la estructura, porque manejarlo con soltura desactiva mucha confusión posterior. Un Job no es un identificador opaco: es un valor con estados observables y con operaciones propias. Nace activo cuando la corrutina arranca, pasa a completando cuando su cuerpo terminó pero aún espera a sus hijos, y solo llega a completado cuando el último descendiente ha acabado. La rama alternativa es simétrica: cancelando mientras se propaga la orden y se ejecutan las limpiezas, y cancelado cuando todo el subárbol se apagó.
val trabajo: Job = scope.launch { sincronizar() }
trabajo.isActive // sigue en marcha
trabajo.isCompleted // termino, con exito o no
trabajo.children // secuencia de sus hijos vivos
trabajo.invokeOnCompletion { causa -> registro.fin(causa) }
trabajo.join() // suspende hasta que este subarbol termine del todo
Repara en join, que es la contrapartida explícita de la espera automática del ámbito: suspende a quien llama hasta que ese Job y toda su descendencia han terminado. coroutineScope no hace otra cosa que llamarlo por ti sobre todos sus hijos, y por eso los dos mecanismos dan la misma garantía. invokeOnCompletion, por su parte, permite enganchar una acción al final de la rama sin ocupar un hilo esperando, y recibe la causa: nula si acabó bien, la excepción si falló, la de cancelación si lo apagaron.
Existe también la posibilidad de construir un Job a mano y ponerlo en el contexto de un ámbito. Es lo que ocurre cuando escribes CoroutineScope(Job() + Dispatchers.Default): le estás dando al ámbito una raíz propia que podrás cancelar cuando quieras. Y existe su variante supervisora, SupervisorJob, que desactiva la segunda ley para sus hijos directos, de modo que el fallo de uno no arrastra a los demás. La última lección del nivel la estudia en detalle; por ahora basta con saber que el mismo lugar del contexto admite dos políticas distintas de propagación, y que elegir entre ellas es una decisión de diseño y no un detalle.
Dos formas de tener un ámbito
Kotlin ofrece dos maneras de introducir un ámbito, y confundirlas produce errores sutiles. La diferencia es de ciclo de vida y de firma.
coroutineScope { } es una función suspend que crea un ámbito local y efímero: existe mientras dura el bloque, hereda el contexto de quien lo llama, suspende hasta que todos sus hijos terminan y luego devuelve el valor de la última expresión. Es la herramienta para decir aquí necesito hacer varias cosas a la vez y no seguir hasta que todas acaben. No se guarda en una propiedad ni sobrevive a la llamada.
La firma delata la diferencia mejor que cualquier explicación: coroutineScope es una función suspend que devuelve un valor, así que solo puedes invocarla desde dentro de otra corrutina y su resultado es el de la operación que agrupó. No es un objeto que se guarde, ni una propiedad, ni algo que sobreviva a la llamada.
CoroutineScope(...) con su constructor, en cambio, crea un ámbito duradero que asocias a un objeto con ciclo de vida propio y que alguien deberá cancelar cuando ese objeto muera. Es la herramienta para decir este componente puede lanzar trabajo mientras exista. En Android casi nunca lo construyes a mano porque la plataforma ya te da los que necesitas.
// Ambito local: agrupa, paraleliza y no sobrevive al bloque
suspend fun resumen(id: String): Resumen = coroutineScope {
val perfil = async { repo.perfil(id) }
val stats = async { repo.estadisticas(id) }
Resumen(perfil.await(), stats.await())
}
// Ambito duradero: pertenece a un objeto y alguien debe cancelarlo
class Sincronizador : Closeable {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun arrancar() { scope.launch { bucleDeSincronizacion() } }
override fun close() { scope.cancel() } // sin esto, la rama nunca muere
}
La línea que cancela en close no es una cortesía: es la contrapartida obligatoria de haber creado una raíz. Un ámbito duradero pone un Job sin padre, y por tanto nadie lo va a cancelar en tu lugar. Quien crea la raíz asume el deber de terminarla, y el sitio correcto para ese deber es el mismo método donde el objeto declara que ha dejado de existir.
coroutineScope
Función suspend, ámbito local, suspende hasta que todos los hijos terminan y devuelve un valor. Para paralelizar dentro de una operación.
viewModelScope
Propiedad que Android da a cada ViewModel, atada a Dispatchers.Main.immediate y cancelada automáticamente en onCleared.
container de Orbit
La fábrica de Orbit monta su ámbito sobre viewModelScope, así que cada intent es un nieto que muere con la pantalla sin que escribas nada.
GlobalScope
Un ámbito sin padre y sin fin que nadie cancela. Rompe el invariante entero y por eso está marcado como API delicada.
Aquí se cierra el círculo con lo que ya sabías de Orbit. La fábrica container de orbit-viewmodel construye su ámbito sobre viewModelScope, cuyo Job se cancela en onCleared. Cada intent que declaras es una corrutina hija de ese ámbito, y cada launch o async que abres dentro del intent es un nieto. Cuando el usuario abandona la pantalla, un solo cancel en la raíz desciende por todo el subárbol y detiene las descargas en vuelo, los coleccionadores de Flow que no terminaban nunca y los temporizadores. La frase de la lección anterior sobre que Orbit no inventa cancelación se entiende ahora del todo: lo único que hizo fue elegir bien de qué rama colgar.
Cuando una clase recibe un CoroutineScope ajeno por el constructor para lanzar trabajo dentro, la responsabilidad se difumina: el dueño del ámbito no sabe qué corre en él y el que lanza no sabe cuándo lo van a cancelar. La alternativa idiomática es no lanzar nada: expón funciones suspend y deja que el llamante decida en qué ámbito ejecutarlas y cuánto vive el trabajo. La regla corta es que las capas de datos suspenden y las capas con ciclo de vida lanzan. Así el árbol lo forma quien tiene información para formarlo bien.
Detente en la naturaleza del avance, porque es más profundo que la comodidad. Durante veinte años, la gestión de la asincronía fue una cuestión de disciplina: acuérdate de cancelar la suscripción, acuérdate de comprobar si la actividad sigue viva antes de tocar la vista, acuérdate de que si esta rama falla hay que abortar las otras tres. Y la disciplina, como recurso de ingeniería, tiene una propiedad terrible: se degrada con el tamaño del equipo, con las prisas y con el tiempo. Cada olvido individual es venial y el agregado es una app que consume batería en segundo plano y se cae al girar la pantalla. Lo que hace la concurrencia estructurada no es facilitar esa disciplina, sino eliminar la necesidad de tenerla, y el mecanismo es el mismo que emplean todas las buenas soluciones a esta clase de problema: mover la corrección desde el comportamiento del programador hacia la estructura del programa. Al declarar que toda corrutina tiene un padre y que terminar significa que también terminaron los hijos, la tarea huérfana deja de ser un error que hay que evitar y pasa a ser un estado que no se puede escribir. Repara en el parentesco con lo que ya viste en este mismo camino: la inmutabilidad del estado en MVI no te pide que recuerdes no mutar, hace que mutar no compile; los dos flujos del container no te piden que recuerdes distinguir un dato retenido de un evento efímero, te obligan a elegir un tipo distinto para cada uno. Es siempre el mismo movimiento, y es la marca del diseño maduro: donde antes había una recomendación, ahora hay una imposibilidad. La consecuencia práctica es que puedes leer una función y saber, solo por sus llaves, que cuando retorne no habrá dejado nada corriendo por detrás, exactamente igual que sabes que un bloque secuencial no dejó instrucciones a medias. Esa recuperación del razonamiento local es lo que vale, porque razonar localmente es lo único que escala cuando el sistema crece. Y explica también por qué GlobalScope no es simplemente un atajo desaconsejado sino una perforación en el casco: una corrutina sin padre no la cancela nadie, no la espera nadie y su fallo no informa a nadie. Basta una para que la garantía deje de valer en todo el programa, y ese es el motivo de que la biblioteca la marque como API delicada en vez de limitarse a mencionarla en la documentación.
- Escribe una función con
coroutineScopeque lance tres hijos conlaunchy añade un registro al final del bloque. Comprueba que ese registro se imprime después de los tres hijos y explica por qué. - Haz que uno de los tres hijos lance una excepción. Observa qué ocurre con los otros dos y con la función que llamó, y nombra cuál de las dos leyes actuó en cada paso.
- Dibuja el árbol de
Jobde una de tus pantallas con Orbit: sitúaviewModelScope, el ámbito delcontainer, unintenty losasyncque abre dentro. Marca dónde entra elcancelal salir de la pantalla y qué apaga. - Sustituye un
launchde tu código por uno sobreGlobalScopey provoca la salida de la pantalla mientras el trabajo sigue. Documenta qué sigue vivo y por qué nadie lo detiene. - Busca una clase tuya que reciba un
CoroutineScopepor el constructor y reescríbela para que solo exponga funcionessuspend. Argumenta qué responsabilidad cambia de sitio.