El árbol de tareas: concurrencia que no deja huérfanos
Qué significa exactamente que la concurrencia sea estructurada: la vida de cada tarea hija acotada al ámbito léxico del padre, la herencia de prioridad, contexto y cancelación, y por qué esa forma de árbol convierte las fugas de trabajo en errores imposibles de escribir.
En 1968 Dijkstra sostuvo que el goto era dañino no por ineficiente sino porque destruía la correspondencia entre el texto del programa y su ejecución: leyendo el código ya no se sabía cómo se había llegado a una línea. Medio siglo después, la concurrencia sin estructura reproduce exactamente ese daño en el eje del tiempo. Lanzar un hilo, disparar un callback o crear una tarea que sobrevive a la función que la creó es un salto sin retorno: el trabajo sigue vivo en un lugar del que el lector del código no tiene constancia, nadie sabe quién lo espera, quién recoge su error y quién lo detiene. La concurrencia estructurada es la respuesta análoga a la que dieron el bloque y la llamada a función: si el trabajo concurrente nace y muere dentro de un ámbito léxico, el texto vuelve a decir la verdad sobre la ejecución.
- Enunciar la regla de ámbito de la concurrencia estructurada y sus consecuencias sobre la vida de cada tarea.
- Distinguir tarea padre, tarea hija y tarea sin estructura dentro del árbol que forma un programa.
- Explicar qué se hereda por las ramas: prioridad, contexto de aislamiento, valores locales de tarea y cancelación.
- Justificar qué clase de errores desaparecen por construcción y cuáles siguen siendo responsabilidad del programador.
Un salto sin retorno en el eje del tiempo
Toda API de concurrencia responde, explícita o implícitamente, a tres preguntas: quién espera el resultado, quién recibe el error y quién puede detener el trabajo. Las primitivas clásicas responden con silencio a las tres.
// modelo sin estructura: el trabajo escapa de la funcion que lo crea
func cargarPerfil() {
DispatchQueue.global().async {
let datos = descargarSincrono() // nadie espera esto
DispatchQueue.main.async { pintar(datos) }
}
} // la funcion ya termino
Cuando cargarPerfil retorna, el trabajo continúa. Su error, si lo hay, no tiene a dónde volver; su duración no está acotada por nada; y si el usuario cierra la pantalla, no existe ningún asidero para cancelarlo. Peor aún: el tipo de retorno de la función miente. Dice Void inmediato cuando en realidad ha dejado una obligación pendiente en el sistema.
La versión estructurada invierte la relación. El trabajo concurrente no escapa: queda contenido.
func cargarPerfil() async throws -> Perfil {
async let avatar = descargarAvatar() // hija
async let datos = descargarDatos() // hija
return try await Perfil(avatar: avatar, datos: datos)
} // aqui ya no vive ninguna hija
Las dos descargas ocurren a la vez, pero ninguna sobrevive al cierre de llave. Si descargarDatos lanza, la otra hija se cancela y el error sube por la llamada como cualquier otro. La firma vuelve a ser verdad: cuando la función retorna, ha terminado todo lo que puso en marcha.
Concurrencia estructurada no significa que las tareas se ejecuten en orden, sino que el conjunto de tareas vivas en cualquier instante forma un árbol cuya topología se puede leer en el código fuente. El paralelismo real es libre; lo que se estructura es la propiedad, la duración y la ruta de los errores.
La regla del ámbito y sus tres corolarios
La regla es una sola frase: una tarea padre no puede terminar mientras alguna hija siga viva. De ella salen tres corolarios que conviene tratar como leyes.
Primero, no hay trabajo huérfano. Si una hija se ejecuta, existe un punto del programa —el cierre del ámbito— donde se garantiza que ya ha acabado. Segundo, los errores tienen destino: el fallo de una hija se propaga al padre, y este cancela automáticamente al resto de sus hijas antes de re-lanzar. Tercero, la cancelación viaja hacia abajo: cancelar una rama cancela todo el subárbol, sin que nadie tenga que registrar oyentes ni mantener listas de trabajos activos.
Hay una consecuencia menos evidente y muy importante para razonar sobre rendimiento: la espera del padre no bloquea ningún hilo. El padre se suspende y su hilo vuelve al conjunto disponible; la relación padre-hija es contable, no física. Un árbol de mil nodos no consume mil hilos, sino mil registros de continuación en el montículo, cada uno del tamaño de las variables que sobreviven a una suspensión.
El árbol tampoco es plano ni se limita a la función que lo escribe. Cualquier función asíncrona que internamente cree hijas se convierte en un nodo interior, y quien la llama hereda sus garantías sin saber nada de su implementación.
func sincronizar() async throws {
async let cuentas = sincronizarCuentas() // esta a su vez crea hijas
async let ficheros = sincronizarFicheros() // y esta tambien
_ = try await (cuentas, ficheros)
}
Esa composicionalidad es la propiedad que hace utilizable el modelo: las garantías no se degradan al apilar capas. Una función asíncrona bien escrita se comporta, desde fuera, exactamente como una función síncrona respecto de la duración —cuando retorna, ha terminado— y respecto de los errores —lo que falló dentro sale por su firma—, sin importar cuánta concurrencia haya ocurrido dentro.
flowchart TB R[Tarea raiz de la peticion] --> A[Hija: descargar avatar] R --> B[Hija: descargar datos] B --> B1[Nieta: validar esquema] B --> B2[Nieta: cachear en disco] A -.->|error| R R -.->|cancela el resto del subarbol| B B -.->|propaga hacia abajo| B1 B -.->|propaga hacia abajo| B2 style R fill:#89b4fa,color:#11111b style A fill:#f38ba8,color:#11111b
Las tres formas de crear hijas son async let, los grupos de tareas y, en la práctica, cualquier función que use internamente una de las dos. Todo lo demás —Task y Task.detached— crea raíces nuevas, no hijas, y por eso queda fuera del árbol y de sus garantías.
Lo que baja por las ramas
Una tarea hija no solo hereda la obligación de terminar antes que su padre. Hereda cuatro cosas concretas, y saber cuáles son explica la mayor parte del comportamiento sorprendente.
@MainActor
func refrescar() async {
async let a = calcular() // hereda prioridad, contexto y locales
_ = await a
}
Prioridad. La hija nace con la prioridad del padre, y sube si alguien de mayor prioridad espera el resultado; ese mecanismo, la elevación de prioridad, evita la inversión clásica sin que el programador intervenga. Contexto de aislamiento. Si el padre está aislado a un actor, la clausura hija se comprueba respecto de ese actor, aunque su cuerpo se ejecute en el conjunto cooperativo de hilos. Valores locales de tarea. Lo declarado con @TaskLocal es visible en todo el subárbol, lo que permite propagar identificadores de traza o de petición sin pasarlos por cada firma. Estado de cancelación. Una hija creada dentro de un padre ya cancelado nace cancelada.
Los valores locales de tarea son la demostración más clara de que el árbol existe de verdad en ejecución y no solo en el texto.
enum Contexto {
@TaskLocal static var peticion: UUID?
}
await Contexto.$peticion.withValue(id) {
await procesar() // toda hija de procesar ve el mismo id
}
La visibilidad sigue las ramas, no el fichero: cualquier función llamada dentro del ámbito, y cualquier hija creada por ella a la profundidad que sea, lee el valor sin recibirlo como parámetro. Lo que queda fuera del árbol —una tarea desligada, un callback de una biblioteca— no lo ve, y ese contraste es un diagnóstico gratuito: si un registro pierde el identificador de petición, es que en algún punto el trabajo salió del árbol.
Que exista un árbol no impone ningún orden entre hermanas. Dos hijas del mismo padre pueden ejecutarse a la vez, en cualquier orden y en cualquier hilo del conjunto cooperativo; lo único garantizado es que ambas habrán terminado antes de que el padre continúe más allá del punto de reunión.
El ámbito es el dueño
La llave de cierre es la línea donde el compilador garantiza que todo el trabajo lanzado dentro ha concluido. Léela como una junta de reunión obligatoria.
Suspender no es bloquear
Esperar hijas libera el hilo. Un árbol profundo no consume hilos proporcionalmente a su tamaño, sino continuaciones en el montículo.
La herencia es implícita
Prioridad, aislamiento, locales y cancelación viajan solos. Salir del árbol significa renunciar a las cuatro a la vez.
Lo que el árbol no resuelve
Conviene delimitar la promesa, porque el entusiasmo produce expectativas falsas. La estructura garantiza vida acotada, propagación de errores y cancelación transitiva; no garantiza ausencia de carreras de datos —de eso se ocupan Sendable y los actores—, ni ausencia de interbloqueos lógicos, ni que tu código observe la cancelación cuando llega. Tampoco impide que el trabajo real dure demasiado: acotar la duración es cosa de plazos explícitos, no de la forma del árbol.
Y hay una frontera dura: todo lo que cruza hacia callbacks, temporizadores del sistema, sockets o bibliotecas en C queda fuera del árbol por definición. Ese puente se cose a mano, y su nombre técnico es withTaskCancellationHandler o la clausura de terminación del adaptador correspondiente.
La concurrencia estructurada no es una comodidad sintáctica: es la aplicación, al dominio del tiempo, del mismo descubrimiento que hizo posible programar en grande. Cuando el bloque sustituyó al salto arbitrario, lo que se ganó no fue elegancia sino razonamiento local: la posibilidad de entender un fragmento sin conocer la historia completa del programa, porque el fragmento declara en su propio texto todo lo que empieza y todo lo que termina dentro de él. El goto rompía esa propiedad en el eje del control; el hilo suelto y el callback la rompen en el eje del tiempo, y por eso los sistemas asíncronos clásicos son tan resistentes al análisis: no existe ningún punto del código del que se pueda afirmar «aquí no queda nada pendiente de lo que escribí arriba». La aportación profunda de Swift, siguiendo el trabajo de Sústrik y Kohlhoff sobre nurseries y el de Rust y Kotlin sobre ámbitos de corrutinas, es reconocer que la duración de un trabajo es una forma de propiedad, y que la propiedad se gestiona mejor con la misma herramienta que ya usamos para la memoria: un ámbito con un dueño identificable y un punto de liberación determinista. De ahí salen, como teoremas y no como buenas prácticas, tres resultados que en el modelo anterior había que conquistar a base de disciplina: que ningún trabajo pueda perderse, porque nadie puede salir de su ámbito sin recogerlo; que ningún error pueda desaparecer, porque solo hay un camino de vuelta y es el mismo que el de las funciones ordinarias; y que la cancelación sea transitiva y gratuita, porque el árbol ya codifica quién depende de quién. Interiorizar esto cambia el modo en que se diseña incluso fuera de Swift: ante cualquier trabajo asíncrono que vayas a poner en marcha, la primera pregunta útil ya no es cómo lo hago rápido, sino en qué ámbito vive, quién es su dueño y en qué línea exacta se garantiza que ha muerto. Si no sabes responder a las tres, no tienes concurrencia: tienes trabajo suelto que todavía no te ha fallado.
- Toma una función que use un callback de completado y reescríbela con
async let; señala la línea exacta donde antes se perdía el error y donde ahora se recoge. - Escribe un padre con dos hijas donde la primera lance a mitad de camino y comprueba, con trazas, que la segunda recibe la cancelación antes de que el padre retorne.
- Declara un valor
@TaskLocalcon un identificador de petición y verifica que es visible a tres niveles de profundidad, pero no dentro de unTask.detached. - Mide la prioridad efectiva de una hija creada desde un contexto de baja prioridad cuya espera realiza una tarea de interfaz.
- Dibuja el árbol de tareas de una pantalla real de tu aplicación y marca en rojo cada nodo que no cuelgue de ninguna raíz identificable.