Manipulação eficiente de memória no Node.js

Os programas, no decorrer do trabalho, usam a memória de acesso aleatório dos computadores. Em JavaScript, no ambiente do Node.js, você pode escrever projetos de servidor de várias escalas. A organização do trabalho com memória é sempre uma tarefa difícil e responsável. Ao mesmo tempo, se em linguagens como C e C ++, os programadores estão bastante envolvidos no gerenciamento de memória, o JS possui mecanismos automáticos que, ao que parece, removem completamente a responsabilidade do trabalho eficiente com a memória do programador. No entanto, esse não é realmente o caso. O código mal escrito para o Node.js pode interferir na operação normal de todo o servidor no qual ele é executado.



O material, cuja tradução publicamos hoje, se concentrará no trabalho efetivo com memória no ambiente do Node.js. Em particular, conceitos como fluxos, buffers e o método de fluxo pipe() serão discutidos aqui. O Node.js v8.12.0 será usado nas experiências. Um repositório com código de exemplo pode ser encontrado aqui .

Tarefa: copiar um arquivo enorme


Se alguém for solicitado a criar um programa para copiar arquivos no Node.js., provavelmente ele escreverá imediatamente sobre o que é mostrado abaixo. basic_copy.js o arquivo que contém este código como 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!'); }); 

Este programa cria manipuladores para ler e gravar um arquivo com um determinado nome e tenta gravar dados do arquivo após a leitura. Para arquivos pequenos, essa abordagem está funcionando.

Suponha que nosso aplicativo precise copiar um arquivo enorme (consideraremos arquivos “grandes” maiores que 4 GB) durante o processo de backup de dados. Por exemplo, eu tenho um arquivo de vídeo de 7,4 GB de tamanho que, usando o programa descrito acima, tentarei copiar do meu diretório atual para o diretório Documents . Aqui está o comando para começar a copiar:

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

No Ubuntu, após executar este comando, uma mensagem de erro era exibida relacionada a um estouro de buffer:

 /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) 

Como você pode ver, a operação de leitura de arquivo falhou devido ao fato de o Node.js permitir que apenas 2 GB de dados sejam lidos no buffer. Como superar essa limitação? Ao executar operações que usam intensivamente o subsistema de E / S (copiando arquivos, processando, compactando-os), é necessário levar em consideração os recursos dos sistemas e as limitações associadas à memória.

Fluxos e buffers no Node.js


Para contornar o problema descrito acima, precisamos de um mecanismo com o qual possamos dividir grandes quantidades de dados em pequenos fragmentos. Também precisaremos de estruturas de dados para armazenar esses fragmentos e trabalhar com eles. Um buffer é uma estrutura de dados que permite armazenar dados binários. Em seguida, precisamos poder ler os dados do disco e gravá-los no disco. Essa oportunidade pode nos dar fluxos. Vamos falar sobre buffers e threads.

UffBuffers


Um buffer pode ser criado inicializando o objeto Buffer .

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

Nas versões do Node.js mais recentes que o dia 8, é melhor usar a seguinte construção para criar buffers:

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

Se já temos alguns dados, como uma matriz ou algo semelhante, um buffer pode ser criado com base nesses dados.

 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> 

Os buffers têm métodos que permitem “examiná-los” e descobrir quais dados existem - esses são os métodos toString() e toJSON() .

No processo de otimização do código, nós mesmos não criaremos buffers. O Node.js cria essas estruturas de dados automaticamente ao trabalhar com fluxos ou soquetes de rede.

▍ Fluxos


Os fluxos, se nos voltarmos para a linguagem da ficção científica, podem ser comparados com portais para outros mundos. Existem quatro tipos de fluxos:

  • Um fluxo para leitura (os dados podem ser lidos a partir dele).
  • Fluxo para gravação (os dados podem ser enviados para ele).
  • Fluxo duplex (está aberto para leitura de dados e envio de dados).
  • Transformação de fluxo (um fluxo duplex especial que permite processar dados, por exemplo, compactá-los ou verificar sua correção).

Precisamos de fluxos, porque o objetivo vital da API de fluxo no Node.js, e em particular o método stream.pipe() , é limitar o buffer de dados a níveis aceitáveis. Isso é feito para que o trabalho com fontes e receptores de dados que diferem em diferentes velocidades de processamento não sobrecarregue a memória disponível.

Em outras palavras, para resolver o problema de copiar um arquivo grande, precisamos de algum tipo de mecanismo que nos permita não sobrecarregar o sistema.


Fluxos e buffers (com base na documentação do Node.js.)

O diagrama anterior mostra dois tipos de fluxos - fluxos legíveis e fluxos graváveis. O método pipe() é um mecanismo muito simples que permite anexar threads para leitura em threads para gravação. Se o esquema acima não estiver muito claro para você, tudo bem. Depois de analisar os exemplos a seguir, você pode lidar facilmente com isso. Em particular, agora consideraremos exemplos de processamento de dados usando o método pipe() .

Solução 1. Copiando arquivos usando fluxos


