Tester un ordonnanceur iOS à concurrence bornée sur Mac cloud
Lors du traitement par lots de vingt pièces jointes, placer toutes les tâches dans un groupe puis récupérer leurs résultats un à un ne suffit pas à limiter la concurrence. Le nombre de connexions réseau, les tampons de décodage et les fichiers temporaires peuvent malgré tout augmenter simultanément. Sur un Mac cloud, la validation de cette logique ne consiste pas à mesurer sa rapidité, mais à démontrer une propriété précise : avec une limite de trois opérations, la quatrième doit attendre qu’une place se libère.
Nous allons donc séparer l’ordonnanceur de l’implémentation réseau. Nous vérifierons d’abord la gestion des places avec des opérations contrôlables, avant de réintégrer le code métier iOS. En cas d’échec, il sera ainsi possible de distinguer une erreur d’ordonnancement d’une fluctuation du service externe, sans multiplier les relances dans l’espoir d’en tirer une conclusion.
Définir d’abord un contrat d’ordonnancement vérifiable
Le contrat est le suivant : l’ordre des sorties correspond à celui des entrées, même si les opérations se terminent dans un autre ordre ; une entrée vide produit un tableau vide ; la limite de concurrence doit être strictement positive. Si une tâche enfant lève une erreur, l’appel entier échoue, sans fournir de résultats partiels. Après l’annulation de la tâche parente, l’ordonnanceur ne doit plus alimenter volontairement le groupe avec du travail en attente.
La limite porte ici sur le nombre d’opérations actives passées à la fonction, et non sur le nombre de threads ou le débit des requêtes. Si une opération lance elle-même plusieurs requêtes, celles-ci ne sont pas comptées individuellement par l’ordonnanceur externe. Un quota de requêtes par seconde exige un mécanisme distinct : les places de concurrence ne peuvent pas en tenir lieu.
L’annulation est un signal coopératif, pas un arrêt forcé. Avant de quitter sa portée, le groupe de tâches attend toujours la fin de ses tâches enfants ; une opération qui ignore l’annulation retarde donc la fin de l’appel entier.
Soumettre la tâche suivante à chaque fin d’opération
Enregistrez le code suivant dans BoundedMap.swift. Exécutez-le sur un Mac cloud équipé d’une chaîne d’outils Swift, en commençant par relever sa version avec swift --version. L’exemple utilise les contraintes Sendable nécessaires à la vérification stricte de la concurrence et ne dépend ni d’une session graphique ni d’un simulateur.
import Foundation
func boundedMap<Input: Sendable, Output: Sendable>(
_ inputs: [Input],
limit: Int,
operation: @escaping @Sendable (Input) async throws -> Output
) async throws -> [Output] {
precondition(limit > 0)
return try await withThrowingTaskGroup(
of: (Int, Output).self
) { group in
var nextIndex = 0
var completed: [(Int, Output)] = []
completed.reserveCapacity(inputs.count)
for index in 0..<min(limit, inputs.count) {
try Task.checkCancellation()
let input = inputs[index]
group.addTask {
try Task.checkCancellation()
return (index, try await operation(input))
}
nextIndex += 1
}
while let result = try await group.next() {
completed.append(result)
try Task.checkCancellation()
if nextIndex < inputs.count {
let index = nextIndex
let input = inputs[index]
group.addTask {
try Task.checkCancellation()
return (index, try await operation(input))
}
nextIndex += 1
}
}
try Task.checkCancellation()
return completed.sorted { $0.0 < $1.0 }.map { $0.1 }
}
}
Au départ, le nombre de tâches soumises ne dépasse pas la limite. Ensuite, une nouvelle tâche n’est soumise qu’à la réception d’un résultat. Une longue liste d’entrées ne provoque donc pas la création d’autant de tâches en attente. Chaque résultat est accompagné de son indice, ce qui permet de rétablir l’ordre des entrées à la fin, sans confondre ordre de fin d’exécution et position dans la sortie.
Les erreurs sont propagées par group.next(). Si la fermeture se termine en levant une erreur, le groupe annule les tâches enfants restantes et attend qu’elles se terminent. Ici, aucune erreur n’est interceptée pour continuer à alimenter le groupe, et aucune tâche d’arrière-plan indépendante du cycle de vie de la tâche parente n’est utilisée.
Mesurer le nombre réel d’opérations actives avec une sonde actor
Ajoutez le code suivant à la fin du même fichier, puis exécutez swift BoundedMap.swift. La sonde incrémente le compteur à l’entrée de chaque opération et le décrémente à sa sortie. Pendant la suspension, l’actor peut recevoir d’autres appels : il ne sérialise donc pas accidentellement l’ensemble du travail.
actor Probe {
private var active = 0
private var peak = 0
func run(_ value: Int) async throws -> Int {
active += 1
peak = max(peak, active)
defer { active -= 1 }
let delay = UInt64(5 - value % 5) * 10_000_000
try await Task.sleep(nanoseconds: delay)
return value * 2
}
func snapshot() -> (active: Int, peak: Int) {
(active, peak)
}
}
let probe = Probe()
let inputs = Array(0..<20)
let output = try await boundedMap(inputs, limit: 3) {
try await probe.run($0)
}
let state = await probe.snapshot()
precondition(output == inputs.map { $0 * 2 })
precondition(state.active == 0)
precondition(state.peak <= 3)
Les délais différents servent à modifier l’ordre de fin des opérations, pas à établir une référence de performance. N’exigez pas qu’une tâche se termine à une milliseconde précise et ne considérez pas comme un échec un pic inférieur à trois sur une machine chargée. Les propriétés à garantir sont les suivantes : le pic ne dépasse pas la limite, les résultats sont complets et le nombre d’opérations actives revient à zéro à la fin de l’appel.
Exécutez également des tests distincts avec un tableau vide, un seul élément et une limite de un. Si le code appelant lit la limite dans un fichier de configuration, il doit rejeter au préalable les valeurs nulles et négatives. La précondition de cet exemple convient pour révéler une erreur de programmation, mais pas pour valider directement une saisie utilisateur.
Valider séparément les échecs et l’annulation
Un scénario réussi ne prouve pas que l’annulation fonctionne correctement. Commencez par créer une opération qui lève systématiquement une erreur pour une entrée donnée, puis vérifiez que l’appel échoue et que le compteur de la sonde revient finalement à zéro. N’imposez pas un nombre exact de tâches démarrées avant l’échec : avant que l’erreur n’atteigne le groupe, d’autres tâches peuvent déjà s’être terminées et avoir déclenché la soumission de nouvelles tâches.
Pour tester l’annulation, utilisez une barrière contrôlable : les premières opérations signalent qu’elles sont entrées, puis attendent. À la réception de ces signaux, le test annule la tâche parente, puis libère la barrière. Après l’avoir franchie, les opérations vérifient l’annulation. Le test attend ensuite la fin de la tâche parente et confirme que toutes les opérations entrées sont bien sorties.
Ne vous fiez pas à une temporisation fixe pour deviner si les tâches ont démarré. La barrière doit elle-même pouvoir libérer tous ceux qui attendent ; sinon, le test risque de prendre un interblocage du dispositif de test pour un blocage de l’ordonnanceur. Le rapport de validation doit distinguer le moment de la demande d’annulation, celui de la sortie des opérations et celui du retour de la tâche parente.
Préserver le périmètre des places lors de l’intégration métier
Pour un téléchargement réseau, le téléchargement, le décodage nécessaire et le nettoyage des fichiers temporaires doivent rester dans la portée d’une même opération. Si la place est libérée dès la réception de la réponse, tandis que le décodage d’une grande image est confié à une tâche d’arrière-plan, la concurrence réseau reste maîtrisée, mais le pic de mémoire ne l’est plus.
Cette implémentation conserve toutes les sorties : son occupation mémoire augmente donc avec le volume total des résultats. Pour traiter de gros fichiers, mieux vaut renvoyer leur emplacement et un petit résumé plutôt que de charger tout leur contenu dans le tableau. Un pipeline qui consomme les résultats au fil de leur arrivée nécessite un contrat distinct de contre-pression en sortie ; cette implémentation ne peut pas être présentée telle quelle comme ayant une occupation mémoire constante.
Conservez enfin la version de la chaîne d’outils, les entrées de test, la limite, le pic et le nombre d’opérations sorties. Placez les tests de l’ordonnanceur dans une cible de tests unitaires classique, puis ajoutez aux opérations réelles des vérifications de l’annulation et de la libération des ressources. Dès lors que les limites de concurrence, l’ordre des résultats et la bonne fin des opérations sont démontrés de manière reproductible, vous disposez d’une référence comportementale vérifiable pour passer à un autre Mac cloud.
Questions fréquentes
Décaler le démarrage des tâches limite-t-il la concurrence ?
Non. Des démarrages décalés ne garantissent aucun plafond de tâches actives. Lancez uniquement le nombre autorisé, puis ajoutez une tâche à chaque fin.
Un groupe annulé rend-il immédiatement la main ?
Pas nécessairement. Un groupe structuré attend la fin de ses tâches enfants. Chaque opération doit respecter l’annulation et arrêter le travail sous-jacent.
Besoin d’un Mac dédié pour effectuer cette compilation ?
Choisissez la durée de location selon vos besoins. Profitez d’un Mac physique entièrement dédié, avec macOS accessible en interface graphique et en ligne de commande. Les modèles, les régions et les disponibilités sont ceux indiqués en temps réel dans la console.