Skip to content

Overhead de création

Le terme "overhead de création" fait référence au coût temporel et aux ressources nécessaires pour initialiser une nouvelle instance de quelque chose, que ce soit un processus, un thread, une connexion à une base de données, etc. Dans le contexte de worker_threads dans Node.js, cela se réfère spécifiquement au coût associé à la création d'un nouveau thread.

Pourquoi il y a-t-il un overhead de création ?

  1. Initialisation de ressources : Lors de la création d'un nouveau thread, le système doit allouer de la mémoire pour la pile du thread, initialiser diverses structures de données nécessaires pour gérer le thread et éventuellement copier certaines données pour le nouveau thread.

  2. Sécurité : Le système doit également s'assurer que le nouveau thread a les bonnes permissions et qu'il est correctement isolé des autres threads.

  3. Planification : L'ajout d'un nouveau thread implique des ajustements dans la planification des threads par l'OS. Le système d'exploitation doit décider quand et comment le thread sera exécuté par rapport aux autres threads.

Pourquoi est-ce important ?

Le coût d'initialisation d'un worker peut ne pas être négligeable, surtout si le travail que le worker doit effectuer est de courte durée. Si vous créez fréquemment de nouveaux workers pour effectuer de petites tâches, l'overhead cumulé de création des workers peut surpasser les bénéfices que vous obtenez en parallélisant ces tâches.

Comment minimiser l'overhead de création ?

  1. Réutiliser les threads : Plutôt que de créer un nouveau thread pour chaque tâche, envisagez de créer un pool de threads que vous pouvez réutiliser pour plusieurs tâches. Cela peut aider à amortir le coût de création de threads sur plusieurs opérations.

  2. Créer des threads à l'avance : Si vous savez que vous aurez besoin de plusieurs threads pour effectuer des tâches, envisagez de les créer à l'avance (lors de l'initialisation de votre application, par exemple) plutôt que juste avant qu'une tâche ne soit nécessaire.

  3. Évaluez le coût par rapport aux bénéfices : Avant de décider de déplacer une opération vers un thread séparé, évaluez le coût de création du thread par rapport au temps d'exécution de l'opération. Si l'opération est très rapide, elle pourrait être plus efficace lorsqu'elle est exécutée dans le thread principal plutôt que dans un thread séparé.

En résumé, l'overhead de création est un coût associé à la mise en place et à l'initialisation de nouvelles ressources, telles que les threads. Dans le contexte de la programmation multithread, il est essentiel de comprendre cet overhead et de développer des stratégies pour le minimiser afin d'obtenir de meilleures performances.

Mauvais exemple

Prenons l'exemple d'une application Node.js qui effectue de nombreux calculs simples mais les exécute dans de nouveaux workers à chaque fois. C'est un exemple de ce qu'il ne faut pas faire car l'overhead de création des workers surpasserait le bénéfice de paralléliser les calculs.

Mauvais exemple

worker.js :

javascript
const { parentPort } = require('worker_threads');

parentPort.on('message', (data) => {
    parentPort.postMessage(data + 1);
});
const { parentPort } = require('worker_threads');

parentPort.on('message', (data) => {
    parentPort.postMessage(data + 1);
});

main.js :

javascript
const { Worker } = require('worker_threads');

const incrementInWorker = (number, callback) => {
    const worker = new Worker('./worker.js');
    worker.on('message', (result) => {
        callback(result);
        worker.terminate();
    });
    worker.postMessage(number);
};

const numbers = [...Array(1000).keys()];

numbers.forEach(number => {
    incrementInWorker(number, (result) => {
        console.log(result);
    });
});
const { Worker } = require('worker_threads');

const incrementInWorker = (number, callback) => {
    const worker = new Worker('./worker.js');
    worker.on('message', (result) => {
        callback(result);
        worker.terminate();
    });
    worker.postMessage(number);
};

const numbers = [...Array(1000).keys()];

numbers.forEach(number => {
    incrementInWorker(number, (result) => {
        console.log(result);
    });
});

Dans cet exemple, pour chaque nombre dans la liste, nous créons un nouveau worker, faisons le calcul et terminons ensuite le worker. Créer et détruire 1000 workers pour une opération aussi simple est extrêmement inefficace. L'overhead de création et de terminaison des workers serait bien plus long que le temps nécessaire pour simplement augmenter le nombre.

La meilleure approche serait soit d'effectuer ces calculs dans le thread principal (car ils sont simples), soit d'utiliser un pool de workers qui peuvent être réutilisés pour plusieurs calculs.

Bon exemple

Pour optimiser cela, je vais vous montrer deux approches :

  1. Effectuer les calculs dans le thread principal : Comme les opérations sont simples, il pourrait être plus efficace de simplement les exécuter directement dans le thread principal.
  2. Utiliser un pool de workers : Cette approche réutilise un ensemble de workers pour effectuer des tâches, réduisant l'overhead de création et de terminaison des workers.

1. Calculs dans le thread principal

C'est la solution la plus simple. Vous n'avez même pas besoin de workers pour cette tâche :

javascript
const numbers = [...Array(1000).keys()];

numbers.forEach(number => {
    console.log(number + 1);
});
const numbers = [...Array(1000).keys()];

numbers.forEach(number => {
    console.log(number + 1);
});

2. Utiliser un pool de workers

Pour cette solution, vous aurez besoin d'un gestionnaire de pool de workers ou d'une bibliothèque pour faciliter cela. Cependant, voici une version simplifiée de ce que cela pourrait ressembler :

main.js :

javascript
import { Worker } from 'worker_threads';

const numWorkers = 4;
const workers = [];
const numbers = [...Array(1000).keys()];
let currentIndex = 0;

for (let i = 0; i < numWorkers; i++) {
    const worker = new Worker('./worker.js');
    worker.on('message', (result) => {
        console.log(result);

        if (currentIndex < numbers.length) {
            worker.postMessage(numbers[currentIndex]);
            currentIndex++;
        }
    });

    workers.push(worker);
}

// Lancez les premières tâches
for (let i = 0; i < numWorkers && i < numbers.length; i++) {
    workers[i].postMessage(numbers[currentIndex]);
    currentIndex++;
}
import { Worker } from 'worker_threads';

const numWorkers = 4;
const workers = [];
const numbers = [...Array(1000).keys()];
let currentIndex = 0;

for (let i = 0; i < numWorkers; i++) {
    const worker = new Worker('./worker.js');
    worker.on('message', (result) => {
        console.log(result);

        if (currentIndex < numbers.length) {
            worker.postMessage(numbers[currentIndex]);
            currentIndex++;
        }
    });

    workers.push(worker);
}

// Lancez les premières tâches
for (let i = 0; i < numWorkers && i < numbers.length; i++) {
    workers[i].postMessage(numbers[currentIndex]);
    currentIndex++;
}

worker.js reste le même.

Avec cette approche, vous initialisez seulement 4 workers (ou tout autre nombre que vous jugerez approprié en fonction de vos ressources). Chaque fois qu'un worker a terminé sa tâche, il demande la suivante jusqu'à ce qu'il n'y ait plus de tâches à effectuer. Cela minimise l'overhead de création et de terminaison et optimise l'utilisation des ressources système.

Notez que gérer un pool de workers manuellement peut devenir complexe pour des scénarios plus élaborés. Dans ces cas, il peut être judicieux d'utiliser une bibliothèque qui fournit une gestion de pool de workers pour Node.js.