为逃逸气流而设计的建筑(https://pixelz.cc)软件应用程序运行在计算机的主内存中,我们称之为随机内存存取内存(RAM)。JavaScript,尤其是NodeJS(服务器端JS)允许我们为最终用户编写从小到大的软件项目。处理程序内存始终是一个棘手的问题,因为糟糕的实现可能会阻止在给定服务器或系统上运行的所有其他应用程序。C和C++程序员确实关心内存管理,因为代码的每个角落都可能潜伏着可怕的内存泄漏。但是对于JS开发者来说,你真的关心过这个问题吗?由于JS开发人员通常在专用的大容量服务器上进行Web服务器编程,因此他们可能不会意识到多任务延迟。比方说在开发Web服务器的情况下,我们还根据需要运行多个应用程序,例如数据库服务器(MySQL)、缓存服务器(Redis)等。我们需要注意它们也会消耗可用的主内存。如果我们随意编写应用程序,很可能会降低其他进程的性能,甚至完全拒绝为它们分配内存。在本文中,我们解决了一个问题,以了解NodeJS结构,如流、缓冲区和管道,以及它们如何支持编写内存高效的应用程序。我们使用NodeJSv8.12.0来运行这些程序,所有代码示例都放在这里:narenaryan/node-backpressure-internals原文链接:WritingmemoryefficientsoftwareapplicationsinNode.js问题:大文件复制如果有人被要求用NodeJS编写一个文件复制程序,然后他会很快写出如下代码:constfs=require('fs');letfileName=process.argv[2];letdestPath=process.argv[3];fs.readFile(fileName,(err,data)=>{if(err)throwerr;fs.writeFile(destPath||'output',data,(err)=>{if(err)throwerr;});console.log('新文件已创建!');});此代码仅获取输入文件名和路径,并在尝试读取文件后将其写入目标路径,这对于小文件来说不是问题。现在假设我们有一个大文件(大于4GB)需要用这个程序备份。以我的7.4G超高清4K电影为例。我使用上面的程序代码将它从当前目录复制到另一个目录。$nodebasic_copy.jscartoonMovie.mkv~/Documents/bigMovie.mkv然后我在Ubuntu(Linux)系统下得到这个错误:/home/shobarani/Workspace/basic_copy.js:7if(err)throwerr;^RangeError:FilesizeisgreaterthanpossibleBuffer:0x7fffffffbytesatFSReqWrap.readFileAfterStat[asoncomplete](fs.js:453:11)如您所见,由于NodeJS最多只允许将2GB的数据写入其缓冲区,因此在读取文件时会发生错误。要解决这个问题,当你在做I/O密集型操作(复制、处理、压缩等)时,最好考虑一下内存情况。NodeJS中的Streams和Buffers为了解决以上问题,我们需要一种将大文件切割成很多文件块的方法,我们需要一个数据结构来存储这些文件块。缓冲区是用于存储二进制数据的结构。接下来,我们需要一种读取和写入文件块的方法,而Streams提供了这种功能。Buffers(缓冲区)我们可以使用Buffer对象轻松地创建缓冲区。letbuffer=newBuffer(10);#10是buffer的体积console.log(buffer);#prints在NodeJS新版本(>8)中,也可以这样写。letbuffer=newBuffer.alloc(10);console.log(buffer);#prints如果我们已经有了一些数据,比如数组或者其他数据集,我们可以为它们创建一个缓冲区。letname='NodeJSDEV';letbuffer=Buffer.from(name);console.log(buffer)#printsBuffers有一些重要的方法,比如buffer.toString()和buffer.toJSON()存储的数据。我们不会直接为代码优化创建原始缓冲区。在处理流和网络套接字时,NodeJS和V8引擎已经在创建内部缓冲区(队列)时这样做了。Streams(流)简单来说,流就像NodeJS对象上的任何一扇门。在计算机网络中,入口是输入动作,出口是输出动作。我们将在下文中继续使用这些术语。流有四种类型:可读流(用于读取数据)可写流(用于写入数据)双工流(可用于读写)转换流(用于处理数据的自定义流)双工流,例如压缩,检查数据等)下面这句话可以很清楚的解释为什么我们要使用流。StreamAPI(尤其是stream.pipe()方法)的一个重要目标是将数据缓冲限制在可接受的水平,以便不同速度的源和目标不会阻塞可用内存。我们需要一些方法来完成工作而不会使系统不堪重负。这是我们在文章开头已经提到的。在上图中,我们有两种类型的流,可读流和可写流。.pipe()方法是连接可读流和可写流的一种非常基本的方法。如果你看不懂上图没关系,看完我们的例子,你可以回到图上,那时一切都将是理所当然的。管道是一种引人注目的机制,我们用下面的两个例子来说明它。解决方案一(简单的使用流复制文件)让我们设计一个解决方案来解决上一篇文章中的大文件复制问题。首先,我们创建两个流,然后执行接下来的几个步骤。从可读流中监听块将块写入可写流跟踪文件复制进度=require('fs');letfileName=process.argv[2];letdestPath=process.argv[3];constreadabale=fs.createReadStream(fileName);constwriteable=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('文件名无效!请传递正确的');}process.stdout.write(`File:${this.duplicate}isbeingcreated:`);readabale.on('data',(chunk)=>{letpercentageCopied=((chunk.length*this.counter)/this.fileSize)*100;process.stdout.clearLine();//clearcurrenttextprocess.stdout.cursorTo(0);process.stdout.write(`${Math.round(percentageCopied)}%`);writeable.write(chunk);this.counter+=1;});readabale.on('end',(e)=>{process.stdout.clearLine();//清除当前文本process.stdout.cursorTo(0);process.stdout.write("成功完成操作");return;});readabale.on('error',(e)=>{console.log("出现一些错误:",e);});writeable.on('finish',()=>{console.log("成功创建文件副本!");});});在这个程序中,我们接收到用户传入的两个文件路径(源文件和目标文件),然后创建两个流,用于将数据块从可读流传输到可写流,然后我们定义了一些变量来创建跟踪文件复制的进度,然后输出到控制台(这里是console)。同时我们还订阅了一些事件:data:读取一个数据块时触发end:可读流读取到一个数据块时触发error:读取数据块出错时触发运行这个程序,我们可以顺利完成大文件(这里7.4G)的复制任务。$timenodestreams_copy_basic.jscartoonMovie.mkv~/Documents/4kdemo.mkv但是我们通过任务管理器观察程序在运行过程中的内存状态,还是有问题。4.6GB?我们的程序在运行时消耗的内存在这里没有意义,很可能会卡死其他应用程序。发生了什么?如果仔细观察上图中的读写速率,就会发现一些端倪。磁盘读取:53.4MiB/s磁盘写入:14.8MiB/s这意味着生产者正在以更快的速度生产,而消费者无法跟上。为了保存读取的数据块,计算机将冗余数据存储在机器的RAM中。这就是RAM峰值的原因。上面的代码在我的机器上跑了3分16秒...17.16suser25.06ssystem21%cpu3:16.61total方案二(基于流的文件复制和自动反压)为了克服上面的问题,我们可以修改程序为自动调整磁盘读写速度。这种机制就是背压。我们不需要做太多,只需将可读流导入可写流,NodeJS会处理背压。让我们将这个程序命名为streams_copy_efficient.js/*Afilecopywithstreamsandpiping-Author:NarenArya*/conststream=require('stream');constfs=require('fs');letfileName=process.argv[2];letdestPath=process.argv[3];constreadabale=fs.createReadStream(fileName);constwriteable=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('Filenameisinvalid!pleasepasstheproperone');}process.stdout.write(`File:${this.duplicate}isbeingcreated:`);readabale.on('data',(chunk)=>{letpercentageCopied=((chunk.length*this.counter)/this.fileSize)*100;process.stdout.clearLine();//clearcurrenttextprocess.stdout.cursorTo(0);process.stdout.write(`${Math.round(percentageCopied)}%`);this.counter+=1;});readabale.pipe(writeable);//AutopilotON!//Incaseifwehaveaninterruptionwhilecopyingwriteable.on('unpipe',(e)=>{process.stdout.write("Copyhasfailed!");});});在本例中,我们将之前的数据块写操作替换为一行代码readabale.pipe(writeable);//AutopilotON!这里的管道是所有魔法发生的地方。它控制磁盘读写的速度,以免阻塞内存(RAM)。运行。$timenodestreams_copy_efficient.jscartoonMovie.mkv~/Documents/4kdemo.mkv我们复制了同一个大文件(7.4GB),让我们看看内存利用率。震惊!现在Node程序只占用61.9MiB的内存。如果您观察读写速率:磁盘读取:35.5MiB/s磁盘写入:35.5MiB/s在任何给定时间,由于背压,读写速率保持一致。更令人惊喜的是,这段优化后的程序代码比之前的代码快了13秒。12.13suser28.50ssystem22%cpu3:03.35total由于NodeJS流和管道,减少了98.68%的内存负载和更少的执行时间。这就是为什么管道是一个强大的存在。61.9MiB是可读流创建的缓冲区大小。我们还可以使用Readable流上的read方法为缓冲区块分配自定义大小。constreadabale=fs.createReadStream(fileName);readable.read(no_of_bytes_size);除了本地文件复制,该技术还可用于优化许多I/O操作:处理从Kafka到数据库的数据流,从文件系统数据流,动态压缩和写入磁盘等等...源代码(Git)您可以在我的存储库下找到所有示例,并在您自己的机器上进行测试。narenaryan/node-backpressure-internals结论我写这篇文章的动机主要是想表明即使NodeJS提供了良好的API,我们也可以在不经意间编写出性能不佳的代码。如果我们能更多地关注它内置的工具,我们就能更好地优化程序的运行方式。您可以在此处找到有关“背压”的更多信息:流中背压结束。