Gestion efficace de la mémoire dans Node.js

Les programmes, au cours du travail, utilisent la mémoire vive des ordinateurs. En JavaScript, dans l'environnement de Node.js, vous pouvez écrire des projets de serveur de différentes échelles. L'organisation du travail avec la mémoire est toujours une tâche difficile et responsable. Dans le même temps, si dans des langages tels que C et C ++, les programmeurs sont assez étroitement impliqués dans la gestion de la mémoire, JS dispose de mécanismes automatiques qui, il semblerait, enlèvent complètement la responsabilité du programmeur pour un travail efficace avec la mémoire. Cependant, ce n'est pas le cas. Un code mal écrit pour Node.js peut interférer avec le fonctionnement normal de l'ensemble du serveur sur lequel il s'exécute.



Le matériel, dont nous publions la traduction aujourd'hui, se concentrera sur le travail efficace avec la mémoire dans l'environnement de Node.js. En particulier, des concepts tels que les flux, les tampons et la méthode de flux pipe() seront discutés ici. Node.js v8.12.0 sera utilisé dans les expériences. Un référentiel avec un exemple de code peut être trouvé ici .

Tâche: copier un énorme fichier


Si quelqu'un est invité à créer un programme pour copier des fichiers dans Node.js, il est très probable qu'il écrira immédiatement sur ce qui est illustré ci-dessous. Nous basic_copy.js le fichier contenant ce code basic_copy.js .

 const fs = require('fs'); let fileName = process.argv[2]; let destPath = process.argv[3]; fs.readFile(fileName, (err, data) => {   if (err) throw err;   fs.writeFile(destPath || 'output', data, (err) => {       if (err) throw err;   });     console.log('New file has been created!'); }); 

Ce programme crée des gestionnaires pour lire et écrire un fichier avec un nom donné et essaie d'écrire des données de fichier après l'avoir lu. Pour les petits fichiers, cette approche fonctionne.

Supposons que notre application ait besoin de copier un énorme fichier (nous considérerons les «énormes» fichiers de plus de 4 Go) pendant le processus de sauvegarde des données. Par exemple, j'ai un fichier vidéo de 7,4 Go, que j'essaierai, en utilisant le programme décrit ci-dessus, de copier de mon répertoire actuel vers le répertoire Documents . Voici la commande pour commencer la copie:

 $ node basic_copy.js cartoonMovie.mkv ~/Documents/bigMovie.mkv 

Dans Ubuntu, après avoir exécuté cette commande, un message d'erreur a été affiché concernant un débordement de tampon:

 /home/shobarani/Workspace/basic_copy.js:7   if (err) throw err;            ^ RangeError: File size is greater than possible Buffer: 0x7fffffff bytes   at FSReqWrap.readFileAfterStat [as oncomplete] (fs.js:453:11) 

Comme vous pouvez le voir, l'opération de lecture de fichier a échoué en raison du fait que Node.js ne permet que 2 Go de données à lire dans le tampon. Comment surmonter cette limitation? Lors de l'exécution d'opérations utilisant intensivement le sous-système d'E / S (copie de fichiers, traitement, compression), il est nécessaire de prendre en compte les capacités des systèmes et les limitations associées à la mémoire.

Flux et tampons dans Node.js


Afin de contourner le problème décrit ci-dessus, nous avons besoin d'un mécanisme avec lequel nous pouvons diviser de grandes quantités de données en petits fragments. Nous aurons également besoin de structures de données pour stocker ces fragments et travailler avec eux. Un tampon est une structure de données qui vous permet de stocker des données binaires. Ensuite, nous devons être en mesure de lire des données sur le disque et de les écrire sur le disque. Cette opportunité peut nous donner des flux. Parlons des tampons et des threads.

▍Buffers


Un tampon peut être créé en initialisant l'objet Buffer .

 let buffer = new Buffer(10); // 10 -    console.log(buffer); //  <Buffer 00 00 00 00 00 00 00 00 00 00> 

Dans les versions de Node.js plus récentes que la 8e, il est préférable d'utiliser la construction suivante pour créer des tampons:

 let buffer = new Buffer.alloc(10); console.log(buffer); //  <Buffer 00 00 00 00 00 00 00 00 00 00> 

Si nous avons déjà des données, comme un tableau ou quelque chose de similaire, un tampon peut être créé sur la base de ces données.

 let name = 'Node JS DEV'; let buffer = Buffer.from(name); console.log(buffer) //  <Buffer 4e 6f 64 65 20 4a 53 20 44 45 5> 

Les tampons ont des méthodes qui vous permettent de les «examiner» et de découvrir quelles données se trouvent - ce sont les méthodes toString() et toJSON() .

Nous, dans le processus d'optimisation du code, ne créerons pas de tampons nous-mêmes. Node.js crée automatiquement ces structures de données lors de l'utilisation de flux ou de sockets réseau.

▍ Streams


