Explorer la copie sur écriture dans Swift
Actuellement, je travaille sur un projet d’application iOS basé sur TCA (The Composable Architecture). Mais je ne parlerai pas en détail de l’architecture. En bref, cette architecture fournit un moyen de diviser le système en petits composants indépendants qui peuvent être composés ensemble pour former un système plus grand et plus complexe. Cette architecture est inspirée de redux et très similaire à son fonctionnement, si vous ne la connaissez pas, vous pouvez en explorer davantage sur leur Page GitHub.
Cette architecture était vraiment amusante et elle correspond parfaitement au produit sur lequel je travaillais et bien sûr à SwiftUI. Mais un jour, avant que notre code soit terminé, nous avons fusionné tous nos PR, puis nous avons commencé à faire des tests de développement avant d’envoyer la version au QA. De mauvaises choses arrivent ! Notre fonctionnalité ne plante que lorsqu’elle est exécutée sur un appareil réel. L’erreur qui apparaît est Thread 1: EXC_BAD_ACCESS (code=2, address=0x16b6enff8)
, j’ai toujours mal à la tête si j’obtiens cette erreur car elle sera difficile à déboguer car elle est généralement liée à une erreur de mémoire et d’exécution. J’ai essayé plusieurs fois, peut-être que ce n’est qu’une erreur intermittente. Mais je me trompe ! ça arrive toujours. j’ai le vertige !
Après avoir lu plusieurs discussions sur divers forums de programmation, je suis éclairé. Il doit s’agir d’un débordement de pile car TCA utilise des structures pour l’état en tant que modèle de données, car SwiftUI utilise des types de valeur, car le changement de propriété entraîne la création d’une nouvelle instance de la structure que SwiftUI sous le capot peut détecter et déclencher la mise à jour de la vue.
Cependant, lorsque la valeur est une classe qui est un type de référence, la modification d’une propriété de la classe ne crée pas de nouvelle instance, donc SwiftUI n’est pas en mesure de détecter le changement et la vue n’est pas mise à jour. Si les données sont volumineuses et qu’il y a beaucoup de calculs en cours, cela provoquera certainement un plantage car elles sont stockées dans la mémoire de la pile et utilisent trop de cadres de pile. Cette théorie a beaucoup de sens car nos États sont si grands et ont beaucoup d’énumérations profondément imbriquées avec des valeurs associées.
Mais pourquoi le crash ne se produit-il que sur de vrais appareils ?
C’était aussi ma question à l’époque. Vous pouvez trouver les détails ici. En bref, le simulateur utilise une taille de pile différente de celle de l’appareil réel. D’après l’article, un appareil iOS n’a que 1 Mo d’espace de pile alors qu’un simulateur a plus de mémoire, mais je ne sais pas combien, s’il suit la taille de la pile de mémoire mac, il est d’environ 8 Mo. Pourquoi donc? seul Apple peut répondre.
En fait, Apple a déjà mentionné ce mécanisme sur leur GitHub, vous pouvez également lire d’autres conseils d’optimisation par les ingénieurs d’Apple ici. Mais si nous n’avons pas ce genre de problème, nous ne saurons peut-être même pas que la page GitHub existe.
Avec cet « incident », j’ai pris connaissance de ce mécanisme de Copy-on-Write et l’ai implémenté sur mon projet, il fonctionne parfaitement maintenant ! Fini les débordements de pile.
Il y a de la sagesse derrière tout ce qui arrive. Droite? 🙂
Sans plus tarder, approfondissons le sujet.
La copie sur écriture, également connue sous le nom de COW, est une technique d’optimisation des performances utilisée dans certains langages de programmation pour réduire la surcharge liée à la copie d’objets. Dans la copie et la mutation traditionnelles, un nouvel objet est créé et initialisé avec les valeurs d’un objet existant, et toute modification apportée au nouvel objet affecte également l’original. Cela peut être inefficace, en particulier lorsque vous travaillez avec des objets volumineux ou lorsque vous effectuez de nombreuses copies du même objet et que l’application s’exécute sur des appareils mobiles dont la mémoire est très limitée.
La copie sur écriture retarde plutôt la copie d’un objet jusqu’à ce qu’il soit réellement modifié. Cela signifie que, jusqu’à ce que l’objet soit modifié, plusieurs références au même objet peuvent toutes pointer vers les mêmes données sous-jacentes. Cela peut économiser de la mémoire et améliorer les performances puisque la copie n’est effectuée qu’en cas d’absolue nécessité.
Swift est un langage de programmation qui prend en charge à la fois la sémantique de valeur et la sémantique de référence. Cela signifie que, dans certains cas, les objets sont transmis par valeur et qu’une nouvelle copie est effectuée chaque fois qu’ils sont affectés ou transmis à une fonction. Dans d’autres cas, les objets sont transmis par des références, et plusieurs références peuvent pointer vers les mêmes données sous-jacentes.
Je crois que vous êtes tous déjà au courant de cela. Mais si vous ne le savez pas encore, vous pouvez lire les détails ici. En bref, principalement la sémantique de valeur est stockée dans la pile tandis que la sémantique de référence est stockée dans le tas. pourquoi je dis surtout? car c’est au cas par cas, s’il y a des données dans une classe avec un type de sémantique de valeur logiquement, ses données sont également stockées sur le tas car la classe dans swift est une sémantique de référence. Mais vous n’avez pas à vous en soucier.
De plus, la copie sur écriture est utilisée dans certains des types de collection intégrés de Swift, tels que les tableaux et les dictionnaires, pour optimiser leurs performances et l’utilisation de la mémoire. Mais malheureusement, ce n’est pas un comportement par défaut des types de valeur, vous devez créer une implémentation pour vos propres types de données. Mais ne vous inquiétez pas, j’en parlerai plus tard.
Ce mécanisme devient intéressant, mais avant de l’implémenter dans nos propres types de données, je vais donner une image plus claire de ce mécanisme que Swift utilise dans Array.
Comme je l’ai déjà mentionné ci-dessus, un tableau est un type valeur dans Swift. Mais pourquoi dans l’exemple ci-dessus tableau2 pointe vers la même adresse mémoire même si nous avons déjà copié la valeur dans une autre variable qui devrait avoir une adresse mémoire différente, pourquoi cela ressemble-t-il à un type référence ? Déroutant? Faisons quelque chose ensuite…
Maintenant, après avoir fait la mutation dans array2
en ajoutant un nouvel élément, l’adresse mémoire de array2
modifié. Oui, il suit la règle du type de valeur mais « un peu de retard », ce mécanisme s’appelle un « Copy-On-Write » où le compilateur alloue uniquement une nouvelle mémoire jusqu’à ce que l’objet change. Si nous ne mutons pas ou ne modifions pas l’objet, il ne fait que copier la référence.
Qu’en est-il de struct ou de notre propre type de données ? Oui, cela a également été mentionné ci-dessus, nous devons l’implémenter nous-mêmes 🙂
Pour prouver que par défaut le type valeur n’utilise pas le mécanisme CoW, nous pouvons le simuler de la même manière que ci-dessus
Comme vous pouvez le voir, lorsque nous copions une structure sans muter ni modifier l’original, elle finit par avoir une adresse mémoire différente dès qu’elle est copiée. Nous ne voulons pas que cela se produise, car nous voulons économiser l’utilisation de la mémoire et améliorer les performances. Si notre structure devient plus complexe et nécessite beaucoup de calculs, la copie prend du temps et coûte cher, ce qui a également un impact sur les performances de l’application. Nous voulons que notre application fonctionne efficacement en réduisant la consommation de ressources !
Mais maintenant, créons une implémentation CoW pour notre structure ou notre propre type de données. La façon la plus simple de l’implémenter est de composer avec les structures de données CoW existantes, mais je ne le ferai pas car Apple ne le recommande pas non plus. Nous allons créer un wrapper de valeur CoW à la place.
Pour y parvenir, nous pouvons simplement créer une classe et l’envelopper dans une sémantique de valeur dans ce cas une structure, et créer un setter-getter pour lire ou écrire une propriété de classe à partir de celle-ci et c’est fait. Nos données sont stockées dans un tas et pointent toujours vers la même adresse mémoire.
Bon, faisons ça !
Oui, tout fonctionne bien. Après avoir copié et écrit, il pointe toujours vers la même adresse mémoire. Mais si vous remarquez, ce n’est pas ce que nous voulons réaliser.
Mais changeons le scénario comme ceci :
Avez-vous remarqué quelque chose d’étrange ?
oui variable b
pointe toujours vers la même adresse mémoire que la variable a
. Notre implémentation actuelle ne fonctionne que pour les relations un à un, mais lorsque deux structures pointent vers le même stockage de sauvegarde, elles remplaceront également les autres données de structure, ce qui signifie que la modification d’une donnée de structure remplacera l’autre structure. Bien sûr, ce n’est pas ce que nous voulons. Nous souhaitons créer une copie distincte lorsqu’il y en a plusieurs qui détiennent la référence de stockage de sauvegarde et souhaitent effectuer une mutation. Mais comment y parvenir ?
Heureusement, Swift fournit une fonction isKnownUniquelyReferenced(_:)
qui peuvent nous aider à résoudre cette condition.
Renvoie une valeur booléenne indiquant si l’objet donné est connu pour avoir une seule référence forte.
En d’autres termes, il nous dira si nous sommes le seul à détenir la référence ou si quelqu’un d’autre détient également la référence.
Oui, cela ressemble exactement à ce dont nous avons besoin.
Modifions notre wrapper précédent et débarrassons-nous de cette situation.
Comme vous pouvez le voir après notre petite modification, cela fonctionne également parfaitement pour les relations un-à-plusieurs et c’est exactement ce que nous voulons, copier uniquement lorsque nous en avons besoin. S’il y a une seule référence, nous pouvons modifier la valeur immédiatement sans la copier et s’il y a plus d’une référence, il copiera d’abord la valeur, puis modifiera la valeur.
C’est exactement ce que Swift a implémenté pour String, Array, Dictionaries et de nombreux autres types de données rapides afin d’optimiser la mémoire et d’améliorer les performances.
Mais notre implémentation ci-dessus n’est pas très flexible car elle n’accepte que des entiers et la syntaxe n’est pas non plus très bonne car nous devons explicitement envelopper nos données dans une structure wrapper, qui serait plus passe-partout et non « lisible par l’homme ». Améliorons cela !
Comme vous pouvez le voir, nous utilisons un wrapper de propriété pour réduire le passe-partout et rendre notre code plus « lisible », nous n’acceptons également strictement que Equatable
types pour notre implémentation Copy-on-Write car dans TCA State doit être Equatable
. Vous pouvez ajuster selon vos besoins.
Vous trouverez peut-être que ce mécanisme est génial, mais est-il si important pour l’application ? il ne semble pas y avoir de raison de l’implémenter. Eh bien, il s’avère que ce mécanisme améliore les performances.
Dans la fonction Swift, les arguments sont passés par copie et non par référence. En d’autres termes, lorsque nous passons un type valeur dans une fonction, le compilateur duplique l’objet. À partir de là, il est clair que si nous passons le type de valeur à de nombreux endroits, cela fera beaucoup de duplication ou de copie et si l’objet est trop complexe et coûteux, bien sûr, le processus sera également long. Qu’en est-il de notre pile de mémoire ? Bien sûr, il sera plein plus tard et votre application plantera comme ce que j’ai vécu.
Lorsque vous décidez d’utiliser une classe ou une structure dans votre code, il est important de prendre en compte la sémantique de vos données, plutôt que de simplement prendre en compte les performances. Les structures peuvent offrir de meilleures performances lorsque vous travaillez avec des structures de données simples, tandis que les classes peuvent être plus performantes lorsque vous travaillez avec des structures plus complexes. Cependant, il convient de noter que vous pouvez utiliser la technique de « copie sur écriture » pour améliorer les performances des structures si nécessaire. Gardez à l’esprit que l’utilisation de la « copie sur écriture » peut augmenter la complexité de votre code, il est donc important de profiler soigneusement votre application pour vous assurer qu’elle conduit réellement à de meilleures performances. De plus, la « copie sur écriture » n’est pas seulement utile pour optimiser les performances ; il peut également être utilisé pour fournir une sémantique de valeur pour les entités de structure basées sur les classes.
Si vous avez des idées ou des questions sur le sujet abordé dans cet article, n’hésitez pas à laisser un commentaire ci-dessous. Vos commentaires et discussions sont toujours les bienvenus et nous aident à approfondir notre compréhension du sujet. S’il y a un sujet spécifique que vous aimeriez voir traité dans un futur article, n’hésitez pas à me le faire savoir dans les commentaires. Ensemble, nous pouvons continuer à apprendre et à explorer de nouvelles choses.