JavaScript à mémoire statique avec pools d'objets

Introduction

Vous recevez un e-mail vous informant que votre jeu ou application Web ne fonctionne pas correctement après un certain temps. Vous parcourez votre code, mais ne voyez rien d'anormal, jusqu'à ce que vous ouvriez les outils de performances de la mémoire de Chrome et que vous voyiez ceci :

Un instantané de votre historique de souvenirs

L'un de vos collègues sourit, car il se rend compte que vous rencontrez un problème de performances lié à la mémoire.

Dans la vue du graphique de mémoire, ce motif en dents de scie est très révélateur d'un problème de performances potentiellement critique. À mesure que l'utilisation de la mémoire augmente, la zone du graphique s'agrandit également dans la capture de la chronologie. Lorsque le graphique chute soudainement, cela signifie que le récupérateur de mémoire s'est exécuté et a nettoyé vos objets de mémoire référencés.

Signification des dents de scie

Dans un graphique comme celui-ci, vous pouvez voir que de nombreux événements de récupération de mémoire se produisent, ce qui peut nuire aux performances de vos applications Web. Cet article explique comment contrôler l'utilisation de la mémoire afin de réduire l'impact sur les performances.

Coûts de récupération de mémoire et de performances

Le modèle de mémoire de JavaScript repose sur une technologie appelée récupérateur de mémoire. Dans de nombreux langages, le programmeur est directement responsable de l'allocation et de la libération de la mémoire du tas de mémoire du système. Cependant, un système de récupération de mémoire gère cette tâche pour le programmeur, ce qui signifie que les objets ne sont pas directement libérés de la mémoire lorsque le programmeur les déréférence, mais plutôt à un moment ultérieur, lorsque les heuristiques du récupérateur de mémoire décident qu'il serait bénéfique de le faire. Ce processus de décision nécessite que le récupérateur de mémoire effectue une analyse statistique sur les objets actifs et inactifs, ce qui prend un certain temps.

La récupération de mémoire est souvent présentée comme l'opposé de la gestion manuelle de la mémoire, qui nécessite que le programmeur spécifie les objets à désallouer et à renvoyer au système de mémoire.

Le processus par lequel un récupérateur de mémoire récupère de la mémoire n'est pas sans frais. Il réduit généralement vos performances disponibles en prenant un certain temps pour effectuer son travail. En outre, le système lui-même décide quand s'exécuter. Vous n'avez aucun contrôle sur cette action. Une impulsion de récupération de mémoire peut se produire à tout moment pendant l'exécution du code, ce qui bloque l'exécution du code jusqu'à ce qu'elle soit terminée. La durée de cette impulsion vous est généralement inconnue. Elle prendra un certain temps, en fonction de la façon dont votre programme utilise la mémoire à un moment donné.

Les applications hautes performances s'appuient sur des limites de performances cohérentes pour garantir une expérience fluide aux utilisateurs. Les systèmes de récupération de mémoire peuvent court-circuiter cet objectif, car ils peuvent s'exécuter à des moments aléatoires et pendant des durées aléatoires, ce qui réduit le temps disponible dont l'application a besoin pour atteindre ses objectifs de performances.

Réduire la saturation de la mémoire et les taxes de récupération de mémoire

Comme indiqué, une impulsion de récupération de mémoire se produit lorsqu'un ensemble d'heuristiques détermine qu'il y a suffisamment d'objets inactifs pour qu'une impulsion soit bénéfique. Par conséquent, pour réduire le temps que le récupérateur de mémoire prend à votre application, vous devez éliminer autant que possible les cas de création et de libération excessives d'objets. Ce processus de création/libération fréquente d'objets est appelé "saturation de la mémoire". Si vous parvenez à réduire la saturation de la mémoire pendant la durée de vie de votre application, vous réduirez également le temps que la récupération de mémoire prend sur votre exécution. Cela signifie que vous devez supprimer ou réduire le nombre d'objets créés et détruits. En d'autres termes, vous devez arrêter d'allouer de la mémoire.

Ce processus va déplacer votre graphique de mémoire de ceci :

Un instantané de votre historique de souvenirs

par celle-ci :

Static Memory Javascript

Dans ce modèle, vous pouvez voir que le graphique n'a plus un motif en dents de scie, mais qu'il augmente considérablement au début, puis lentement au fil du temps. Si vous rencontrez des problèmes de performances dus à une saturation de la mémoire, c'est le type de graphique que vous devez créer.

Passage au JavaScript à mémoire statique