Les flux, si nous nous tournons vers le langage de la science-fiction, peuvent être comparés à des portails vers d'autres mondes. Il existe quatre types de flux:

  • Un flux de lecture (les données peuvent en être lues).
  • Stream pour l'enregistrement (des données peuvent y être envoyées).
  • Flux duplex (il est ouvert pour y lire des données et pour lui envoyer des données).
  • Flux de transformation (un flux duplex spécial qui vous permet de traiter des données, par exemple, de les compresser ou de vérifier leur exactitude).

Nous avons besoin de flux car l'objectif essentiel de l'API de flux dans Node.js, et en particulier la méthode stream.pipe() , est de limiter la mise en mémoire tampon des données à des niveaux acceptables. Ceci est fait de sorte que travailler avec des sources et des récepteurs de données qui diffèrent par des vitesses de traitement différentes ne déborderait pas la mémoire disponible.

En d'autres termes, pour résoudre le problème de la copie d'un fichier volumineux, nous avons besoin d'une sorte de mécanisme qui nous permet de ne pas surcharger le système.


Flux et tampons (basés sur la documentation Node.js)

Le diagramme précédent montre deux types de flux: les flux lisibles et les flux inscriptibles. La méthode pipe() est un mécanisme très simple qui vous permet d'attacher des threads pour la lecture à des threads pour l'écriture. Si le schéma ci-dessus n'est pas particulièrement clair pour vous, alors ça va. Après avoir analysé les exemples suivants, vous pouvez facilement y faire face. En particulier, nous allons maintenant considérer des exemples de traitement de données utilisant la méthode pipe() .

Solution 1. Copie de fichiers à l'aide de flux


Considérez la solution au problème de la copie d'un gros fichier, dont nous avons parlé plus haut. Cette solution peut être basée sur deux threads et ressemblera à ceci:

  • Nous nous attendons à ce que la prochaine donnée apparaisse dans le flux pour lecture.
  • Nous écrivons les données reçues dans le flux pour l'enregistrement.
  • Nous surveillons la progression de l'opération de copie.

