Swift y Objective-C

[Swift avanzado #4] Concurrencia estructurada: por qué evitar Task

La concurrencia estructurada responde quién garantiza el final de las tareas asíncronas. Resume por qué las tareas hijas no salen del ámbito padre, cómo se propaga la cancelación y qué garantías debes recuperar manualmente al usar Task.detached.

7 min de lectura
Imagen de portada de [Swift avanzado #4] Concurrencia estructurada: por qué evitar Task

Queda una última pregunta para cerrar la serie sobre Concurrency. Hasta ahora vimos las funciones async (parte 1), actor (parte 2) y Sendable (parte 3).

Este contenido continúa el artículo anterior Swift avanzado #3.

Sin embargo, no tratamos correctamente Task, la puerta de entrada a este mundo asíncrono. Y al trabajar con Task aparece el concepto más práctico de toda la serie.

Es la concurrencia estructurada (structured concurrency).

El nombre suena grandilocuente, pero la pregunta es sencilla: después de iniciar una tarea asíncrona, ¿quién responde por su finalización?

Qué significa «estructurada»: las tareas también tienen ámbito

El nombre procede del antiguo término programación estructurada. Tras la época en que goto podía lanzar el flujo de control a cualquier lugar, los bloques if y for empezaron a garantizar que «el control vuelve al lugar del que entró».

La concurrencia estructurada aplica el mismo principio a las tareas asíncronas. Una tarea hija no puede salir del ámbito de su padre, y el padre no termina hasta que terminan todas sus hijas.

El async let que vimos en la parte 1 era, de hecho, el primer ejemplo de este principio.

func loadDashboard() async throws -> Dashboard {
    async let profile = fetchProfile()    // tarea hija 1
    async let feed = fetchFeed()          // tarea hija 2
    return try await Dashboard(profile: profile, feed: feed)
}   // Esta función no puede devolver el control antes de que se completen las dos tareas hijas

Las hijas creadas con async let deben completarse o cancelarse antes de que la función devuelva el control. Lo mismo ocurre ante una excepción.

Si feed lanza un error, profile, que aún está ejecutándose, recibe automáticamente la señal de cancelación. Después de completar la limpieza, la función propaga el error.

Como las tareas están ligadas al ámbito, no puede existir estructuralmente una «tarea iniciada y olvidada».

En la parte sobre cierres, escaping era la etiqueta de advertencia para un «cierre que vive más que la función». La concurrencia estructurada elimina de serie las «tareas que viven más que la función».

Cuando el número de hijas es dinámico, usamos TaskGroup. Un ejemplo típico para obtener una lista de URL en paralelo es el siguiente.

let images = try await withThrowingTaskGroup(of: (URL, Image).self) { group in
    for url in urls {
        group.addTask { (url, try await download(url)) }
    }
    var result: [URL: Image] = [:]
    for try await (url, image) in group {
        result[url] = image
    }
    return result
}

Hay dos puntos importantes. Los resultados llegan en orden de finalización (si necesitas conservar el orden, guárdalos junto con una clave, como arriba), y todos los hijos del grupo se limpian cuando termina el cierre.

Si async let es la sintaxis para «unas pocas hijas», TaskGroup es la sintaxis para «n hijas», con la misma garantía de ámbito.

Cancelación: la señal llega, pero detenerse es responsabilidad tuya

El segundo eje de la concurrencia estructurada es la propagación de la cancelación. Cuando se cancela el padre, la señal baja por el árbol hasta todos sus descendientes.

Cuando abandonas una pantalla, las solicitudes de red iniciadas por ella se cancelan en cadena gracias a esta estructura.

Pero la cancelación de Swift es cooperativa (cooperative). La señal de cancelación no termina una tarea por la fuerza.

Solo activa el indicador isCancelled; comprobarlo y detenerse es responsabilidad de la propia tarea.

func processLargeFile() async throws {
    for chunk in chunks {
        try Task.checkCancellation()   // si se canceló, CancellationErrorse lanza
        await process(chunk)
    }
}

¿Por qué no terminarla por la fuerza? Si una tarea muere mientras escribe un archivo a medias, mantiene un bloqueo o está en mitad de una transacción, el sistema queda en un estado inconsistente.

La lógica de la cancelación cooperativa es que «solo la propia tarea sabe cuál es un buen punto para detenerse».

La implicación práctica es clara: los bucles largos deben incluir checkCancellation.

Las API del sistema, como URLSession, ya respetan la cancelación internamente, así que puedes confiar en ellas.

En cambio, un cálculo largo que ignora la cancelación seguirá ejecutándose aunque lo canceles. No es que la cancelación falle: no se comprobó.

Diagrama de la señal de cancelación que baja del padre a los hijos y de la lista de comprobación
La señal de cancelación baja por el árbol, pero solo se detienen las tareas que la comprueban

Task y Task.detached: el mundo no estructurado y su coste

Si hasta aquí estábamos en el mundo estructurado, Task { }queda fuera de él. Una tarea creada con Task es una tarea no estructurada (unstructured) que no entra en el árbol padre-hijo.

No está ligada a un ámbito, y ni los errores ni la cancelación se propagan automáticamente.

¿Por qué existe entonces? Porque necesitamos un puente para pasar del mundo síncrono al asíncrono.

Como un controlador de pulsación es una función síncrona y no puede usar await, abrimos un contexto asíncrono nuevo con Task { await viewModel.refresh() }. Ese es el lugar legítimo de Task.

El modificador .task de SwiftUI añade a este puente la gestión del ciclo de vida (se cancela automáticamente cuando desaparece la vista), por lo que debe tener prioridad en la UI.

El problema aparece cuando Task se convierte en hábito. Ocurre al abrir otro Task dentro de una función async o al lanzar Tasks fire-and-forget. Debes recuperar manualmente todas las garantías de la estructura: esperar la finalización, propagar errores y encadenar la cancelación.

Hay que guardar la referencia para llamar a cancel directamente y registrar los errores manualmente. Sin ese código de gestión aparecen errores que desaparecen en silencio y tareas zombi.

Como regla: dentro de un contexto async, async let y TaskGroup son la opción predeterminada; Task solo en la frontera síncrona→asíncrona.

Task.detached está un paso más lejos. No hereda la prioridad, el aislamiento del actor ni los valores task-local: es una tarea completamente huérfana (SE-0304).

A veces se usa para «salir del contexto de @MainActor y hacer trabajo pesado», pero casi siempre es una solución equivocada. Basta declarar ese trabajo como una función async nonisolated: se ejecutará automáticamente en el pool cooperativo (parte 1).

Los casos realmente necesarios para detached son escasos: tareas en segundo plano que deban ser deliberadamente independientes del contexto actual. En palabras de la documentación oficial, es el último recurso (last resort).

Resumen de la serie: una sola visión formada por cuatro conceptos

Para cerrar la serie sobre Concurrency, resumamos toda la visión de una vez.

async/await devolvió el flujo asíncrono al campo de visión del compilador (parte 1). actor incorporó protección serial para el estado mutable compartido (parte 2), y Sendable comprueba la seguridad de los valores que cruzan fronteras de aislamiento (parte 3).

La concurrencia estructurada ligó además el ciclo de vida de las tareas al ámbito. Lo que empieza debe terminar, y la cancelación fluye por el árbol (esta parte).

Una frase atraviesa los cuatro conceptos: convertir la disciplina implícita de la concurrencia en estructuras explícitas del lenguaje.

La disciplina de gestionar hilos se transformó en pool cooperativo y suspensión; la de gestionar bloqueos, en actor. El conocimiento transmitido sobre «si se puede pasar este objeto entre hilos» se convirtió en Sendable, y el comentario de revisión «no olvides limpiar la solicitud», en un árbol de tareas.

El principio de seguridad ante todo que vimos en la parte 1 de la serie sobre la filosofía de Swift se ha extendido al área más difícil: la concurrencia. Esa es la historia completa de Swift Concurrency.

Ilustración que contrasta las tareas estructuradas dentro de una cerca con un Task no estructurado flotante
Las tareas fuera de la estructura exigen recuperar manualmente las garantías de finalización, errores y cancelación

Resumen

  • La concurrencia estructurada establece que «las tareas hijas no pueden salir del ámbito del padre». async let (número fijo) y TaskGroup (número dinámico) son su sintaxis, y la cancelación de hermanas y la limpieza tras un error son automáticas.
  • La cancelación es cooperativa. La señal se propaga por el árbol, pero solo se detiene la propia tarea que incluye checkCancellation.
  • Task { } se usa únicamente como puente síncrono→asíncrono. El uso habitual de Task dentro de un contexto async convierte las garantías estructurales en deuda de gestión manual. Task.detached es el último recurso.
  • La idea central de la serie: las disciplinas implícitas de gestionar hilos, bloqueos y ciclos de vida se trasladaron a las estructuras del lenguaje suspension, actor, Sendable y árbol de tareas.

A partir de la próxima parte profundizaremos en el rendimiento. Trataremos por qué final es una palabra clave de rendimiento, dónde se ralentizan las llamadas a protocolos y en qué consisten realmente el despacho estático y el dinámico.

Seguir leyendo

Fuentes y verificación

  • SE-0304: Structured ConcurrencySwift Evolution · Estándar o especificación · Consultado 17 de agosto de 2026Respalda: Estructura padre-hijo de Task y TaskGroup; herencia de cancelación, prioridad, task-local y contexto de actor