Concurrence avancée dans Swift avec HoneyBee

Publié: 2022-03-11

Concevoir, tester et maintenir des algorithmes simultanés dans Swift est difficile et obtenir les bons détails est essentiel au succès de votre application. Un algorithme concurrent (également appelé programmation parallèle) est un algorithme conçu pour effectuer plusieurs opérations (peut-être plusieurs) en même temps afin de tirer parti de davantage de ressources matérielles et de réduire le temps d'exécution global.

Sur les plates-formes d'Apple, la manière traditionnelle d'écrire des algorithmes concurrents est NSOperation. La conception de NSOperation invite le programmeur à subdiviser un algorithme concurrent en tâches individuelles asynchrones de longue durée. Chaque tâche serait définie dans sa propre sous-classe de NSOperation et les instances de ces classes seraient combinées via une API objective pour créer un ordre partiel des tâches au moment de l'exécution. Cette méthode de conception d'algorithmes concurrents a été à la pointe de la technologie sur les plateformes d'Apple pendant sept ans.

En 2014, Apple a introduit Grand Central Dispatch (GCD) comme une avancée spectaculaire dans l'expression des opérations simultanées. GCD, ainsi que les nouveaux blocs de fonctionnalités de langage qui l'accompagnaient et l'alimentaient, fournissaient un moyen de décrire de manière compacte un gestionnaire de réponse asynchrone immédiatement après la requête asynchrone initiale. Les programmeurs n'étaient plus encouragés à répartir la définition des tâches simultanées sur plusieurs fichiers dans de nombreuses sous-classes NSOperation. Désormais, un algorithme simultané complet pourrait être écrit dans une seule méthode. Cette augmentation de l'expressivité et de la sécurité typographique a été un changement conceptuel important. Un algorithme typique de cette façon d'écrire pourrait ressembler à ceci :

 func processImageData(completion: (result: Image?, error: Error?) -> Void) { loadWebResource("dataprofile.txt") { (dataResource, error) in guard let dataResource = dataResource else { completion(nil, error) return } loadWebResource("imagedata.dat") { (imageResource, error) in guard let imageResource = imageResource else { completion(nil, error) return } decodeImage(dataResource, imageResource) { (imageTmp, error) in guard let imageTmp = imageTmp else { completion(nil, error) return } dewarpAndCleanupImage(imageTmp) { imageResult in guard let imageResult = imageResult else { completion(nil, error) return } completion(imageResult, nil) } } } } }

Décomposons un peu cet algorithme. La fonction processImageData est une fonction asynchrone qui effectue elle-même quatre appels asynchrones pour terminer son travail. Les quatre invocations asynchrones sont imbriquées les unes dans les autres de la manière la plus naturelle pour la gestion asynchrone basée sur les blocs. Les blocs de résultat ont chacun un paramètre Error facultatif et tous sauf un contiennent un paramètre facultatif supplémentaire signifiant le résultat de l'opération aysnc.

