DEV Community đŸ‘©â€đŸ’»đŸ‘šâ€đŸ’»

Cover image for Optimisez vos applications JS avec l'Object Pool Design Pattern !
Quentin Philippot
Quentin Philippot

Posted on • Updated on

Optimisez vos applications JS avec l'Object Pool Design Pattern !

Parfois méconnu des développeurs junior, l'object pool est un patron de conception fréquemment utilisés lorsqu'on manipule un grand nombre d'instances.

IndĂ©pendamment de votre langage de prĂ©dilection, vos chances de le rencontrer sont loin d'ĂȘtre dĂ©risoires. Que vous soyez dĂ©veloppeur Web, Mobile, ou que vous luttiez tous les jours contre un langage de bas niveau, ce design pattern s'adresse Ă  vous ! 😉

Qu'est-ce que l'Object Pool design pattern ?

Ce patron de conception repose sur la réutilisation massive d'instances. Le concept est simple : au lieu de laisser notre programme détruire nos objets lorsqu'ils ne sont plus utiles, on les place dans une réserve d'instance : la pool.

Ainsi, dĂšs que notre application aura Ă  nouveau besoin d'une instance de mĂȘme type, au lieu d'en crĂ©er une, il suffira simplement d'en piocher une dans notre pool. C'est tout.

Rappel sur la gestion mĂ©moire ⚙

Principe général

Tout au long de son exécution, un programme manipule toute sorte d'objet et de structure de données plus ou moins complexe. Pour créer une nouvelle instance, il effectue une allocation mémoire, c'est-à-dire qu'il réserve une certaine quantité de mémoire en RAM afin d'y stocker les informations relative à notre objet.

Lorsqu'une instance n'est plus nécessaire, le programme libÚre l'espace mémoire précédemment réservée, et détruit notre instance, c'est ce que l'on appelle la libération mémoire.

En pratique

Selon les langages, la gestion de la mémoire est une tùche plus ou moins aisée. Ceux qui bénéficient d'une expérience en C/C++ (ou autre langage bas niveau), connaissent les difficultés liées à la gestion de la mémoire. Jongler entre les pointeurs et les adresses mémoire n'est pas aussi amusant qu'on le voudrait.

Toutefois, la gestion de la mĂ©moire est d'un enjeu critique. Une mauvaise gestion de celle-ci peut entraĂźner des dĂ©sagrĂ©ments allant du simple crash, Ă  la faille de sĂ©curitĂ©, en passant par la perte de performance et une myriade de fuites-mĂ©moire. đŸ€Ż

C'est pourquoi les langages haut niveau (dont JavaScript fait parti), imposent généralement un systÚme limitant les possibilités du développeur en matiÚre d'allocation mémoire. Adieu malloc, adieu les pointeurs, le garbage collector gÚre désormais la libération mémoire pour nous. Ainsi nous pouvons concentrer tous nos efforts sur la logique propre à notre application, et non à son fonctionnement subsidiaire.