La mémoire statique JavaScript est une technique qui consiste à préallouer, au démarrage de votre application, toute la mémoire qui sera nécessaire pendant sa durée de vie, et à gérer cette mémoire pendant l'exécution lorsque les objets ne sont plus nécessaires. Nous pouvons atteindre cet objectif en quelques étapes simples :

  1. Instrumentez votre application pour déterminer le nombre maximal d'objets de mémoire active requis (par type) pour une série de scénarios d'utilisation.
  2. Réimplémentez votre code pour préallouer cette quantité maximale, puis récupérez-les/libérez-les manuellement plutôt que d'accéder à la mémoire principale.

En réalité, pour accomplir le point 1, nous devons faire un peu du point 2. Commençons donc par là.

Pool d'objets

En termes simples, la mise en pool d'objets est le processus qui consiste à conserver un ensemble d'objets inutilisés qui partagent un type. Lorsque vous avez besoin d'un nouvel objet pour votre code, au lieu d'en allouer un nouveau à partir du tas de mémoire du système, vous recyclez l'un des objets inutilisés du pool. Une fois que le code externe a terminé d'utiliser l'objet, au lieu de le libérer dans la mémoire principale, il est renvoyé au pool. Comme l'objet n'est jamais déréférencé (c'est-à-dire supprimé) du code, il ne sera pas récupéré par le garbage collector. L'utilisation de pools d'objets permet au programmeur de reprendre le contrôle de la mémoire, ce qui réduit l'influence du récupérateur de mémoire sur les performances.

Étant donné qu'une application gère un ensemble hétérogène de types d'objets, l'utilisation appropriée des pools d'objets nécessite de disposer d'un pool par type d'objet qui subit un taux de désabonnement élevé pendant l'exécution de votre application.

var newEntity = gEntityObjectPool.allocate();
newEntity.pos = {x: 215, y: 88};

//..... do some stuff with the object that we need to do

gEntityObjectPool.free(newEntity); //free the object when we're done
newEntity = null; //free this object reference

Pour la grande majorité des applications, vous finirez par atteindre un certain niveau de stabilisation en termes de besoin d'allocation de nouveaux objets. En exécutant plusieurs fois votre application, vous devriez pouvoir vous faire une idée précise de cette limite supérieure et préallouer ce nombre d'objets au démarrage de votre application.

Pré-allocation d'objets

L'implémentation de la mise en commun d'objets dans votre projet vous donnera un maximum théorique pour le nombre d'objets requis pendant l'exécution de votre application. Une fois que vous avez exécuté votre site dans différents scénarios de test, vous pouvez vous faire une idée précise des types de mémoire requis, cataloguer ces données quelque part et les analyser pour comprendre les limites supérieures des besoins en mémoire de votre application.

Ensuite, dans la version de production de votre application, vous pouvez définir la phase d'initialisation pour préremplir tous les pools d'objets jusqu'à un certain montant. Cette action transfère l'initialisation de tous les objets au début de votre application et réduit le nombre d'allocations qui se produisent de manière dynamique lors de son exécution.

function init() {
  //preallocate all our pools. 
  //Note that we keep each pool homogeneous wrt object types
  gEntityObjectPool.preAllocate(256);
  gDomObjectPool.preAllocate(888);
}

La quantité que vous choisissez dépend en grande partie du comportement de votre application. Parfois, le maximum théorique n'est pas la meilleure option. Par exemple, choisir la moyenne maximale peut vous permettre de réduire l'espace mémoire utilisé pour les utilisateurs non expérimentés.

Loin d'être une solution miracle

Il existe toute une catégorie d'applications dans lesquelles les modèles de croissance de mémoire statique peuvent être un avantage. Cependant, comme le souligne Renato Mangini, un autre membre de l'équipe Chrome DevRel, il existe quelques inconvénients.

Conclusion

L'une des raisons pour lesquelles JavaScript est idéal pour le Web est qu'il s'agit d'un langage rapide, amusant et facile à prendre en main. Cela est principalement dû à ses restrictions de syntaxe peu contraignantes et à sa gestion des problèmes de mémoire à votre place. Vous pouvez coder et laisser Gemini s'occuper du travail fastidieux. Toutefois, pour les applications Web hautes performances, comme les jeux HTML5, le GC peut souvent réduire la fréquence d'images, ce qui nuit à l'expérience de l'utilisateur final. En instrumentant soigneusement et en adoptant des pools d'objets, vous pouvez réduire cette charge sur votre fréquence d'images et récupérer ce temps pour des choses plus intéressantes.

Code source

Il existe de nombreuses implémentations de pools d'objets sur le Web. Je ne vais donc pas vous ennuyer avec une autre. Je vais plutôt vous rediriger vers ces ressources, qui présentent chacune des nuances d'implémentation spécifiques. C'est important, car chaque utilisation d'application peut avoir des besoins d'implémentation spécifiques.

Références