La forme du bloc de code ci-dessus semble probablement familière à la plupart des développeurs Swift. Mais qu'est-ce qui ne va pas avec cette approche? La liste suivante de points douloureux vous sera probablement également familière.

  • Cette forme de « pyramide du destin » de blocs de code imbriqués peut rapidement devenir difficile à manier. Que se passe-t-il si nous ajoutons deux autres opérations asynchrones ? Quatre ? Qu'en est-il des opérations conditionnelles ? Qu'en est-il du comportement de nouvelle tentative ou des protections pour les limites de ressources ? Le code du monde réel n'est jamais aussi propre et simple que les exemples dans les articles de blog. L'effet « pyramide du destin » peut facilement entraîner un code difficile à lire, difficile à maintenir et sujet aux bogues.
  • La tentative de gestion des erreurs dans l'exemple ci-dessus, bien que rapide, est en fait incomplète. Le programmeur a supposé que les blocs de rappel asynchrones à deux paramètres de style Objective-C fourniront toujours l'un des deux paramètres ; ils ne seront jamais tous les deux nuls en même temps. Ce n'est pas une hypothèse sûre. Les algorithmes simultanés sont réputés pour être difficiles à écrire et à déboguer, et les hypothèses non fondées en sont une partie. La gestion complète et correcte des erreurs est une nécessité incontournable pour tout algorithme concurrent qui a l'intention de fonctionner dans le monde réel.
  • Poussant cette pensée encore plus loin, peut-être que le programmeur qui a écrit les fonctions appelées asynchrones n'avait pas autant de principes que vous. Que se passe-t-il s'il existe des conditions dans lesquelles les fonctions appelées ne parviennent pas à se rappeler ? Ou rappeler plus d'une fois ? Qu'advient-il de l'exactitude de processImageData dans ces circonstances ? Les pros ne prennent pas de risques. Les fonctions critiques doivent être correctes même lorsqu'elles reposent sur des fonctions écrites par des tiers.
  • Peut-être le plus convaincant, l'algorithme asynchrone considéré est construit de manière sous-optimale. Les deux premières opérations asynchrones sont toutes deux des téléchargements de ressources distantes. Même s'ils n'ont aucune interdépendance, l'algorithme ci-dessus exécute les téléchargements de manière séquentielle et non en parallèle. Les raisons en sont évidentes; la syntaxe des blocs imbriqués encourage un tel gaspillage. Les marchés concurrentiels ne tolèrent pas les retards inutiles. Si votre application n'exécute pas ses opérations asynchrones aussi rapidement que possible, une autre application le fera.

Comment pouvons-nous faire mieux? HoneyBee est une bibliothèque de contrats à terme/promesses qui rend la programmation simultanée Swift facile, expressive et sûre. Réécrivons l'algorithme asynchrone ci-dessus avec HoneyBee et examinons le résultat :

 func processImageData(completion: (result: Image?, error: Error?) -> Void) { HoneyBee.start() .setErrorHandler { completion(nil, $0) } .branch { stem in stem.chain(loadWebResource =<< "dataprofile.txt") + stem.chain(loadWebResource =<< "imagedata.dat") } .chain(decodeImage) .chain(dewarpAndCleanupImage) .chain { completion($0, nil) } }

La première ligne que cette implémentation commence est une nouvelle recette HoneyBee. La deuxième ligne établit le gestionnaire d'erreurs par défaut. La gestion des erreurs n'est pas facultative dans les recettes HoneyBee. Si quelque chose peut mal tourner, l'algorithme doit le gérer. La troisième ligne ouvre une branche qui permet une exécution parallèle. Les deux chaînes de loadWebResource s'exécuteront en parallèle et leurs résultats seront combinés (ligne 5). Les valeurs combinées des deux ressources chargées sont transmises à decodeImage et ainsi de suite dans la chaîne jusqu'à ce que l'achèvement soit appelé.

Passons en revue la liste des points faibles ci-dessus et voyons comment HoneyBee a amélioré ce code. Le maintien de cette fonction est maintenant beaucoup plus facile. La recette HoneyBee ressemble à l'algorithme qu'elle exprime. Le code est lisible, compréhensible et rapidement modifiable. La conception de HoneyBee garantit que tout mauvais ordre des instructions entraîne une erreur de compilation et non une erreur d'exécution. La fonction est désormais beaucoup moins sensible aux bogues et aux erreurs humaines.

Toutes les erreurs d'exécution possibles ont été entièrement traitées. Chaque signature de fonction prise en charge par HoneyBee (il y en a 38) est assurée d'être entièrement gérée. Dans notre exemple, le rappel à deux paramètres de style Objective-C produira soit une erreur non nulle qui sera acheminée vers le gestionnaire d'erreurs, soit une valeur non nulle qui progressera dans la chaîne, ou bien si les deux les valeurs sont nulles HoneyBee générera une erreur expliquant que le rappel de la fonction ne remplit pas son contrat.