Enfin, il est toujours bon de rappeler que le garbage collector ne peut ni ĂȘtre invoquĂ© explicitement (comme j'ai parfois entendu certains dĂ©veloppeurs le supposer), ni ĂȘtre contrĂŽlĂ© d'une quelconque façon que ce soit. Au mieux, il est possible de diminuer son impact en gĂ©rant judicieusement le cycle de vie des instances. C'est justement sur ce point que nous allons jouer.

L'Object Pool Design Pattern et le Javascript

On peut s'interroger sur les bénéfices apportés par l'object pool. En effet, si le garbage collector s'occupe de l'allocation mémoire et de libération mémoire, ce n'est sont plus de notre ressort. Pourquoi s'encombrer avec un tel systÚme ?

Et puis : "Jusque là, mes applications ont toujours bien fonctionnés"

Certes.

Il faut garder Ă  l'esprit que l'allocation mĂ©moire et la libĂ©ration ne sont pas des opĂ©rations anodines. Elles peuvent ĂȘtre relativement coĂ»teuses en fonction du nombre et de la complexitĂ© des instances Ă  crĂ©er ou Ă  dĂ©truire.

Diminuer le coût de fonctionnement de notre application est possible en recyclant nos instances :

Au lieu de laisser le garbage collector les détruire, on conserve une référence de ces instances dans un pool d'instances. Ainsi, elles sont toujours considérées comme actives par le garbage collector, mais temporairement non utilisées au sein de notre programme.

✔ On aura Ă©conomisĂ© une libĂ©ration mĂ©moire.

Lorsqu'une instance du mĂȘme type sera requise, plutĂŽt d'en crĂ©er une nouvelle, on rĂ©cupĂšrera l'instance recyclĂ©e dans notre pool d'instance.

✔ On aura Ă©conomisĂ© une allocation mĂ©moire.

Mise en situation

Exercice : un monde de particules

Supposons que l'on développe le systÚme de particule suivant :

Des particules apparaissent sur un fond noir avec une position et une couleur aléatoire toutes les 200ms. Chaque particule vit approximativement 1000ms. Lorsqu'on déplacera la souris, un nuage de particule suivra le curseur. Pour donner une impression de crépitement, on déplacera les particules à chaque rendu sur des cases voisines.

objectif-pool

let particles = [];
const maxTtl = 50;

    class Particle {
        constructor(x, y, r, g, b) {
            this.initialize(x, y, r, g, b);
        }               

        initialize(x, y, r, g, b) {
            this.x = x || 0;
            this.y = y || 0;
            this.ttl = maxTtl;
            this.rgb = [
                r || 255, 
                g || 255, 
                b || 255 
            ];
        }

        live() {
            this.wiggle();
            this.ttl--;
        }

        /**
        * Retourne l'index de notre particule dans une matrice de pixels en fonction de sa position (x, y)
        */
        getOffset() {
            return (Math.ceil(this.y) * image.width + Math.ceil(this.x)) * 4;
        }

        /**
        * @image {ImageData} Matrice de pixels sur lesquels faire le rendu
        */
        draw(image) {
            const offset = this.getOffset();

            // 4 channels : r, g, b, a 
            image.data[offset] = this.rgb[0]; 
            image.data[offset + 1] = this.rgb[1];
            image.data[offset + 2] = this.rgb[2];
            image.data[offset + 3] = 255 * (this.ttl / maxTtl);
        }

        wiggle() {
            this.x += Math.random() * 4 - 2;
            this.y += Math.random() * 4 - 2;
       }

       isAlive() {
           return this.ttl > 0;
       }
}


Enter fullscreen mode Exit fullscreen mode

Et c'est tout pour le comportement d'une particule.

Concernant le systĂšme en lui-mĂȘme, on gĂ©rera l'apparition de particules grĂące Ă  un intervalle :

function clamp(value) {
    return Math.ceil(Math.max(Math.min(value, 255), 0));
}

function spread(x, y, r, g, b) {
    // On crée une particule à l'emplacement désiré
    particles.push(new Particle(x, y));

    // On ajoute un nuage de particules tout autour pour un meilleur rendu
    for(var i = 0; i < 10; i++) {
        particles.push(
            new Particle(
                x + Math.random() * 10 - 5, 
                y + Math.random() * 10 - 5,
                clamp(r + Math.random() * 10 - 5),
                clamp(g + Math.random() * 10 - 5),
                clamp(b + Math.random() * 10 - 5)
            )
        );
    }
}

// boucle gérant l'apparition aléatoire de particules
setInterval(function() {
    for (let i = 0; i < 1500; ++i) {
        spread(
            // position aléatoire
            Math.ceil(Math.random() * context.width),
            Math.ceil(Math.random() * context.height),

            // couleur aléatoire
            Math.ceil(Math.random() * 255),                        
            Math.ceil(Math.random() * 255),                        
            Math.ceil(Math.random() * 255)    
        );
    }                  
}, 200);

// boucle simulant la "vie" d'une particule
setInterval(function() {
    particles.forEach(function(particle) {
        particle.live();
    });
}, 20);
Enter fullscreen mode Exit fullscreen mode

Concernant la boucle d'animation, elle ne prĂ©sente pas un intĂ©rĂȘt majeur dans cet exemple. NĂ©anmoins, si vous ĂȘtes curieux :

function clearImage(image) {
    const nbSample = image.width * image.height;
    const data = image.data;
    for (let i = 0; i < nbSample; i++) {
        const offset = i * 4;
        data[offset] = 0;
        data[offset + 1] = 0;
        data[offset + 2] = 0;
        data[offset + 3] = 0;
    }
}

function animation() {
    let nbParticlesAlive = 0;

    clearImage(image);

    particles.forEach(function(particle) {
        particle.draw(image);

        if (particle.isAlive()) {
            nbParticlesAlive++;
        }
    });

    const nextParticles = new Array(nbParticlesAlive);
    let currentParticleIndex = 0;

    particles.forEach(function(particle) {
        if (particle.isAlive()) {
            nextParticles[currentParticleIndex] = particle;
            currentParticleIndex++;
        }
    });

    // La variable particles fait désormais référence à nextParticle
    // -> le garbage collector pourra supprimer l'ancien tableau (quand ça lui chantera)
    particles = nextParticles;
    context.putImageData(image, 0, 0);

    window.requestAnimationFrame(animation);
}

animation();
Enter fullscreen mode Exit fullscreen mode

Une fois que l'on a implémenté toutes ces méthodes, vient l'heure du test :

no-pool-1

En le testant, on s'aperçoit que notre systùme de particule fonctionne à merveille. Notre animation tourne à 60 FPS. 🏆

L'utilisation de requestAnimationFrame limitant notre fréquence maximale à environ 60 FPS, nous obtenons le meilleur résultat possible. Class.

AprÚs quelques secondes d'euphorie et d'auto-congratulation, on essaie de jouer avec notre script, on augmente le nombre de particules et on diminue leur durée de vie. Tout de suite, le résultat est moins flatteur.

no-pool-3

Le nombre de FPS s'effondre. La boucle d'animation est durement touchée, le nombre de wiggles par secondes a lui aussi quasiment été divisé par 2. Pourtant le cycle de vie de nos particules était indépendant de la boucle d'animation, et répondait à un intervalle, comment est-ce possible ?

Notre programme est tellement ralenti que le navigateur "repousse" leur exécution. Pourtant, la durée de vie de nos particules se basant sur un timestamp, une des conséquences directes de ce ralentissement est que les particules se déplaceront moins au cours de leur vie et formeront des sortes de pùtés multicolores.

Comment expliquer cette perte de performance ?

En augmentant le nombre d'instances affichées, on a également augmenté le nombre d'allocations mémoires, et donc la libération mémoire lorsque celles-ci meurent. En diminuant leur durée de vie, on laisse moins de temps au garbage collector pour libérer la mémoire, on augmente sa charge.

Un coup d'oeil sur l'analyseur de performance confortera notre hypothĂšse.

GB-no-pool

Implémentation de l'Object Pool design pattern

Puisque c'est ainsi, implémentons un pool de particules et voyons si le pattern tient sa promesse.

class ParticlesPool {
    constructor() {
        this.instances = [];
        this.index = -1;
    }

    getOne(x, y, r, g, b, born_at) {
        let instance = null;
        if (this.index >= 0) {
            instance = this.instances[this.index];
            instance.initialize(x, y, r, g, b, born_at);
            this.index--;
        }

        else {
            instance = new Particle(x, y, r, g, b, born_at);
        }

        return instance;
    }

    recycle(instance) {
        this.instances[this.index + 1] = instance;
        this.index++;
    }
}   


const pool = new ParticlesPool();
Enter fullscreen mode Exit fullscreen mode

Puis on adapte notre code pour l'utiliser. Les modifications seront simplissimes :

  • Remplacer tous les appels au constructeur de Particle par pool.getOne().
  • Ajouter un appel Ă  pool.recycle lorsqu'une particule meurt afin d'Ă©viter la fuite mĂ©moire.
// ...

function spread(x, y, r, g, b, born_at) {
    particles.push(pool.getOne(x, y, r, g, b, born_at));
    for(var i = 0; i < window.additionnalSpreadParticles; i++) {
        particles.push(
            pool.getOne(
               // ...
            )
        );
    }
}

 // ...

function animation() {

    // ...

    particles.forEach(function(particle) {
        if (particle.isAlive(currentTime)) {
            particle.draw(image);
            nbParticlesAlive++;
        }

        else {
            pool.recycle(particle);
        }
    });

    // ...
}

Enter fullscreen mode Exit fullscreen mode

Et c'est tout !

On relance notre application :

pool-23

On constate un gain de 10 FPS ! 🚀

Le nombre de wiggle est lui aussi plus élevé. Quant à la charge du garbage collector, celle-ci devient tout de suite plus acceptable.

after-pool

Analyse à postériori

On n'atteint pas encore les 60 FPS, certes. Mais, il ne faut pas oublier que le but fondamental de notre application est de faire une animation graphique ! En augmentant le nombre d'objets Ă  dessiner, notre boucle de rendu voit naturellement sa charge augmenter. L'object pool design pattern ne peux rien pour cela.

Des optimisations au niveau de la logique de rendu existent, et feront peut-ĂȘtre l'objet d'un autre article. Quant Ă  la gestion mĂ©moire, on peut encore l'amĂ©liorer, notamment lorsqu'on recalcule la liste des particules en vie.

Conclusion

L'implĂ©mentation d'un Object Pool design pattern peut avoir un effet bĂ©nĂ©fique sur les performances de votre application. En gĂ©rant judicieusement la mĂ©moire, vous pouvez augmenter le nombre de ressources manipulables par votre application. Dans notre exemple, augmenter le nombre de particules affichables simultanĂ©ment, l'a rendue plus rĂ©siliente. đŸ’Ș

Bon Ă  savoir

Correspondance avec les autres langages

Cet article / cours se focalise sur les avantages que peut avoir ce pattern pour le JavaScript. On n'y aborde pas du tout la problématique liée à la fragmentation de la mémoire, qui mérite au moins notre curiosité. Pour en apprendre plus à ce sujet, je vous invite à lire cet excellent article (C++/anglais).

Domaine d'application

Comme nous ne développons pas un systÚme à particules tous les jours, voici d'autres exemples d'utilisations :

  • Les jeux vidĂ©os : on instancie toute sorte d'objets Ă  durĂ©e de vie limitĂ©e.
  • Le traitement d'image et la 3D : pour tout ce qui est calcul, Ă©quation mathĂ©matique, gestion des ressources.
  • CouplĂ© Ă  un Singleton, on le retrouve rĂ©guliĂšrement comme un service gĂ©rant les connections Ă  une couche tierce, cache, connexions base de donnĂ©es, pool de workers, etc.

Ce pattern est particuliÚrement adapté quand :

  • Vous devez instancier un grand nombre d'objets complexes.
  • La durĂ©e de vie de ses objets est courte.
  • Le nombre d'objets requis simultanĂ©ment est faible.

Le mot de la fin

Voici qui clĂŽt mon premier article (qui prend un peu des aspects de cours) ! đŸ€“

Je sais qu'il est un peu long, le sujet est tellement dense qu'il mérite bien toutes ces lignes. N'hésitez pas à me faire remonter vos remarques afin d'améliorer la qualité de mes articles !

D'ailleurs, si vous avez dĂ©jĂ  rencontrĂ©s ce design pattern, ou qu'il vous a sauvĂ© la vie, je vous encourage Ă  partager votre expĂ©rience dans les commentaires. 😃

Top comments (4)

Collapse
 
daviddalbusco profile image
David Dal Busco

D'un certain point de vue, se reposer sur le garbage collector c'est aussi souvent s'Ă©viter les emm***** et autres "memory leak surprise" 😉

Cool ton article et tes dĂ©mos sont vraiment parlantes đŸ€©

Bookmarké, faudra que je prenne le temps de relire tout ça plus en détail.

Merci pour l'article!

Collapse
 
qphilippot profile image
Quentin Philippot

Je suis entiùrement d'accord. 🙂

D'ailleurs lorsqu'on utilise le l'Object Pool design pattern, on prend le risque de créer des fuites mémoires si on ne pense pas a recycler ses instances (les remettre dans la pool). C'est un mode de pensée qui n'est pas trÚs naturel en js, et donc facile à oublier.

Personnellement, j'ajoute toujours un compteur d'instances quand je suis en mode dev. Si la capacitĂ© de la pool augmente et que le nombre d'instances en cours d'utilisation ne descend pas, c'est que j'ai oubliĂ© un pool.recycle quelque part 😅

Collapse
 
daviddalbusco profile image
David Dal Busco

Ah cool astuce ça pour le développement, bien vu!

Encore merci pour l'article.

Collapse
 
jbaptisteq profile image
jean baptiste

Etant débutant j'ai appris pas mal en lisant cet article.

Merci Quentin P. :)

18 Useful Github Repositories Every Developer Should Bookmark

18 Useful GitHub repositories every developer should bookmark: everything from learning resources and roadmaps to best practices, system designs, and tools.