1. Pourquoi TaskGroup dans LibraryCLI
Imaginez que, dans LibraryCLI, nous ayons une commande qui doit telecharger d’un coup beaucoup de livres par id : fetch-many 10 11 12 13 .... Nous ne voulons pas le faire un par un, car c’est lent. Mais async let ne convient pas non plus : le nombre d’id n’est pas code a l’avance, il vient de l’entree de l’utilisateur. Dans une telle situation, nous avons besoin d’un mecanisme du type “cree autant de taches qu’il y a d’elements dans le tableau, puis rassemble les resultats”. C’est exactement ce que fait TaskGroup.
Dans une analogie du quotidien, TaskGroup ressemble a un “panier de commandes” dans un restaurant : vous y deposez des commandes (taches), la cuisine les prepare en parallele (chaque cuisinier fait sa part), puis vous recuperer les plats au fur et a mesure qu’ils sont prets et vous composez la table finale. Et, point important, le restaurant ne vous laissera pas partir tant que la cuisine est encore en train de travailler — c’est cela, la structured concurrency.
Les taches ne “s’echappent” pas du bloc
L’idee principale de TaskGroup, c’est la portee. Le groupe est cree a l’interieur de l’appel a withTaskGroup (ou withThrowingTaskGroup) et ne vit que dans la fermeture body. Des que vous sortez de cette fermeture, Swift garantit que toutes les taches ajoutees au groupe seront terminees (si necessaire, elles seront attendues). Dans la proposition sur la structured concurrency, cela est formule comme une garantie cle : les taches a l’interieur du groupe sont automatiquement attendues avant la sortie de withTaskGroup.
C’est super important pour le cerveau du debutant : nous ne disperons pas des “petites taches de fond” dans le programme comme des confettis. Nous disons : “ici je lance une serie de travaux, et ici meme je les termine et je rassemble le resultat”. Un tel code est plus facile a deboguer, a tester et a expliquer a un collegue (ou a soi-meme une semaine plus tard).
On peut le representer schematiquement ainsi :
flowchart TD
A[Fonction asynchrone parente] --> B["withTaskGroup { ... }"]
B --> C[group.addTask ...]
B --> D[group.addTask ...]
B --> E[group.addTask ...]
C --> F[resultat 1]
D --> G[resultat 2]
E --> H[resultat 3]
F --> I["group.next()"]
G --> I
H --> I
I --> J[Aggregation dans le parent]
J --> K[return de withTaskGroup]
K --> L[On continue la fonction parente]
2. Syntaxe de base de withTaskGroup
Quand vous voyez withTaskGroup pour la premiere fois, il est facile de se perdre dans les types. Il y a effectivement deux niveaux de resultat, et c’est normal : nous decrivons a la fois le resultat d’une seule tache enfant et le resultat de toute l’operation de groupe.
Dans la structured concurrency proposal, cela est separe au moins en deux entites : TaskGroup<ChildTaskResult> et le resultat final que withTaskGroup renvoie en tant que fonction enveloppe.
Commencons par quelque chose de simple, sans reseau ni modele metier — juste pour que le cerveau “s’habitue a la forme”.
Exemple : elever des nombres au carre en parallele et les sommer
func square(_ x: Int) async -> Int { x * x }
func sumOfSquares(_ numbers: [Int]) async -> Int {
await withTaskGroup(of: Int.self) { group in
for n in numbers { group.addTask { await square(n) } }
var total = 0
while let v = await group.next() { total += v }
return total
}
}
Ici, le resultat d’une tache est un Int (le carre d’un nombre). D’ou of: Int.self.
Et le resultat de toute l’operation est aussi un Int (la somme des carres). Il est renvoye par un simple return total a l’interieur du bloc.
Remarquez la forme de la boucle : while let v = await group.next() { ... }. Dans la proposition, ce motif est justement presente comme une facon typique de “recueillir les resultats jusqu’a nil”.
3. group.addTask : ajout de taches enfants
Quand nous disons “ajouter une tache au groupe”, nous disons en pratique : “cree une tache enfant de la tache courante et prends la responsabilite d’attendre sa fin”.
Point de style important : a l’interieur de group.addTask, vous calculez generalement une valeur, au lieu de “modifier le monde”. Autrement dit, la tache doit renvoyer un resultat (par exemple, Book ou Int), tandis que l’ajout dans un tableau, le calcul de progression, l’affichage de logs et, plus encore, l’ecriture dans un depot sont mieux faits dans le parent, la ou vous lisez group.next().
Pourquoi cela ? Parce que cela apporte deux avantages d’un coup :
Premierement, le code devient plus deterministe : un seul endroit modifie l’accumulateur.
Deuxiemement, il y a moins de chances de commencer “par accident” a partager un etat mutable commun entre les taches, et c’est justement la voie royale vers des bugs invisibles.
Mini-exemple : ajout de taches dans une boucle
func delayed(_ x: Int) async -> Int {
try? await Task.sleep(nanoseconds: UInt64(x) * 20_000_000)
return x
}
func collect(_ values: [Int]) async -> [Int] {
await withTaskGroup(of: Int.self) { group in
for v in values { group.addTask { await delayed(v) } }
var out: [Int] = []
while let v = await group.next() { out.append(v) }
return out
}
}
Oui, l’ordre dans out peut etre inattendu — parce que les taches se terminent au fur et a mesure qu’elles sont pretes. Mais aujourd’hui, nous restons concentres sur la syntaxe et la mecanique. Comprendre simplement que “l’ordre n’est pas garanti” suffit ; nous verrons comment restaurer l’ordre dans une autre lecon.
4. group.next() : reunir les resultats jusqu’a nil
Si group.addTask consiste a “jeter du travail dans le panier”, alors group.next() consiste a “donne-moi le prochain resultat pret”.
Selon la description de la structured concurrency proposal, la methode next() renvoie soit le resultat suivant d’une tache enfant, soit nil lorsqu’il ne reste plus de taches. Il y est egalement montre que next() est pratique a utiliser dans une boucle while.
Pourquoi next() est asynchrone
Parce que le “resultat suivant” n’est peut-etre pas encore pret. L’attendre signifie attendre, donc await.
Mini-exemple : disons explicitement nil
func demoNext() async {
let values = [1, 2, 3]
await withTaskGroup(of: Int.self) { group in
for v in values { group.addTask { v * 10 } }
while let x = await group.next() {
print("got:", x) // l'ordre peut etre quelconque
}
print("group is done")
}
}
Et ici, point psychologique important : la boucle se termine non pas “parce que nous sommes fatigues”, mais parce que next() a renvoye nil. C’est un tres bon point d’ancrage : nil signifie “il n’y a plus de taches actives dans le groupe”.
5. withThrowingTaskGroup : les erreurs comme partie du flux de controle
Ajoutons maintenant la deuxieme version : withThrowingTaskGroup. Elle est utile lorsque les taches enfants peuvent lever des erreurs et que vous voulez que l’erreur fasse partie du flux de controle normal.
Dans la proposal sur la structured concurrency, la signature de la variante throwing est presentee, et il est dit clairement que lorsqu’une erreur est levee, les taches restantes sont annulees et l’erreur est remontee vers l’exterieur.
Il est aussi important de comprendre la mecanique : dans un groupe throwing, la lecture du resultat se fait avec try await group.next(), car c’est justement au moment de “recuperer le resultat” que l’erreur d’une tache particuliere peut se manifester. Dans la documentation, cela est dit tres explicitement : si une operation dans le groupe a leve une erreur, cette erreur sera “re-relancee” par l’appel correspondant a next().
Mini-exemple : “on echoue parfois”
enum DemoError: Error { case boom(Int) }
func fragileWork(_ x: Int) async throws -> Int {
if x == 2 { throw DemoError.boom(x) }
return x * 100
}
func allOrNothing(_ values: [Int]) async throws -> [Int] {
try await withThrowingTaskGroup(of: Int.self) { group in
for v in values { group.addTask { try await fragileWork(v) } }
var out: [Int] = []
while let v = try await group.next() { out.append(v) }
return out
}
}
Ici, nous choisissons deliberement la politique “tout ou rien”. Si une tache echoue, toute l’operation allOrNothing echoue avec une erreur. C’est un scenario normal pour certaines operations metier (par exemple, “charger une configuration en plusieurs parties” : si une partie n’est pas arrivee, la configuration est incomplete).
6. Exemple : fetchMany dans LibraryCLI via TaskGroup
Passons maintenant au plus interessant : relions cela a notre application reelle, que nous construisons depuis le debut du cours. Rappel du contexte : nous avons un CLI, un parseur de commandes, ApiClient, des erreurs reseau, et des commandes du type fetch/fetch-many. Plus tot, nous pouvions faire des requetes paralleles via async let, mais le nombre de requetes y est fixe. Aujourd’hui, nous voulons traiter un nombre arbitraire d’id.
Important : dans cette lecon, nous construisons uniquement le squelette. Nous ne resolvons pas encore la question de “comment gerer proprement un succes partiel” ni celle de “comment limiter le parallelisme” — cela viendra plus tard dans le plan du jour. Ici, uniquement la syntaxe de base de withThrowingTaskGroup.
Supposons que nous ayons deja une methode apiClient.fetchBook(id:) async throws -> Book (son idee et ses couches d’erreurs ont ete introduites plus tot). Alors, dans le service, on peut ecrire ceci :
import Foundation
struct LibraryService {
let apiClient: ApiClient
func fetchBooks(ids: [String]) async throws -> [Book] {
try await withThrowingTaskGroup(of: Book.self) { group in
for id in ids { group.addTask { try await apiClient.fetchBook(id: id) } }
var out: [Book] = []
while let b = try await group.next() { out.append(b) }
return out
}
}
}
On voit ici tout cela “en toute honnetete” :
- Nous ajoutons des taches dans une boucle for id in ids, c’est-a-dire que le nombre de taches depend des donnees d’entree.
- Nous rassemblons les resultats via while let ... = try await group.next() — c’est le motif de base issu de la description de TaskGroup.next().
- Nous renvoyons le tableau final depuis withThrowingTaskGroup par un simple return.
Comment appeler cela depuis le CLI
Nous avons une application CLI en SwiftPM, et nous vivons depuis longtemps avec @main. Dans la structured concurrency proposal, il est montre separement que @main peut avoir un main() async throws, et c’est normal : Swift cree une tache et execute votre async-main comme point d’entree.
Appel pseudo-code (simplifie, sans analyse des commandes) :
@main
struct LibraryCLI {
static func main() async throws {
let service = makeService()
let books = try await service.fetchBooks(ids: ["10", "11", "12"])
print("Fetched: \(books.count)") // par exemple : Fetched: 3
}
}
Fiche memoire : async let ou TaskGroup
Quand les outils se ressemblent, le cerveau du debutant commence a prendre un air tres serieux et a paniquer silencieusement. Il est donc utile d’avoir un petit tableau qui supprime la question “que choisir ?”.
Voici une fiche simple. Elle correspond a l’idee presente dans les materiaux sur async let : async let est bon pour un nombre fixe de taches, tandis que TaskGroup convient au cas dynamique.
| Situation | Mieux vaut utiliser | Pourquoi |
|---|---|---|
| “Lance exactement 2–3 operations independantes en parallele” | |
Le code est plus court, la structure est statique, facile a lire |
| “Lance N operations, ou N = array.count et n’est pas connu a l’avance” | |
On peut ajouter des taches dans une boucle et reunir les resultats |
| “Les operations peuvent lever des erreurs, et l’erreur doit remonter” | |
try await group.next() propage les erreurs des taches |
| “Il faut une portee stricte : les taches ne doivent pas survivre au bloc” | |
Garanties de structured concurrency : les taches se terminent a l’interieur du scope |
7. Erreurs typiques lors de l’utilisation de TaskGroup
Erreur n°1 : essayer d’utiliser group en dehors de withTaskGroup.
Parfois, on a envie de “renvoyer le group a l’exterieur” ou de le stocker dans une propriete pour ajouter d’autres taches plus tard. Cela casse l’idee meme de la structured concurrency : le groupe ne vit qu’a l’interieur de la fermeture. De facon correcte, considerez group comme une “zone de travail a usage unique” : on entre, on fait une serie de travaux, on sort. La proposition souligne que TaskGroup n’a pas d’initialisateurs publics et n’est transmis que dans body ; on ne peut pas l’utiliser apres la sortie du scope.
Erreur n°2 : oublier await avant group.next().
C’est un grand classique. next() est une attente de resultat, et sans await le compilateur ne laissera pas passer. Mais il est utile d’en comprendre le sens : si le resultat n’est pas encore pret, la tache courante doit se suspendre et laisser le systeme executer d’autres taches.
Erreur n°3 : dans un groupe throwing, ecrire await group.next() au lieu de try await group.next().
Si les taches enfants peuvent lever une erreur, le moment de recuperation du resultat peut aussi lever. Dans la description de next(), cela est dit tres clairement : l’erreur sera “re-relancee” par l’appel correspondant a next().
Erreur n°4 : modifier directement un tableau/dictionnaire partage a l’interieur de group.addTask.
On fait souvent cela par habitude : “je veux bien mettre le livre dans books directement ici”. Mais alors vous obtenez un etat mutable partage, touche par plusieurs taches en parallele. Meme si “ca marche a peu pres”, cela deviendra tres vite instable. Beaucoup plus propre : la tache renvoie une valeur, et le parent rassemble via group.next() et l’ajoute au tableau a un seul endroit.
Erreur n°5 : confondre “resultat de la tache” et “resultat de toute l’operation”.
of: Book.self — c’est ce que renvoie une seule tache enfant. Et ce que vous faites avec return out — c’est le resultat de toute l’operation withThrowingTaskGroup. Si l’on garde cette distinction en tete, les signatures cessent de ressembler a de la magie et deviennent simplement une description precise de deux niveaux de resultat.
GO TO FULL VERSION