¿Qué es la concurrencia estructurada y cómo se propaga realmente la cancelación a través de un árbol de corrutinas/tareas?
La concurrencia estructurada ata la vida de cada tarea hija al scope del padre, de modo que la cancelación, los errores y la limpieza fluyen de forma predecible en lugar de dejar trabajo huérfano.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Qué es la concurrencia estructurada y cómo se propaga realmente la cancelación a través de un árbol de corrutinas/tareas?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Qué Significa "Estructurada" Acá
La concurrencia estructurada toma prestada la misma idea que la programación estructurada le impuso al control de flujo hace décadas: en lugar de un goto que salta a cualquier lado del programa, el flujo de control queda anidado dentro de bloques con una entrada y una salida claras. La concurrencia estructurada hace lo mismo con las tareas: la vida de cualquier trabajo concurrente que lanzás queda anidada dentro del scope de código que lo lanzó. Un scope no termina de ejecutarse hasta que todas las tareas que lanzó también terminaron — con éxito, con error, o canceladas.
#Las Tres Garantías
En cualquier implementación real de concurrencia estructurada (corrutinas de Kotlin, asyncio.TaskGroup de Python, async/await con manejo de scope), valen tres garantías:
- Un scope no completa hasta que todos sus hijos completan. No hay forma de "salir" de un bloque de concurrencia estructurada dejando trabajo huérfano corriendo atrás.
- Si un hijo falla, sus hermanos se cancelan y la excepción se propaga al padre. No hay una tarea fallida en silencio cuyo error nunca nadie observa.
- Cancelar el scope cancela a todos los descendientes. La cancelación fluye hacia abajo por el árbol completo, no solo al hijo directo.
Compará eso con la concurrencia no estructurada — un Thread crudo al que nunca le hacés join, una goroutine lanzada sin un canal para reportar su resultado, una Promise a la que nunca le agregás un .catch. En todos esos casos, el trabajo puede seguir corriendo después de que la función que lo lanzó ya retornó, sus errores pueden perderse en silencio, y no hay ningún punto natural donde cancelarlo si ya no hace falta.
#Kotlin: coroutineScope, Árboles de Job y Cancelación
En Kotlin, cada corrutina tiene un Job que forma un árbol padre-hijo. El builder coroutineScope { ... } crea un scope hijo que suspende hasta que todas las corrutinas lanzadas dentro terminan, y si cualquiera de ellas lanza una excepción, cancela a sus hermanas y relanza la excepción hacia quien llamó a coroutineScope.
import kotlinx.coroutines.*suspend fun fetchUser(id: Int): String {delay(100)return "user-" + id}suspend fun fetchOrders(id: Int): List<String> {delay(50)throw IllegalStateException("orders service unavailable")}suspend fun loadDashboard(userId: Int) {try {coroutineScope {val user = async { fetchUser(userId) }val orders = async { fetchOrders(userId) }// Si fetchOrders lanza, coroutineScope cancela la// corrutina hermana `user` y relanza acá.println("Dashboard: " + user.await() + ", " + orders.await())}} catch (e: IllegalStateException) {println("No se pudo cargar el dashboard: " + e.message)}}
fetchOrders falla a los 50ms, antes de que fetchUser termine (100ms). Cuando eso pasa, coroutineScope cancela la corrutina user que todavía está activa, y la excepción de fetchOrders se propaga hacia afuera de coroutineScope hasta el catch del llamador. Nada queda corriendo de fondo después de que la función retorna — esa es la garantía estructurada en acción.
La limpieza sigue funcionando igual que en código secuencial, con try/finally:
suspend fun watchDownload(): Unit = coroutineScope {launch {try {while (isActive) {delay(200)println("...descargando...")}} finally {println("limpieza: cerrando el handle del archivo")}}}
#La Cancelación Es Cooperativa
Cancelar una corrutina no la detiene a la fuerza en medio de una instrucción de CPU — le pide, mediante una CancellationException, que se detenga en el próximo punto de suspensión cooperativo (delay, yield, cualquier llamada suspendible que chequee isActive). Eso significa que un bug muy común es atrapar esa excepción sin darse cuenta:
// MAL: este catch también atrapa la cancelación, así que la// corrutina nunca se detiene de verdad y el scope padre queda// esperando para siempre.launch {try {doWork()} catch (e: Exception) {log.warn("el trabajo falló", e) // ¡CancellationException también es una Exception!}}
La corrección es atrapar CancellationException por separado y relanzarla, o directamente no atrapar Exception en general:
launch {try {doWork()} catch (e: CancellationException) {throw e // dejá que la cancelación siga su curso} catch (e: Exception) {log.warn("el trabajo falló", e)}}
#supervisorScope: Optar Por No Cancelar a los Hermanos
A veces las tareas hijas son genuinamente independientes y la falla de una no debería tumbar a las demás — por ejemplo, cargar tres widgets independientes de un dashboard. supervisorScope (o un Job raíz creado con SupervisorJob()) rompe la propagación de cancelación entre hermanos, aunque las excepciones de cada hijo siguen sin perderse si las manejás explícitamente:
suspend fun loadWidgets(userId: Int) = supervisorScope {val weather = async { fetchWeather() }val news = async { fetchNews() }val stocks = async { fetchStocks() }// Cada widget es independiente: que uno falle no debe cancelar a los demás.listOf(weather, news, stocks).map { deferred ->try {deferred.await()} catch (e: Exception) {"no disponible"}}}
#La Respuesta de Python: asyncio.TaskGroup
Antes de Python 3.11, componer tareas de asyncio de forma estructurada requería disciplina manual con asyncio.gather y bastante cuidado con la cancelación. asyncio.TaskGroup, agregado en 3.11, es el equivalente directo de coroutineScope: es un context manager async que espera a que todas las tareas creadas dentro terminen, y si una falla, cancela a las demás y agrupa las excepciones.
import asyncioasync def fetch_user(user_id: int) -> str:await asyncio.sleep(0.1)return "user-" + str(user_id)async def fetch_orders(user_id: int) -> list[str]:await asyncio.sleep(0.05)raise RuntimeError("orders service unavailable")async def load_dashboard(user_id: int) -> None:try:async with asyncio.TaskGroup() as tg:user_task = tg.create_task(fetch_user(user_id))orders_task = tg.create_task(fetch_orders(user_id))print("dashboard:", user_task.result(), orders_task.result())except* RuntimeError as eg:for exc in eg.exceptions:print("no se pudo cargar el dashboard:", exc)
La sintaxis except* maneja un ExceptionGroup — porque dentro de un TaskGroup, más de una tarea podría fallar al mismo tiempo, y Python necesita un mecanismo para reportar varias excepciones sin descartar ninguna. El comportamiento es el mismo que en Kotlin: cuando fetch_orders falla, fetch_user (si todavía sigue corriendo) se cancela, y no queda ninguna tarea huérfana después del bloque async with.