Considere a solução para o problema de copiar um arquivo enorme, sobre o qual falamos acima. Esta solução pode ser baseada em dois threads e terá a seguinte aparência:

  • Esperamos que o próximo dado apareça no fluxo para leitura.
  • Escrevemos os dados recebidos no fluxo para gravação.
  • Monitoramos o progresso da operação de cópia.

Chamaremos o programa que implementa essa ideia de streams_copy_basic.js . Aqui está o código dela:

 /*         . : 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!");   });  }); 

Esperamos que o usuário execute este programa para fornecer dois nomes de arquivo. O primeiro é o arquivo de origem, o segundo é o nome de sua cópia futura. Criamos dois fluxos - um fluxo para leitura e um fluxo para escrita, transferindo partes de dados do primeiro para o segundo. Existem também alguns mecanismos auxiliares. Eles são usados ​​para monitorar o processo de cópia e enviar as informações correspondentes ao console.

Usamos o mecanismo de eventos aqui, em particular, estamos falando sobre a inscrição nos seguintes eventos:

  • data - chamados ao ler um dado.
  • end - chamado quando os dados são lidos do fluxo de leitura.
  • error - é chamado se ocorrer um erro durante a leitura dos dados.

Usando este programa, um arquivo de 7,4 GB é copiado sem mensagens de erro.

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

No entanto, há um problema. Ele pode ser identificado analisando dados sobre o uso dos recursos do sistema por vários processos.


Dados de uso de recursos do sistema

Observe que o processo do node , após copiar 88% do arquivo, ocupa 4,6 GB de memória. Isso é muito, esse manuseio de memória pode interferir no trabalho de outros programas.

▍ Motivos para consumo excessivo de memória


Preste atenção à velocidade de leitura de dados do disco e gravação de dados no disco da ilustração anterior (colunas Disk Read Disk Write e Disk Write ). Ou seja, aqui você pode ver os seguintes indicadores:

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

Essa diferença nas velocidades de leitura do registro de dados significa que a fonte de dados as produz muito mais rapidamente do que o receptor pode recebê-las e processá-las. O computador precisa armazenar na memória os fragmentos de dados lidos até serem gravados no disco. Como resultado, vemos esses indicadores de uso de memória.

No meu computador, este programa foi executado por 3 minutos e 16 segundos. Aqui estão as informações sobre o andamento de sua implementação:

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

Solução 2. Copiando arquivos usando fluxos e com ajuste automático da velocidade de leitura e gravação de dados


Para lidar com o problema acima, podemos modificar o programa para que, durante a cópia do arquivo, as velocidades de leitura e gravação sejam configuradas automaticamente. Esse mecanismo é chamado de contrapressão. Para usá-lo, não precisamos fazer nada de especial. É suficiente, usando o método pipe() , conectar o fluxo de leitura ao fluxo de gravação, e o Node.js ajustará automaticamente as velocidades de transferência de dados.

Chame este programa de streams_copy_efficient.js . Aqui está o código dela:

 /*          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); //  !  }); 

A principal diferença entre este programa e o anterior é que o código para copiar fragmentos de dados é substituído pela seguinte linha:

 readable.pipe(writeable); //  ! 

No coração de tudo o que acontece aqui está o método pipe() . Ele controla as velocidades de leitura e gravação, o que leva ao fato de que a memória não está mais sobrecarregada.

Execute o programa.

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

Estamos copiando o mesmo arquivo enorme. Agora vamos ver como o trabalho com memória e com o disco é exibido.


Usando pipe (), as velocidades de leitura e gravação são configuradas automaticamente

Agora vemos que o processo do node consome apenas 61,9 MB de memória. Se você observar os dados sobre o uso do disco, poderá ver o seguinte:

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

Graças ao mecanismo de contrapressão, as velocidades de leitura e gravação agora são sempre iguais entre si. Além disso, o novo programa é executado 13 segundos mais rápido que o antigo.

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

Usando o método pipe() , conseguimos reduzir o tempo de execução do programa e reduzir o consumo de memória em 98,68%.

Nesse caso, 61,9 MB é o tamanho do buffer criado pelo fluxo de leitura de dados. Nós também podemos definir esse tamanho, usando o método read() do fluxo para ler dados:

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

Aqui, copiamos o arquivo no sistema de arquivos local, no entanto, a mesma abordagem pode ser usada para otimizar muitas outras tarefas de entrada e saída de dados. Por exemplo, isso está funcionando com fluxos de dados, cuja origem é Kafka e o destinatário é o banco de dados. De acordo com o mesmo esquema, é possível organizar a leitura dos dados de um disco, compactando-os, como se costuma dizer, “on the fly” e gravando-os novamente no disco já em formato compactado. De fato, existem muitos outros usos para a tecnologia descrita aqui.

Sumário


Um dos objetivos deste artigo era demonstrar como é fácil escrever programas ruins no Node.js, mesmo que essa plataforma forneça ótimas APIs para o desenvolvedor. Com alguma atenção a esta API, você pode melhorar a qualidade dos projetos de software do lado do servidor.

Caros leitores! Como você trabalha com buffers e threads no Node.js?

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


All Articles