Nous appellerons le programme qui implémente cette idée streams_copy_basic.js . Voici son code:

 /*         . : Naren Arya */ const stream = require('stream'); const fs = require('fs'); let fileName = process.argv[2]; let destPath = process.argv[3]; const readable = fs.createReadStream(fileName); const writeable = fs.createWriteStream(destPath || "output"); fs.stat(fileName, (err, stats) => {   this.fileSize = stats.size;   this.counter = 1;   this.fileArray = fileName.split('.');     try {       this.duplicate = destPath + "/" + this.fileArray[0] + '_Copy.' + this.fileArray[1];   } catch(e) {       console.exception('File name is invalid! please pass the proper one');   }     process.stdout.write(`File: ${this.duplicate} is being created:`);     readable.on('data', (chunk)=> {       let percentageCopied = ((chunk.length * this.counter) / this.fileSize) * 100;       process.stdout.clearLine();  //          process.stdout.cursorTo(0);       process.stdout.write(`${Math.round(percentageCopied)}%`);       writeable.write(chunk);       this.counter += 1;   });     readable.on('end', (e) => {       process.stdout.clearLine();  //          process.stdout.cursorTo(0);       process.stdout.write("Successfully finished the operation");       return;   });     readable.on('error', (e) => {       console.log("Some error occurred: ", e);   });     writeable.on('finish', () => {       console.log("Successfully created the file copy!");   });  }); 

Nous attendons de l'utilisateur qu'il exécute ce programme pour lui fournir deux noms de fichier. Le premier est le fichier source, le second est le nom de sa future copie. Nous créons deux flux - un flux pour la lecture et un flux pour l'écriture, transférant des données du premier au second. Il existe également des mécanismes auxiliaires. Ils sont utilisés pour surveiller le processus de copie et pour sortir les informations correspondantes sur la console.

Nous utilisons le mécanisme d'événement ici, en particulier, nous parlons de souscrire aux événements suivants:

  • data - appelé lors de la lecture d'une donnée.
  • end - appelé lorsque les données sont lues dans le flux de lecture.
  • error - est appelé si une erreur se produit lors de la lecture des données.

À l'aide de ce programme, un fichier de 7,4 Go est copié sans message d'erreur.

 $ time node streams_copy_basic.js cartoonMovie.mkv ~/Documents/4kdemo.mkv 

Cependant, il y a un problème. Il peut être identifié en examinant les données sur l'utilisation des ressources système par divers processus.


Données d'utilisation des ressources système

Notez que le processus de node , après avoir copié 88% du fichier, occupe 4,6 Go de mémoire. C'est beaucoup, une telle gestion de la mémoire peut interférer avec le travail d'autres programmes.

▍ Raisons de la consommation excessive de mémoire


Faites attention à la vitesse de lecture des données du disque et d'écriture des données sur le disque de l'illustration précédente (colonnes Lecture de disque et Écriture de disque). À savoir, ici, vous pouvez voir les indicateurs suivants:

 Disk Read: 53.4 MiB/s Disk Write: 14.8 MiB/s 

Une telle différence dans les vitesses de lecture de l'enregistrement de données signifie que la source de données les produit beaucoup plus rapidement que le récepteur ne peut les recevoir et les traiter. L'ordinateur doit stocker en mémoire les fragments de données lus jusqu'à ce qu'ils soient écrits sur le disque. En conséquence, nous voyons de tels indicateurs d'utilisation de la mémoire.

Sur mon ordinateur, ce programme a fonctionné pendant 3 minutes 16 secondes. Voici des informations sur l'avancement de sa mise en œuvre:

 17.16s user 25.06s system 21% cpu 3:16.61 total 

Solution 2. Copie de fichiers à l'aide de flux et avec réglage automatique de la vitesse de lecture et d'écriture des données


Afin de faire face au problème ci-dessus, nous pouvons modifier le programme de sorte que lors de la copie de fichiers, les vitesses de lecture et d'écriture soient automatiquement configurées. Ce mécanisme est appelé contre-pression. Pour l'utiliser, nous n'avons rien à faire de spécial. Il suffit, en utilisant la méthode pipe() , de connecter le flux de lecture au flux d'écriture, et Node.js ajustera automatiquement les vitesses de transfert de données.

Appelez ce programme streams_copy_efficient.js . Voici son code:

 /*          pipe(). : Naren Arya */ const stream = require('stream'); const fs = require('fs'); let fileName = process.argv[2]; let destPath = process.argv[3]; const readable = fs.createReadStream(fileName); const writeable = fs.createWriteStream(destPath || "output"); fs.stat(fileName, (err, stats) => {   this.fileSize = stats.size;   this.counter = 1;   this.fileArray = fileName.split('.');     try {       this.duplicate = destPath + "/" + this.fileArray[0] + '_Copy.' + this.fileArray[1];   } catch(e) {       console.exception('File name is invalid! please pass the proper one');   }     process.stdout.write(`File: ${this.duplicate} is being created:`);     readable.on('data', (chunk) => {       let percentageCopied = ((chunk.length * this.counter) / this.fileSize) * 100;       process.stdout.clearLine();  //          process.stdout.cursorTo(0);       process.stdout.write(`${Math.round(percentageCopied)}%`);       this.counter += 1;   });   readable.on('error', (e) => {       console.log("Some error occurred: ", e);   });     writeable.on('finish', () => {       process.stdout.clearLine();  //          process.stdout.cursorTo(0);       process.stdout.write("Successfully created the file copy!");   });     readable.pipe(writeable); //  !  }); 

La principale différence entre ce programme et le précédent est que le code de copie des fragments de données est remplacé par la ligne suivante:

 readable.pipe(writeable); //  ! 

Au cœur de tout ce qui se passe ici se trouve la méthode pipe() . Il contrôle les vitesses de lecture et d'écriture, ce qui conduit au fait que la mémoire n'est plus surchargée.

Exécutez le programme.

 $ time node streams_copy_efficient.js cartoonMovie.mkv ~/Documents/4kdemo.mkv 

Nous copions le même énorme fichier. Voyons maintenant comment fonctionne la mémoire et le disque.


En utilisant pipe (), les vitesses de lecture et d'écriture sont automatiquement configurées

Nous voyons maintenant que le processus de node ne consomme que 61,9 Mo de mémoire. Si vous regardez les données sur l'utilisation du disque, vous pouvez voir ce qui suit:

 Disk Read: 35.5 MiB/s Disk Write: 35.5 MiB/s 

Grâce au mécanisme de contre-pression, les vitesses de lecture et d'écriture sont désormais toujours égales. De plus, le nouveau programme s'exécute 13 secondes plus vite que l'ancien.

 12.13s user 28.50s system 22% cpu 3:03.35 total 

En utilisant la méthode pipe() , nous avons pu réduire le temps d'exécution du programme et réduire la consommation de mémoire de 98,68%.

Dans ce cas, 61,9 Mo est la taille du tampon créé par le flux de lecture de données. Nous pouvons bien définir cette taille nous-mêmes, en utilisant la méthode read() du flux pour lire les données:

 const readable = fs.createReadStream(fileName); readable.read(no_of_bytes_size); 

Ici, nous avons copié le fichier dans le système de fichiers local, cependant, la même approche peut être utilisée pour optimiser de nombreuses autres tâches d'entrée-sortie de données. Par exemple, cela fonctionne avec des flux de données, dont la source est Kafka, et le récepteur est la base de données. Selon le même schéma, il est possible d'organiser la lecture des données d'un disque, de les compresser, comme on dit, «à la volée», et de les réécrire sur le disque déjà sous forme compressée. En fait, il existe de nombreuses autres utilisations de la technologie décrite ici.

Résumé


L'un des objectifs de cet article était de démontrer à quel point il est facile d'écrire de mauvais programmes sur Node.js, même si cette plate-forme fournit d'excellentes API pour le développeur. Avec une certaine attention à cette API, vous pouvez améliorer la qualité des projets logiciels côté serveur.

Chers lecteurs! Comment travaillez-vous avec les tampons et les threads dans Node.js?

Source: https://habr.com/ru/post/fr433408/


All Articles