Le modèle de concurrence de Swift a subi un changement de paradigme avec Swift 5.5, introduisant la concurrence structurée pour remplacer les anciens modèles Grand Central Dispatch qui menaient souvent à des tâches orphelines et des fuites de ressources. Avant cela, les développeurs géraient manuellement des instances de DispatchGroup pour suivre le travail concurrent, nécessitant une synchronisation explicite pour éviter les conditions de course lors de l'annulation. L'abstraction TaskGroup a été conçue pour encapsuler nativement l'arbre de relations parent-enfant, garantissant que le runtime maintienne les métadonnées du cycle de vie plutôt que le développeur.
Le problème central réside dans le maintien d'une hiérarchie déterministe où les tâches parentes peuvent signaler de manière fiable l'annulation à tous les descendants sans parcourir des enregistrements globaux ou des tableaux de références faibles manuels. Les approches traditionnelles utilisant OperationQueue nécessitent une inscription et une désinscription explicites des gestionnaires de fin de tâche, créant une gestion d'état fragile qui échoue si un gestionnaire de fin est sauté en raison d'une sortie anticipée. De plus, la propagation de l'annulation nécessite un sondage complexe de drapeaux atomiques, entraînant souvent un retard de réactivité ou une surcharge excessive du CPU.
Swift aborde cela en intégrant un enregistrement de tâche dans le contexte de chaque tâche qui pointe vers son parent, formant une liste chaînée intrusive enracinée dans TaskGroup. Lorsque la méthode addTask est invoquée, le runtime insère un enregistrement de tâche enfant dans cette liste, l'enregistrant atomiquement avec le gestionnaire d'annulation du parent. Le mécanisme d'annulation utilise une machine à états : lorsque cancelAll() est appelé, le runtime parcourt cette liste, définissant le drapeau isCancelled sur les métadonnées de chaque tâche enfant et réveillant les exécutants suspendus. Cela garantit une propagation en O(n) où n est la profondeur de l'arbre, évitant les verrouillages globaux.
import Foundation func downloadImages(urls: [URL]) async throws -> [Data] { try await withThrowingTaskGroup(of: Data.self) { group in for url in urls { group.addTask { // La tâche enfant vérifie automatiquement l'annulation du parent let (data, _) = try await URLSession.shared.data(from: url) return data } } // Simulation de l'annulation par l'utilisateur group.cancelAll() var results: [Data] = [] for try await data in group { results.append(data) } return results } }
Une application de traitement média avait besoin de générer des miniatures pour 10 000 images tout en permettant aux utilisateurs d'annuler en cours de traitement. L'équipe d'ingénierie a d'abord utilisé une approche avec DispatchGroup, suivant des objets URLSessionDataTask actifs dans un NSHashTable thread-safe pour permettre l'annulation.
La première solution utilisait DispatchGroup avec un DispatchSemaphore pour limiter la concurrence. Bien que fonctionnelle, cela nécessitait une logique complexe pour retirer les tâches achevées du jeu d'annulation. Des conditions de course se produisaient lorsque des tâches étaient terminées entre le signal d'annulation et l'énumération du jeu, amenant l'application à référencer des objets libérés. Cette approche fuyait également de la mémoire lorsque le contrôleur de vue était rejeté, car les notifications DispatchGroup retenaient fortement le délégué.
La deuxième approche a adopté le FlatMap de Combine avec un PassthroughSubject pour l'annulation. Cela offrait une meilleure composition, mais introduisait une surcharge de mémoire significative due à l'allocation de la chaîne d'éditeurs. La propagation de l'annulation nécessitait de stocker des jetons AnyCancellable dans une collection nécessitant un nettoyage manuel. L'abstraction déclarative masquait la hiérarchie réelle des tâches, rendant le débogage difficile lorsque les signaux d'annulation échouaient à se propager à travers la chaîne d'opérateurs.
L'équipe a migré vers TaskGroup de Swift. Cela a éliminé la gestion manuelle de NSHashTable car le runtime a automatiquement lié chaque tâche de génération de miniature au domaine d'annulation du groupe. Lorsque l'utilisateur a appuyé sur annuler, le contrôleur de vue a invoqué group.cancelAll(), ce qui a signalé atomiquement à toutes les tâches en cours de cesser à leur prochain point de suspension await. Cette solution garantissait qu'aucune tâche orpheline ne continuait le traitement après la désallocation de la vue, et la portée déterministe de withThrowingTaskGroup assurait un nettoyage automatique même si la fonction levait une erreur.
La latence d'annulation est passée d'une moyenne de 500 ms (attente pour l'énumération manuelle du jeu) à moins de 10 ms (parcours direct de la liste chaînée). Le profilage mémoire a montré zéro objet Task fuyant après l'annulation, et la base de code a été réduite de 40 lignes de code de synchronisation.
Comment TaskGroup gère-t-il le scénario où une tâche enfant ignore l'annulation et continue à s'exécuter indéfiniment ?
Les candidats croient souvent que TaskGroup termine de force les tâches ou injecte des exceptions. En réalité, l'annulation de Swift est coopérative : le runtime définit le drapeau isCancelled dans le contexte de la tâche, mais la tâche continue jusqu'à ce qu'elle atteigne un point de suspension ou vérifie explicitement Task.isCancelled. La tâche enfant doit interroger périodiquement Task.checkCancellation() ou s'appuyer sur des API conscientes de l'annulation. Si une tâche effectue une boucle fortement liée au CPU sans points de suspension, elle bloque l'achèvement du groupe indéfiniment. Pour prévenir cela, les calculs de longue durée devraient utiliser Task.yield() ou diviser le travail en morceaux vérifiant les drapeaux d'annulation.
Pourquoi l'ajout d'une tâche à un TaskGroup après avoir appelé cancelAll() entraîne-t-il toujours l'annulation immédiate de cette nouvelle tâche ?
Beaucoup supposent que cancelAll() est un signal unique envoyé uniquement aux enfants existants. Cependant, l'implémentation de Swift marque le TaskGroup lui-même comme annulé dans son enregistrement d'état. Lorsque addTask est ensuite invoqué, le runtime vérifie l'état d'annulation du groupe atomiquement lors de la création de la tâche ; si elle est annulée, la nouvelle tâche enfant est créée avec son drapeau isCancelled préétabli. Cela garantit que les tâches ajoutées tard ne peuvent pas échapper au domaine d'annulation, maintenant la garantie structurelle qu'une portée annulée ne peut pas produire de nouveaux résultats valides. Cela prévient les conditions de course où les tâches ajoutées pendant le démantèlement de l'annulation peuvent passer à travers.
Quelle est la différence fondamentale entre la concurrence structurée de TaskGroup et une tâche créée via Task.init en ce qui concerne la gestion de la mémoire des variables capturées ?
Les candidats négligent souvent que les tâches enfants de TaskGroup héritent de l'isolement des acteurs et de la priorité du contexte parent, mais plus critiquement, elles prolongent la durée de vie des variables capturées uniquement jusqu'à la sortie de la portée du groupe. En revanche, les objets Task non structurés créés avec Task { ... } persistent au-delà de la durée de vie de la portée créée, capturant potentiellement self indéfiniment. Cela signifie que dans TaskGroup, si vous capturez self dans addTask, vous n'avez pas besoin de [weak self] car la tâche ne peut pas survivre au bloc withThrowingTaskGroup. Cependant, les développeurs appliquent souvent à tort des motifs [weak self] provenant de tâches non structurées, compliquant le code inutilement et introduisant potentiellement des bugs de référence nulle s'ils comptent sur self étant présent pour l'achèvement.