El modelo de concurrencia de Swift experimentó un cambio de paradigma con Swift 5.5, introduciendo la concurrencia estructurada para reemplazar los patrones de Grand Central Dispatch heredados que a menudo conducían a tareas huérfanas y fugas de recursos. Antes de esto, los desarrolladores gestionaban manualmente las instancias de DispatchGroup para rastrear el trabajo concurrente, lo que requería sincronización explícita para prevenir condiciones de carrera durante la cancelación. La abstracción de TaskGroup fue diseñada para encapsular la relación padre-hijo de forma nativa, asegurando que el runtime mantenga los metadatos del ciclo de vida en lugar del desarrollador.
El problema central radica en mantener una jerarquía determinista donde las tareas padre pueden señalar de manera confiable la cancelación a todos los descendientes sin recorrer registros globales o arreglos de referencias débiles manuales. Los enfoques tradicionales que utilizan OperationQueue requieren el registro y desregistro explícitos de los controladores de finalización, creando una gestión de estado frágil que falla si se omite un controlador de finalización debido a una salida temprana. Además, propagar la cancelación requiere un sondeo complejo de banderas atómicas, lo que a menudo lleva a una capacidad de respuesta retrasada o un exceso de carga de CPU.
Swift aborda esto al incrustar un registro de tarea dentro de cada contexto de tarea que apunta a su padre, formando una lista enlazada intrusiva enraizada en el TaskGroup. Cuando se invoca addTask, el runtime inserta un registro de tarea hijo en esta lista, registrándolo atómicamente con el controlador de cancelación del padre. El mecanismo de cancelación utiliza una máquina de estados: cuando se llama a cancelAll(), el runtime recorre esta lista, estableciendo la bandera isCancelled en los metadatos de cada tarea hija y despertando a los ejecutores suspendidos. Esto asegura una propagación O(n) donde n es la profundidad del árbol, evitando bloqueos globales.
import Foundation func downloadImages(urls: [URL]) async throws -> [Data] { try await withThrowingTaskGroup(of: Data.self) { group in for url in urls { group.addTask { // La tarea hija verifica automáticamente la cancelación del padre let (data, _) = try await URLSession.shared.data(from: url) return data } } // Simulando la cancelación del usuario group.cancelAll() var results: [Data] = [] for try await data in group { results.append(data) } return results } }
Una aplicación de procesamiento de medios necesitaba generar miniaturas para 10,000 imágenes mientras permitía a los usuarios cancelar en medio de la operación. El equipo de ingeniería inicialmente utilizó un enfoque de DispatchGroup, rastreando objetos activos de URLSessionDataTask en un NSHashTable seguro para subprocesos para habilitar la cancelación.
La primera solución utilizó DispatchGroup con un DispatchSemaphore para limitar la concurrencia. Si bien era funcional, esto requería lógica compleja para eliminar tareas completadas del conjunto de cancelación. Ocurrían condiciones de carrera donde las tareas se completaban entre la señal de cancelación y la enumeración del conjunto, causando que la aplicación hiciera referencia a objetos desalojados. Este enfoque también filtraba memoria cuando se descartaba el controlador de vista porque las notificaciones de DispatchGroup mantenían fuertemente el delegado.
El segundo enfoque adoptó el FlatMap de Combine con un PassthroughSubject para la cancelación. Esto proporcionó una mejor composabilidad, pero introdujo una sobrecarga de memoria significativa debido a la asignación de la cadena de publicadores. La propagación de la cancelación requirió almacenar tokens de AnyCancellable en una colección que necesitaba limpieza manual. La abstracción declarativa ocultó la jerarquía real de tareas, lo que dificultó la depuración cuando las señales de cancelación no lograron propagarse a través de la cadena de operadores.
El equipo migró al TaskGroup de Swift. Esto eliminó la gestión manual de NSHashTable porque el runtime vinculó automáticamente cada tarea de generación de miniaturas al dominio de cancelación del grupo. Cuando el usuario pulsó la opción de cancelar, el controlador de vista invocó group.cancelAll(), que señalizó atómicamente a todas las tareas en ejecución para detenerse en su próximo punto de suspensión await. Esta solución garantizó que no se continuaran procesando tareas huérfanas después de la desasignación de la vista, y el alcance determinista de withThrowingTaskGroup aseguró la limpieza automática incluso si la función generaba un error.
La latencia de cancelación disminuyó de un promedio de 500 ms (esperando la enumeración del conjunto manual) a menos de 10 ms (recorrido directo de la lista enlazada). El perfilado de memoria mostró cero objetos Task filtrados después de la cancelación, y la base de código se redujo en 40 líneas de boilerplate de sincronización.
¿Cómo maneja TaskGroup el escenario donde una tarea hija ignora la cancelación y continúa ejecutándose indefinidamente?
Los candidatos a menudo creen que TaskGroup termina forzosamente las tareas o inyecta excepciones. En realidad, la cancelación de Swift es cooperativa: el runtime establece la bandera isCancelled en el contexto de la tarea, pero la tarea continúa hasta que alcanza un punto de suspensión o verifica explícitamente Task.isCancelled. La tarea hija debe sondear periódicamente Task.checkCancellation() o depender de API que sean conscientes de la cancelación. Si una tarea realiza un bucle intensivo en CPU sin puntos de suspensión, bloquea indefinidamente la finalización del grupo. Para prevenir esto, los cálculos de larga duración deben usar Task.yield() o dividir el trabajo en fragmentos que verifiquen las banderas de cancelación.
¿Por qué agregar una tarea a un TaskGroup después de llamar a cancelAll() aún resulta en la cancelación inmediata de esa nueva tarea?
Muchos asumen que cancelAll() es una señal única enviada solo a los hijos existentes. Sin embargo, la implementación de Swift marca el TaskGroup en sí mismo como cancelado en su registro de estado. Cuando se invoca addTask posteriormente, el runtime verifica el estado de cancelación del grupo atómicamente durante la creación de la tarea; si está cancelado, la nueva tarea hija se crea con su bandera isCancelled preestablecida. Esto garantiza que las tareas añadidas tarde no puedan escapar del dominio de cancelación, manteniendo la garantía estructural de que un ámbito cancelado no puede producir nuevos resultados válidos. Esto previene condiciones de carrera donde las tareas añadidas durante la desaceleración de la cancelación se escapan.
¿Cuál es la diferencia fundamental entre la concurrencia estructurada de TaskGroup y una tarea creada a través de Task.init en cuanto a la gestión de memoria de variables capturadas?
Los candidatos a menudo pasan por alto que las tareas hijas de TaskGroup heredan la aislamiento de actor y prioridad del contexto padre, pero más críticamente, amplían la vida útil de las variables capturadas solo hasta que sale el alcance del grupo. En contraste, los objetos Task no estructurados creados con Task { ... } persisten más allá de la vida del alcance que los creó, capturando potencialmente self indefinidamente. Esto significa que en TaskGroup, si capturas self en addTask, no necesitas [weak self] porque la tarea no puede sobrevivir al bloque withThrowingTaskGroup. Sin embargo, los desarrolladores a menudo aplican erróneamente patrones [weak self] de tareas no estructuradas, complicando innecesariamente el código y potencialmente introduciendo errores de referencias nulas si dependen de que self esté presente para la finalización.