HoneyBee gère également l'exactitude contractuelle du nombre de fois que les rappels de fonction sont invoqués. Si une fonction ne parvient pas à invoquer son rappel, HoneyBee produit un échec descriptif. Si la fonction invoque son rappel plus d'une fois, HoneyBee supprimera les invocations auxiliaires et les avertissements de journal. Ces deux réponses aux pannes (et d'autres) peuvent être personnalisées pour les besoins individuels du programmeur.

Espérons qu'il devrait déjà être évident que cette forme de processImageData parallélise correctement les téléchargements de ressources pour fournir des performances optimales. L'un des objectifs de conception les plus importants de HoneyBee est que la recette ressemble à l'algorithme qu'elle exprime.

Beaucoup mieux. Droit? Mais HoneyBee a bien plus à offrir.

Soyez averti : la prochaine étude de cas n'est pas pour les âmes sensibles. Considérez la description de problème suivante : Votre application mobile utilise CoreData pour conserver son état. Vous disposez d'un modèle NSManagedObject appelé Media, qui représente une ressource multimédia téléchargée sur votre serveur principal. L'utilisateur doit être autorisé à sélectionner des dizaines d'éléments multimédias à la fois et à les télécharger par lots sur le système backend. Les médias sont d'abord représentés via une chaîne de référence, qui doit être convertie en un objet Media. Heureusement, votre application contient déjà une méthode d'assistance qui fait exactement cela :

 func export(_ mediaRef: String, completion: @escaping (Media?, Error?) -> Void) { // transcoding stuff completion(Media(context: managedObjectContext), nil) }

Une fois la référence multimédia convertie en objet multimédia, vous devez charger l'élément multimédia sur le back-end. Encore une fois, vous avez une fonction d'assistance prête à faire les choses sur le réseau.

 func upload(_ media: Media, completion: @escaping (Error?) -> Void) { // network stuff completion(nil) }

Étant donné que l'utilisateur est autorisé à sélectionner des dizaines d'éléments multimédias à la fois, le concepteur UX a spécifié une quantité assez solide de commentaires sur la progression du téléchargement. Les exigences ont été distillées dans les quatre fonctions suivantes :

 /// Called if anything goes wrong in the upload func errorHandler(_ error: Error) { // do the right thing } /// Called once per mediaRef, after either a successful or unsuccessful upload func singleUploadCompletion(_ mediaRef: String) { // update a progress indicator } /// Called once per successful upload func singleUploadSuccess(_ media: Media) { // do celebratory things } /// Called if the entire batch was considered to be uploaded successfully. func totalProcessSuccess() { // declare victory }

Cependant, étant donné que votre application contient des références multimédias parfois expirées, les responsables commerciaux ont décidé d'envoyer un message de « réussite » à l'utilisateur si au moins la moitié des téléchargements réussissent. C'est-à-dire que le processus concurrent doit déclarer la victoire - et appeler totalProcessSuccess - si moins de la moitié des tentatives de téléchargement échouent. C'est la spécification qui vous est remise en tant que développeur. Mais en tant que programmeur expérimenté, vous vous rendez compte qu'il y a plus d'exigences qui doivent être appliquées.

Bien sûr, Business souhaite que le téléchargement par lots se produise le plus rapidement possible, donc le téléchargement en série est hors de question. Les téléchargements doivent être effectués en parallèle.

Mais pas trop. Si vous async sans discernement l'ensemble du lot, les dizaines de téléchargements simultanés inonderont la carte réseau mobile (carte d'interface réseau) et les téléchargements se dérouleront en fait plus lentement qu'en série, pas plus rapidement.

Les connexions au réseau mobile ne sont pas considérées comme stables. Même les transactions courtes peuvent échouer uniquement en raison de modifications de la connectivité réseau. Afin de vraiment déclarer qu'un téléchargement a échoué, nous devrons réessayer le téléchargement au moins une fois.

La stratégie de nouvelle tentative ne doit pas inclure l'opération d'exportation car elle n'est pas sujette à des échecs transitoires.

Le processus d'exportation est lié au calcul et doit donc être effectué en dehors du thread principal.

Étant donné que l'exportation est liée au calcul, elle doit avoir un plus petit nombre d'instances simultanées que le reste du processus de téléchargement pour éviter de surcharger le processeur.

Les quatre fonctions de rappel décrites ci-dessus mettent toutes à jour l'interface utilisateur et doivent donc toutes être appelées sur le thread principal.

Media est un NSManagedObject , qui provient d'un NSManagedObjectContext et a ses propres exigences de threading qui doivent être respectées.

Cette spécification de problème semble-t-elle un peu obscure? Ne soyez pas surpris si vous trouvez des problèmes comme celui-ci qui se cachent dans votre avenir. J'en ai rencontré un comme celui-ci dans mon propre travail. Essayons d'abord de résoudre ce problème avec des outils traditionnels. Bouclez votre ceinture, ce ne sera pas joli.

 /// An enum describing specific problems that the algorithm might encounter. enum UploadingError : Error { case invalidResponse case tooManyFailures } /// A semaphore to prevent flooding the NIC let outerLimit = DispatchSemaphore(value: 4) /// A semaphore to prevent thrashing the processor let exportLimit = DispatchSemaphore(value: 1) /// The number of times to retry the upload if it fails let uploadRetries = 1 /// Dispatch group to keep track of when the entire process is finished let fullProcessDispatchGroup = DispatchGroup() /// How many of the uploads fully completed. var uploadSuccesses = 0 // this notify block is called when the full process has completed. fullProcessDispatchGroup.notify(queue: DispatchQueue.main) { let successRate = Float(uploadSuccesses) / Float(mediaReferences.count) if successRate > 0.5 { totalProcessSuccess() } else { errorHandler(UploadingError.tooManyFailures) } } // start in the background DispatchQueue.global().async { for mediaRef in mediaReferences { // alert the group that we're starting a process fullProcessDispatchGroup.enter() // wait until it's safe to start uploading outerLimit.wait() /// common cleanup operations needed later func finalizeMediaRef() { singleUploadCompletion(mediaRef) fullProcessDispatchGroup.leave() outerLimit.signal() } // wait until it's safe to start exporting exportLimit.wait() export(mediaRef) { (media, error) in // allow another export to begin exportLimit.signal() if let error = error { DispatchQueue.main.async { errorHandler(error) finalizeMediaRef() } } else { guard let media = media else { DispatchQueue.main.async { errorHandler(UploadingError.invalidResponse) finalizeMediaRef() } return } // the export was successful var uploadAttempts = 0 /// define the upload process and its retry behavior func doUpload() { // respect Media's threading requirements managedObjectContext.perform { upload(media) { error in if let error = error { if uploadAttempts < uploadRetries { uploadAttempts += 1 doUpload() // retry } else { DispatchQueue.main.async { // too many upload failures errorHandler(error) finalizeMediaRef() } } } else { DispatchQueue.main.async { uploadSuccesses += 1 singleUploadSuccess(media) finalizeMediaRef() } } } } } // kick off the first upload doUpload() } } } }

Waouh ! Sans commentaires, cela fait environ 75 lignes. Avez-vous suivi le raisonnement jusqu'au bout ? Comment vous sentiriez-vous si vous rencontriez ce monstre lors de votre première semaine dans un nouvel emploi ? Seriez-vous prêt à le maintenir, ou à le modifier ? Sauriez-vous s'il contenait des erreurs ? Contient-il des erreurs ?

Maintenant, considérez l'alternative HoneyBee :

 HoneyBee.start(on: DispatchQueue.main) .setErrorHandler(errorHandler) .insert(mediaReferences) .setBlockPerformer(DispatchQueue.global()) .each(limit: 4, acceptableFailure: .ratio(0.5)) { elem in elem.finally { link in link.setBlockPerformer(DispatchQueue.main) .chain(singleUploadCompletion) } .limit(1) { link in link.chain(export) } .setBlockPerformer(managedObjectContext) .retry(1) { link in link.chain(upload) // subject to transient failure } .setBlockPerformer(DispatchQueue.main) .chain(singleUploadSuccess) } .setBlockPerformer(DispatchQueue.main) .drop() .chain(totalProcessSuccess)

Comment cette forme vous frappe-t-elle ? Examinons-le pièce par pièce. Sur la première ligne, nous commençons la recette HoneyBee, en commençant par le fil principal. En commençant par le thread principal, nous nous assurons que toutes les erreurs seront transmises à errorHandler (ligne 2) sur le thread principal. La ligne 3 insère le tableau mediaReferences dans la chaîne de processus. Ensuite, nous passons à la file d'attente d'arrière-plan globale en vue d'un certain parallélisme. À la ligne 5, nous commençons une itération parallèle sur chacune des mediaReferences . Nous limitons ce parallélisme à un maximum de 4 opérations simultanées. Nous déclarons également que l'itération complète sera considérée comme réussie si au moins la moitié des sous-chaînes réussissent (ne faites pas d'erreur). La ligne 6 déclare un lien finally qui sera appelé si la sous-chaîne ci-dessous réussit ou échoue. Sur le lien finally , nous passons au thread principal (ligne 7) et appelons singleUploadCompletion (ligne 8). À la ligne 10, nous fixons une parallélisation maximale de 1 (exécution unique) autour de l'opération d'exportation (ligne 11). La ligne 13 bascule vers la file d'attente privée appartenant à notre instance managedObjectContext . La ligne 14 déclare une seule nouvelle tentative pour l'opération de téléchargement (ligne 15). La ligne 17 bascule à nouveau vers le thread principal et 18 invoque singleUploadSuccess . Au moment où la ligne de temps 20 serait exécutée, toutes les itérations parallèles sont terminées. Si moins de la moitié des itérations ont échoué, la ligne 20 passe une dernière fois à la file d'attente principale (rappelez-vous que chacune a été exécutée sur la file d'attente en arrière-plan), 21 supprime la valeur entrante (toujours mediaReferences ) et 22 invoque totalProcessSuccess .

Le formulaire HoneyBee est plus clair, plus propre et plus facile à lire, sans parler de sa maintenance. Qu'adviendrait-il de la forme longue de cet algorithme si la boucle était nécessaire pour réintégrer les objets Media dans un tableau comme une fonction map ? Après avoir effectué le changement, dans quelle mesure seriez-vous sûr que toutes les exigences de l'algorithme sont toujours satisfaites ? Dans le formulaire HoneyBee, ce changement consisterait à remplacer chacun par map pour utiliser une fonction de mappage parallèle. (Oui, il a aussi réduit.)

HoneyBee est une puissante bibliothèque de contrats à terme pour Swift qui rend l'écriture d'algorithmes asynchrones et simultanés plus facile, plus sûre et plus expressive. Dans cet article, nous avons vu comment HoneyBee peut rendre vos algorithmes plus faciles à maintenir, plus corrects et plus rapides. HoneyBee prend également en charge d'autres paradigmes asynchrones clés tels que la prise en charge des nouvelles tentatives, les gestionnaires d'erreurs multiples, la protection des ressources et le traitement des collections (formes asynchrones de mappage, de filtrage et de réduction). Pour une liste complète des fonctionnalités, consultez le site Web. Pour en savoir plus ou poser des questions, consultez les tout nouveaux forums communautaires.

Annexe : Garantir l'exactitude contractuelle des fonctions asynchrones

Assurer l'exactitude contractuelle des fonctions est un principe fondamental de l'informatique. À tel point que pratiquement tous les compilateurs modernes ont des contrôles pour s'assurer qu'une fonction qui déclare retourner une valeur, retourne exactement une fois. Retourner moins d'une fois ou plus d'une fois est traité comme une erreur et empêche de manière appropriée une compilation complète.

Mais cette assistance du compilateur ne s'applique généralement pas aux fonctions asynchrones. Prenons l'exemple (ludique) suivant :

 func generateIcecream(from int: Int, completion: (String) -> Void) { if int > 5 { if int < 20 { completion("Chocolate") } else if int < 10 { completion("Strawberry") } completion("Pistachio") } else if int < 2 { completion("Vanilla") } }

La fonction generateIcecream accepte un Int et renvoie de manière asynchrone une chaîne. Le compilateur rapide accepte volontiers la forme ci-dessus comme correcte, même si elle contient des problèmes évidents. Compte tenu de certaines entrées, cette fonction peut appeler l'achèvement zéro, une ou deux fois. Les programmeurs qui ont travaillé avec des fonctions asynchrones se souviendront souvent d'exemples de ce problème dans leur propre travail. Que pouvons-nous faire? Certes, nous pourrions refactoriser le code pour qu'il soit plus net (un commutateur avec des cas de plage fonctionnerait ici). Mais parfois, la complexité fonctionnelle est difficile à réduire. Ne serait-il pas préférable que le compilateur puisse nous aider à vérifier l'exactitude comme il le fait avec les fonctions qui reviennent régulièrement ?

Il s'avère qu'il y a un moyen. Observez l'incantation Swifty suivante :

 func generateIcecream(from int: Int, completion: (String) -> Void) { let finalResult: String defer { completion(finalResult) } let completion: Void = Void() defer { completion } if int > 5 { if int < 20 { completion("Chocolate") } else if int < 10 { completion("Strawberry") } // else completion("Pistachio") } else if int < 2 { completion("Vanilla") } }

Les quatre lignes insérées en haut de cette fonction obligent le compilateur à vérifier que le callback de complétion est invoqué exactement une fois, ce qui signifie que cette fonction ne compile plus. Que se passe-t-il? Dans la première ligne, nous déclarons mais n'initialisons pas le résultat que nous voulons finalement que cette fonction produise. En le laissant indéfini, nous nous assurons qu'il doit être affecté une fois avant de pouvoir être utilisé, et en le déclarant, nous nous assurons qu'il ne peut jamais être affecté deux fois. La deuxième ligne est un report qui s'exécutera comme action finale de cette fonction. Il appelle le bloc d'achèvement avec finalResult - après qu'il a été assigné par le reste de la fonction. La ligne 3 crée une nouvelle constante appelée achèvement qui masque le paramètre de rappel. La nouvelle complétion est de type Void qui ne déclare aucune API publique. Cette ligne garantit que toute utilisation de complètement après cette ligne sera une erreur du compilateur. Le report sur la ligne 2 est la seule utilisation autorisée du bloc d'achèvement. La ligne 4 supprime un avertissement du compilateur qui serait autrement présent à propos de la nouvelle constante de complétion inutilisée.

Nous avons donc réussi à forcer le compilateur Swift à signaler que cette fonction asynchrone ne remplit pas son contrat. Passons en revue les étapes pour le rendre correct. Tout d'abord, remplaçons tous les accès directs au rappel par une affectation à finalResult .

 func generateIcecream(from int: Int, completion: (String) -> Void) { let finalResult: String defer { completion(finalResult) } let completion: Void = Void() defer { completion } if int > 5 { if int < 20 { finalResult = "Chocolate" } else if int < 10 { finalResult = "Strawberry" } // else finalResult = "Pistachio" } else if int < 2 { finalResult = "Vanilla" } }

Maintenant, le compilateur signale deux problèmes :

 error: AsyncCorrectness.playground:1:8: error: constant 'finalResult' used before being initialized defer { completion(finalResult) } ^ error: AsyncCorrectness.playground:11:3: error: immutable value 'finalResult' may only be initialized once finalResult = "Pistachio"

Comme prévu, la fonction a un chemin où finalResult est assigné zéro fois et aussi un chemin où il est assigné plus d'une fois. Nous résolvons ces problèmes comme suit :

 func generateIcecream(from int: Int, completion: (String) -> Void) { let finalResult: String defer { completion(finalResult) } let completion: Void = Void() defer { completion } if int > 5 { if int < 20 { finalResult = "Chocolate" } else if int < 10 { finalResult = "Strawberry" } else { finalResult = "Pistachio" } } else if int < 2 { finalResult = "Vanilla" } else { finalResult = "Neapolitan" } }

La « pistache » a été déplacée vers une autre clause appropriée et nous nous rendons compte que nous n'avons pas couvert le cas général, qui est bien sûr « napolitain ».

Les modèles que nous venons de décrire peuvent facilement être ajustés pour renvoyer des valeurs facultatives, des erreurs facultatives ou des types complexes comme l'énumération Result courante. En forçant le compilateur à vérifier que les rappels sont invoqués exactement une fois, nous pouvons affirmer l'exactitude et l'exhaustivité des fonctions asynchrones.