La idea: ninguna corrutina es huérfana
La concurrencia estructurada es la aplicación a la concurrencia del mismo argumento que en su día desterró el salto incondicional del flujo secuencial. Esta lección explica por qué en Kotlin toda corrutina nace obligatoriamente con un padre, qué dos garantías se obtienen a cambio de esa obligación, cómo un ámbito convierte una lista informal de tareas pendientes en un árbol con dueño, y por qué lanzar trabajo suelto en un ámbito global no es una simplificación sino la renuncia a saber cuándo ha terminado y a poder detenerlo.
En 1968 Dijkstra publicó una carta breve para argumentar que el salto incondicional debía desaparecer del código, y su razón no fue estética sino epistemológica: con saltos arbitrarios resulta imposible mirar una porción de programa y saber qué se ha ejecutado ya y qué queda por ejecutar. La programación estructurada ganó porque impuso que todo bloque tuviera una entrada y una salida, y esa disciplina permitió razonar localmente sobre un texto que antes solo se podía razonar entero. La concurrencia sin estructura repite exactamente el error original: cuando una función arranca una tarea en paralelo y devuelve el control sin esperarla, el punto de retorno deja de significar lo que dice, porque hay trabajo del que esa función es moralmente responsable ejecutándose fuera de su alcance, sin que nadie sepa cuándo acabará ni pueda detenerlo. La concurrencia estructurada es la restauración de la propiedad perdida, y en Kotlin no es una recomendación sino una regla del sistema de tipos.
- Explicar en qué sentido lanzar una tarea sin esperarla es el equivalente concurrente del salto incondicional.
- Enunciar con precisión las dos garantías que aporta la relación padre e hijo entre corrutinas.
- Reconocer un
CoroutineScopecomo el objeto que materializa la responsabilidad sobre un árbol de tareas. - Justificar por qué la ausencia de padre convierte cualquier fallo o cancelación en un problema silencioso.
Una tarea suelta es una fuga que todavía nadie ha notado
Considérese una función que sube unos datos en segundo plano y devuelve el control de inmediato. El compilador está satisfecho, la prueba pasa y el resultado aparece en el registro unos milisegundos más tarde. El problema no está en lo que ocurre sino en lo que no se puede afirmar sobre ello.
// Sin estructura: la funcion retorna, el trabajo sigue vivo en otra parte
fun procesar(datos: List<Dato>) {
GlobalScope.launch { subir(datos) }
// aqui ya se devolvio el control; nadie sabe si subir termino
}
La palabra clave aquí es responsabilidad. Nadie discute que el trabajo deba ejecutarse fuera del hilo actual; lo que se discute es que ejecutarse fuera implique dejar de pertenecer a alguien. Esas dos cosas se han confundido durante décadas porque las herramientas disponibles las presentaban unidas, pero son independientes, y separarlas es justamente lo que hace la concurrencia estructurada.
Cuando esta función retorna, el lector no puede decir si la subida ha ocurrido, si está a mitad, si ha fallado o si fallará dentro de treinta segundos. Tampoco puede cancelarla si el usuario abandona la pantalla, ni sabe a quién notificar si lanza una excepción, ni puede escribir una prueba que espere el final sin dormir un tiempo arbitrario. La corrutina existe pero nadie la posee: no hay ningún objeto en el programa cuya destrucción implique su fin. Eso es una fuga en el sentido literal, porque el trabajo sobrevive al contexto que le dio sentido y sigue consumiendo memoria, conexiones y batería para producir un resultado que ya no le importa a nadie.
// Con estructura: la funcion no retorna hasta que todo lo suyo ha terminado
suspend fun procesar(datos: List<Dato>) = coroutineScope {
launch { subir(datos) }
launch { indexar(datos) }
}
La segunda versión parece casi idéntica y sin embargo cambia la naturaleza de la función. Ahora el punto de retorno vuelve a significar lo que siempre significó en código secuencial: cuando el control pasa a la línea siguiente, todo lo que esta función puso en marcha ha concluido, con éxito o con fallo, pero ha concluido. La firma dice suspend precisamente porque esperar es parte del contrato.
Merece la pena detenerse en las consecuencias prácticas de esa diferencia, porque no son teóricas y aparecen a las pocas semanas de vida de cualquier proyecto. Una prueba sobre la versión sin estructura no tiene forma de saber cuándo comprobar el resultado, así que su autor acaba escribiendo una espera por tiempo, y esa espera es a la vez demasiado corta en la máquina de integración continua y demasiado larga en la suya. Un cronómetro alrededor de la llamada mide el coste de encolar y no el del trabajo, de modo que las métricas de latencia mienten sistemáticamente a la baja. Un bloque de captura alrededor de la llamada no ve el error, porque el error ocurrirá después de que el bloque haya terminado, y por eso el fallo aparece en el registro sin ninguna traza que lo conecte con la petición que lo originó. Ninguno de esos tres problemas se arregla con más cuidado: los tres son la misma pérdida de información expresada de tres maneras.
Usar GlobalScope no simplifica nada: elimina la única pieza que hacía manejable el problema. Una corrutina lanzada ahí vive hasta que termina por sí sola, no se cancela cuando se destruye la pantalla, la petición o el trabajo que la originó, y si falla lo hace en un lugar donde no hay ningún interesado escuchando. Su uso legítimo se reduce a procesos que deben durar tanto como la aplicación entera, y en la práctica casi siempre es una firma que se puede reescribir como suspend o como una propiedad de ámbito con ciclo de vida propio.
El padre como contrato: nada se filtra, todo se espera
La regla estructural de Kotlin es simple de enunciar y tiene consecuencias profundas: toda corrutina se crea dentro de un CoroutineScope y queda registrada como hija del Job que ese ámbito lleva en su contexto. No hay forma de construir una corrutina sin ámbito, porque launch y async son extensiones de CoroutineScope y sin receptor no se pueden invocar. La jerarquía no es un patrón que uno decida seguir, es la única forma disponible de escribir el código.
flowchart TD A[Ambito padre con su Job] --> B[Hija uno] A --> C[Hija dos] C --> D[Nieta] B --> E[Termina] D --> F[Termina] A --> G[Solo entonces el padre se completa]
La imposibilidad de saltarse la regla es tan importante como la regla misma, y conviene apreciarla en su forma técnica. Las funciones launch y async están declaradas como extensiones de CoroutineScope, de modo que sin un receptor de ese tipo la llamada ni siquiera compila. Dentro de una función suspend no hay ningún ámbito disponible por arte de magia: hay que pedirlo, y la forma habitual de pedirlo es un constructor que espera a sus hijas antes de devolver el control. El resultado es que la versión correcta del código es la que sale sola y la versión huérfana exige nombrar de forma explícita un ámbito que no tiene dueño, lo cual la vuelve visible en cualquier revisión.
De esa relación se derivan dos garantías que conviene enunciar por separado porque se confunden con facilidad. La primera es la espera: un padre no se considera completado mientras le quede un solo descendiente activo, de modo que la terminación del árbol entero se observa en un único punto. La segunda es la propagación: si un hijo falla, el padre cancela al resto de los hermanos y se cancela a sí mismo, y si se cancela el padre, la cancelación baja hasta el último nieto. Juntas producen la propiedad que da nombre al enfoque: el árbol de corrutinas termina todo de golpe o no termina nada a escondidas.
Merece la pena señalar que estas dos garantías responden a preocupaciones distintas. La espera resuelve la corrección del flujo: sin ella no se puede componer, porque una función que no espera su propio trabajo no puede usarse como pieza de otra mayor. La propagación resuelve la economía del esfuerzo: si una de cinco peticiones paralelas ha fallado y el resultado combinado ya es imposible, seguir gastando red y tiempo en las otras cuatro es puro desperdicio.
suspend fun panel(id: Id): Panel = coroutineScope {
val a = async { api.resumen(id) }
val b = async { api.alertas(id) }
val c = async { api.historial(id) }
Panel(a.await(), b.await(), c.await())
}
// Si api.alertas lanza, las otras dos se cancelan y la excepcion
// aparece en el punto de llamada a panel, no en un hilo cualquiera
Obsérvese dónde queda el error en ese ejemplo: exactamente donde un programador secuencial lo esperaría, es decir, en la línea que invocó a panel. Esa coincidencia entre el lugar del fallo y el lugar donde se le puede dar sentido es lo que permite que un bloque de captura escrito por quien conoce el contexto de negocio siga siendo útil en presencia de concurrencia. Sin jerarquía, el error emerge en el hilo donde le tocó ejecutarse, que es un dato irrelevante para cualquiera que intente entenderlo.
El ámbito es el lugar donde vive la responsabilidad
Un CoroutineScope es, en su implementación, poco más que un envoltorio alrededor de un CoroutineContext que contiene un Job. Su valor no está en el código que aporta sino en la pregunta que obliga a responder antes de lanzar nada: quién es el dueño de este trabajo y en qué momento deja de importar. Esa pregunta tiene siempre una respuesta concreta en el dominio, y el ámbito es su traducción a código. Conviene subrayar que un ámbito no es un ejecutor ni un contenedor de hilos: el despachador que decide dónde se ejecuta el trabajo es otro elemento del mismo contexto y puede cambiarse sin alterar la jerarquía, porque pertenencia y ubicación son dimensiones independientes.
Ámbito local con `coroutineScope`
Se crea dentro de una función suspend, vive lo que dure la llamada y garantiza que ninguna hija sobreviva al retorno. Es la forma por defecto de paralelizar dentro de una operación.
Ámbito de ciclo de vida
Se guarda como propiedad de un componente con nacimiento y muerte conocidos, y se cancela explícitamente cuando ese componente muere. Es el patrón de las pantallas, las sesiones y los servidores.
Ámbito global
No tiene dueño ni muerte, así que ni espera ni cancela. Reservado a procesos que deben durar tanto como el proceso del sistema operativo.
El contraste con el modelo anterior de hilos y ejecutores es instructivo. Un ExecutorService recibe tareas y devuelve una promesa por cada una, pero las tareas que ese ejecutor lanza a su vez no quedan registradas en ninguna parte, así que apagarlo no garantiza que el trabajo derivado se detenga y esperar una promesa no dice nada de las tareas nietas. La estructura jerárquica es exactamente lo que faltaba: no una API más agradable, sino la información que permite responder a la pregunta de si el trabajo terminó.
Tres preguntas antes de lanzar nada
La disciplina se puede reducir a un interrogatorio breve que conviene aplicar en cada punto del código donde nazca una corrutina. La primera pregunta es quién espera este trabajo, y su respuesta debe ser un objeto concreto y localizable en el código, no una intuición. La segunda es quién puede cancelarlo, y su respuesta debe coincidir con el momento en que el resultado deja de importarle a alguien. La tercera es quién atiende su fallo, y su respuesta debe ser un lugar donde exista suficiente contexto para decidir qué hacer con el error, no un registro genérico al final del sistema.
class Pantalla {
// Ambito con dueno explicito: nace y muere con la pantalla
private val ambito = CoroutineScope(SupervisorJob() + Dispatchers.Main)
fun alAbrir(id: Id) {
ambito.launch { mostrar(cargar(id)) }
}
fun alCerrar() {
ambito.cancel() // aqui muere todo lo que la pantalla puso en marcha
}
}
// La operacion interna no necesita ambito propio: hereda el de quien la llama
private suspend fun cargar(id: Id): Vista = coroutineScope {
val datos = async { repo.datos(id) }
val extra = async { repo.extra(id) }
Vista(datos.await(), extra.await())
}
Nótese la división de responsabilidades de ese fragmento, porque es el patrón que se repetirá en todo lo que queda de nivel. El componente con ciclo de vida posee un ámbito y se encarga de matarlo; las funciones de negocio no poseen nada y se limitan a heredar el ámbito de quien las invoca, creando ámbitos locales cuando necesitan concurrencia interna. Una función suspend que construya su propio CoroutineScope de larga vida está usurpando una decisión que corresponde a su llamador, y ese es probablemente el antipatrón más extendido en bases de código que adoptaron corrutinas sin adoptar su modelo.
Un ámbito sano tiene exactamente un punto en el código donde se le invoca la cancelación, y ese punto está en el mismo objeto que lo creó. Si un ámbito no se cancela en ninguna parte, es un ámbito global disfrazado. Si se cancela desde varios sitios distintos, su propiedad está repartida y nadie sabe realmente cuándo termina el trabajo. Buscar las llamadas a cancelar es la auditoría más rápida que se puede hacer sobre la estructura concurrente de un proyecto.
Conviene mirar esta idea desde arriba porque es, con diferencia, la de mayor alcance de todo el modelo de corrutinas, y porque su valor real no se percibe leyendo la API sino entendiendo qué propiedad matemática se recupera. Un lenguaje es componible cuando el significado de una construcción se puede describir mirando únicamente su interfaz, sin abrir su implementación; y toda la programación secuencial descansa sobre una versión muy fuerte de esa propiedad, a saber, que cuando una llamada devuelve, todo lo que esa llamada implicaba ya sucedió. Sobre ese cimiento se levantan cosas que damos por evidentes y que en realidad son consecuencias suyas: que un bloque try pueda capturar los fallos del código que encierra, que un finally pueda liberar un recurso sabiendo que nadie lo sigue usando, que un cronómetro alrededor de una llamada mida lo que esa llamada costó, y que una prueba pueda afirmar algo al terminar la función bajo prueba. En el momento en que se permite que una función arranque trabajo y retorne sin esperarlo, las cuatro cosas se rompen a la vez y de forma silenciosa: el try no captura porque el fallo ocurre después, el finally cierra un recurso que otra corrutina sigue leyendo, el cronómetro mide el tiempo de encolar y la prueba pasa antes de que exista el bug. Lo verdaderamente notable del diseño de Kotlin es que no combate ese desastre con documentación ni con convenciones de equipo, sino desplazando la restricción al sistema de tipos: como launch es una extensión de CoroutineScope y como la única manera limpia de obtener un ámbito dentro de una función es un constructor que espera a sus hijas, la forma incorrecta requiere más esfuerzo que la correcta y además se ve a simple vista en la revisión del código. Esa es la razón por la que conviene interiorizar la regla antes que la sintaxis: cada vez que se lance una corrutina sin poder señalar en el código quién la espera y quién puede cancelarla, se está reintroduciendo el salto incondicional en un lenguaje que llevaba cincuenta años sin necesitarlo.
- Busca en tu código todas las llamadas a
GlobalScopey, para cada una, escribe una frase que responda quién debería esperar ese trabajo y quién debería poder cancelarlo. - Coge una función que lance una tarea y retorne de inmediato, y reescríbela como
suspendconcoroutineScope. Anota qué cambió en sus llamadores. - Escribe una prueba que falle con la versión sin estructura y pase con la estructurada, sin usar esperas por tiempo.
- Dibuja el árbol de corrutinas de la operación más compleja de tu proyecto y marca en qué nodo se decide la cancelación.
- Argumenta con un ejemplo por qué un bloque
finallypuede cerrar un recurso todavía en uso cuando el trabajo no se espera.