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);
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);
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)
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:
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();
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 sistemaObserve 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:
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();
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 automaticamenteAgora 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?