若是您编写比命令行脚本更复杂的程序,那么阅读本文能够帮助您编写性能更高,更安全的应用程序。javascript
在编写本文档时,主要是基于Node服务器。但里面的原则也适用于其它复杂的Node应用程序。在没有特别说明操做系统的状况下,默认为Linux。html
Node.js在事件循环(初始化和回调)中运行JavaScript代码,并提供工做池来处理成本比较高的任务,如文件I/O。 Node服务节点有很强的扩展能力,有时能提供比相对较重的Apache更好的解决方案。关键点就在于它使用少许线程来处理多客户端链接。若是Node可使用更少的线程,那么它能够将更多的系统时间和内存用于客户端,而不是为线程(内存,上下文切换)占用额外空间和时间。但也由于Node只有少许的线程,所以在构建应用程序时,必须明智地使用它们。java
这里有一些保持Node服务器快速稳健运行的经验法则: 当在任何给定时间与每一个客户端关联的工做“很小”时,Node服务会很快。node
这适用于事件循环上的回调和工做池上的任务。git
Node使用少许的线程来处理多个客户端链接。在Node中有两种类型的线程:github
k
工做池(也称为线程池)中的工做池若是一个线程须要很长时间来执行回调(Event Loop)或任务(Worker),咱们称之为“阻塞”。虽然线程为处理一个客户端链接而阻塞,但它没法处理来自任何其余客户端的请求。这提供了阻止事件循环和工做池的两个动机:web
Node使用事件驱动架构:它有一个事件循环用于调度 和 一个处理阻塞任务的工做池。正则表达式
在开始时,Node应用程序首先完成初始化阶段,即require模块和注册事件的回调。而后,Node应用程序进入事件循环,经过执行相应的回调来响应传入的客户端请求。此回调同步执行,并在完成后又有可能注册新的异步请求。这些新异步请求的回调也将在事件循环上执行。数据库
事件循环中还包含其它一些非阻塞异步请求(例如,网络I/O)产生的回调。express
总之,Event Loop执行这些注册为某些事件的JavaScript回调,而且还负责完成非阻塞异步请求,如网络I/O.
Node的线程池经过libuv
(docs)实现。libuv
暴露出一组任务提交的API。
Node使用线程池(Worker Pool)处理比较费时的任务。例操做系统没有提供非阻塞版本的I/O, CPU密集型任务等。
会用到线程池的Node模块:
I/O密集型
dns.lookup()
, dns.lookupService()
fs.FSWatcher()
和全部明确同步调用的文件API,剩下的都会用到libuv实现的线程池CPU密集型
crypto.pbkdf2()
, crypto.randomBytes()
, crypto.randomFill()
在大多数Node应用程序中,这些API是Worker Pool的惟一任务源。实际上,使用C++插件的应用程序和模块也能够提交任务给工做池。
为了完整起见,咱们注意到当从事件循环上的回调中调用上述其中一个API时,事件循环会花费一些较小的设置成本。由于须要进入该API相关的C++实现模块并将任务提交给工做池。与任务的总成本相比,这些成本能够忽略不计,这就是事件循环将它转接到C++模块的缘由。将这些任务之一提交给Worker Pool时,Node会在Node C++绑定中提供指向相应C++函数的指针。
理论上,Event Loop 和 Worker Pool 分别操做待处理的事件 和 待完成的任务。
实际上,Event Loop并不真正维护队列。相应的,它有一组文件描述符,这些文件描述符被操做系统使用epoll(Linux),kqueue(OSX),事件端口(Solaris)或IOCP(Windows)等机制进行监视。这些文件描述符对应于网络套接字,它正在观看的任何文件,等等。当操做系统说其中一个文件描述符准备就绪时,Event Loop会将其转换为相应的事件并调用与该事件关联的回调。您能够在此处详细了解此过程。
相反,Worker Pool使用一个真正的队列,队列中包含要处理的任务。Worker今后队列中出栈一个任务并对其进行处理,完成后,Worker会为事件循环引起“至少一个任务已完成”事件。
在像Apache这样的一个线程对应一个客户端链接的系统中,每一个挂起的客户端都被分配了本身的线程。若是处理一个客户端的线程阻塞时,操做系统会中断它并切换到另外一个处理客户端请求的线程。所以操做系统确保须要少许工做的客户不会受到须要更多工做的客户的影响。
由于Node用不多的线程数量处理许多客户端链接,若是一个线程处理一个客户端的请求时被阻塞,那么其它被挂起的客户端请求会一直得不到执行机会,直到该线程完成其回调或任务。 所以,保证客户端的链接都受到公平对待是你编写程序的工做内容。 这也就是说,在Node 程序中,不该该在任何单个回调或任务中为任何客户端作太多比较耗时的工做。
上面说的就是Node为何能够很好地扩展的部分缘由,但这也意味着开发者有责任确保公平的调度。接下来的部分将讨论如何确保事件循环和工做池的公平调度。
事件循环通知每一个新客户端链接并协调对客户端的响应。也就是说,全部传入请求和传出响应都经过事件循环处理。这意味着若是事件循环在任什么时候候花费的时间太长,全部当前的 以及新进来的客户端链接都不会得到响应机会。
因此,要确保在任什么时候候都不该该阻塞事件循环。换句话说,每个JavaScript回调应当可以快速完成。这固然也适用于你await
,Promise.then
等。
确保这一点的一个好方法是推断回调的“计算复杂度”。若是你的回调须要必定数量的步骤,不管它的参数是什么,老是会给每一个链接的客户段提供一个合理的响应。若是回调根据其参数采用不一样的步骤数,那么就应该考虑不一样参数可能致使的计算复杂度。
例子1: 恒定时间的回调
app.get('/constant-time', (req, res) => { res.sendStatus(200); });
例子2: 时间复杂度O(n)。回调运行时间与n成线性关系
app.get('/countToN', (req, res) => { let n = req.query.n; // n iterations before giving someone else a turn for (let i = 0; i < n; i++) { console.log(`Iter {$i}`); } res.sendStatus(200); });
例子3: 时间复杂度是O(n^2)的例子。当n比较小的时候,回调执行速度没有太大的影响,若是n比较大,相对O(n)而言,会特别的慢。并且n+1 对 n而言,执行时间也会增加不少。是指数级别的。
app.get('/countToN2', (req, res) => { let n = req.query.n; // n^2 iterations before giving someone else a turn for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) { console.log(`Iter ${i}.${j}`); } } res.sendStatus(200); });
Node使用Google V8引擎解析JavaScript,这对于许多常见操做来讲很是快。可是有例外:regexp和JSON操做。
对于复杂的任务,应该考虑限制输入长度并拒绝太长的输入。这样,即便回调具备很大的复杂度,经过限制输入,也能够确保回调执行时间不会超过最坏状况下的执行时间。而后,能够依据此评估回调的最坏状况成本,并肯定其上下文中的运行时间是否可接受。
一种比较常见的阻塞事件循环的方式是使用比较“脆弱”的正则表达式。
正则表达式(regexp)将输入字符串与特定的模式匹配。一般咱们认为正则表达式只须要匹配一次输入的字符串----时间复杂度是O(n),n是输入字符串的长度。在许多状况下,确实只须要一次即可完成匹配。但在某些状况下,正则表达式可能须要对传入的字符串进行屡次匹配----时间复杂度是O(2^n)。指数级增加意味着若是引擎须要x次回溯来肯定匹配,那么若是咱们在输入字符串中再添加一个字符,则至少须要2*x次回溯。因为回溯次数与所需时间成线性关系,所以这种状况会阻塞事件循环。
一个“脆弱”的正则表达式在你的正则匹配引擎上运行可能须要指数时间,致使你可能遭受REDOS(Regular expression Denial of Service - ReDoS)的“邪恶输入”。可是正则表达式模式是否易受攻击(即正则表达式引擎可能须要指数时间)其实是一个难以回答的问题,而且取决于您使用的是Perl,Python,Ruby,Java,JavaScript等。但有一些经验法则是适用于全部语言的:
若是您不肯定您的正则表达式是否容易受到攻击,但你须要明确的是即便易受攻击的正则表达式和长输入字符串,Node一般没法报告匹配项。当不匹配时, Node在尝试匹配的输入字符串的许多路径以前,是没法肯定是否会触发指数级的时间长度。
一个REDOS(Regular expression Denial of Service - ReDoS) 例子
如下是将其服务器暴露给REDOS的示例易受攻击的正则表达式:
app.get('/redos-me', (req, res) => { let filePath = req.query.filePath; // REDOS if (fileName.match(/(\/.+)+$/)) { console.log('valid path'); } else { console.log('invalid path'); } res.sendStatus(200); });
这个例子中易受攻击的正则表达式是一种(糟糕的)方法来检查Linux上的有效路径。它匹配以“/”做为分隔符的字符串,如“/a/b/c”。它很危险,由于它违反了规则1:它有一个双重嵌套的量词。
若是客户端使用filePath查询///.../n(100 / s后跟换行符“。”将不匹配的换行符),那么事件循环将永远有效,阻塞事件循环。此客户端的REDOS攻击致使全部其余客户端在regexp匹配完成以前不会响应。
所以,您应该谨慎使用复杂的正则表达式来验证用户输入。
有一些工具能够检查你的regexp是否安全,好比
可是,它们并不能保证识别全部易受攻击的正则表达式。
另外一种方法是使用不一样的正则表达式引擎。您可使用node-re2模块,该模块使用Google很是火热的RE2 regexp引擎。可是要注意,RE2与Node的regexp不是100%兼容,所以若是你使用node-re2模块来处理你的regexp,请检查回归。node-re2不支持特别复杂的regexp。
若是您正在尝试匹配一些特别常见的内容,例如URL或文件路径,请在regexp库中查找示例或使用npm模块,例如ip-regex。
Node里有一些核心模块,包含一些比较耗时的同步API:
这些模块中的一些API比较耗时,主要是由于须要大量的计算(encryption, compression),I/O操做(file I/O)或者二者都有(child process)。 这些API旨在方便编写脚本,可是在服务端也许并不适用。若是在事件循环中调用这些API,将会花费更多的时间,从而致使事件循环阻塞。
在服务端程序中,注意一下同步API的使用。
加密:
crypto.randomBytes
(同步版)crypto.randomFillSync
crypto.pbkdf2Sync
压缩:
zlib.inflateSync
zlib.deflateSync
文件系统
child process(子进程)
child_process.spawnSync
child_process.execSync
child_process.execFileSync
从Node V9开始,这个列表已经比较完善了。
JSON.parse
和 JSON.stringify
是另外两种比较耗时的操做。 尽管他们的时间复杂度是O(n),可是若是n比较大的话,也会花费至关多的操做时间。
若是你的服务程序操做对象主要是JSON,特别是这些JSON来自客户端,那么你须要特别注意JSON对象的大小 或者 字符串的长度。
JSON 阻塞示例:咱们建立一个大小为2 ^ 21 的obj对象,而后在字符串上JSON.stringify运行indexOf,而后运行JSON.parse。该JSON.stringify“d字符串为50MB。字符串化对象须要0.7秒,对50MB字符串的indexOf须要0.03秒,解析字符串须要1.3秒。
var obj = { a: 1 }; var niter = 20; var before, res, took; for (var i = 0; i < len; i++) { obj = { obj1: obj, obj2: obj }; // Doubles in size each iter } before = process.hrtime(); res = JSON.stringify(obj); took = process.hrtime(n); console.log('JSON.stringify took ' + took); before = process.hrtime(); res = str.indexOf('nomatch'); took = process.hrtime(n); console.log('Pure indexof took ' + took); before = process.hrtime(); res = JSON.parse(str); took = process.hrtime(n); console.log('JSON.parse took ' + took);
有一些npm模块提供异步JSON API。参见例如:
假设您想在JavaScript中执行复杂计算而不阻塞事件循环。您有两种选择:partitioning切割或offloading转嫁。
partitioning切割
您能够对计算进行分区,以便每一个计算都在事件循环上运行,但会按期产生(转向)其余待处理事件。在JavaScript中,很容易在闭包中保存正在进行的任务的状态,以下面的示例2所示。
举个简单的例子,假设你想要的数字的平均计算1到n。
示例1:未作分割的状况,平均成本 O(n):
for (let i = 0; i < n; i++) sum += i; let avg = sum / n; console.log('avg: ' + avg);
示例2:分割求平均值,每一个n异步步骤的成本O(1)。
function asyncAvg(n, avgCB) { // Save ongoing sum in JS closure. var sum = 0; function help(i, cb) { sum += i; if (i == n) { cb(sum); return; } // "Asynchronous recursion". // Schedule next operation asynchronously. setImmediate(help.bind(null, i+1, cb)); } // Start the helper, with CB to call avgCB. help(1, function(sum){ var avg = sum/n; avgCB(avg); }); } asyncAvg(n, function(avg){ console.log('avg of 1-n: ' + avg); });
您能够将此原则应用于数组迭代等。
offloading
若是您须要作一些更复杂的事情,partitioning也许不是一个好选择。这是由于partitioning仅借助于事件循环。而您几乎没法使用多核系统。 请记住,事件循环应该是调度客户端请求,而不是本身完成它们。 对于复杂的任务,可将工做的转嫁到工做池上。
How to offloading
对于要卸载工做的目标工做线池,您有两个选项。
node-webworker-threads
提供了一种访问Node的Worker Pool的JavaScript方法。offloading的缺点
offloading方法的缺点是它会产生通讯成本。只容许Event Loop查看应用程序的“namespace”(JavaScript状态)。从Worker中,您没法在Event Loop的命名空间中操做JavaScript对象。相反,您必须序列化和反序列化您但愿共享的任何对象。而后,Worker能够对它们本身的这些对象的副本进行操做,并将修改后的对象(或“补丁”)返回给事件循环。
有关序列化问题,请参阅有关JSON DOS的部分。
一些卸载的建议
您须要区分CPU密集型和I/O密集型任务,由于它们具备明显不一样的特征。
CPU密集型任务仅在调度其Worker时进行,而且必须将Worker调度到计算机的一个逻辑核心上。若是您有4个逻辑核心和5个工做线程,则其中一个工做线程会被挂起。因此,您须要为此Worker支付开销(内存和调度成本),而且没有得到任何回报。
I/O密集型任务涉及查询外部服务提供商(DNS,文件系统等)并等待其响应。虽然具备I/O密集型任务的Worker正在等待其响应,由于它没有任何其余事情可作从而被操做系统挂起。这就使另外一个Worker有机会提交其请求。所以,即便关联的线程未运行,I/O密集型任务也将取得进展。数据库和文件系统等外部服务提供商已通过高度优化,能够同时处理许多待处理的请求。例如,文件系统将检查大量待处理的写入和读取请求,以合并冲突的更新并以最佳顺序检索文件(详情能够参阅此处)。
若是您只依赖一个工做池,例如Node Worker Pool,那么CPU绑定和I/O绑定工做的不一样特性可能会损害您的应用程序的性能。
所以,您可能但愿维护一个单独的Computation Worker Pool。
offloadin结论
对于简单的任务,例如迭代任意长数组的元素,partitioning多是一个不错的选择。若是您的计算更复杂,则offloading是一种更好的方法。虽然会有通讯成本,但在事件循环和工做池之间传递序列化对象的开销,会被使用多个核心的好处所抵消。
可是,若是您的服务器在很大程度上依赖于复杂的计算,那么您应该考虑Node是否真的适合。Node擅长I/O操做相关的工做,但对于复杂的计算,它可能不是最好的选择。
若是您采用offloading方法,请参阅有关 不要阻塞工做池的部分。
Node有一个由k
Workers 组成的Worker Pool 。若是您使用上面讨论的Offloading
范例,您可能有一个单独的计算工做池适用上述原则。在任何一种状况下,咱们假设它k
比可能同时处理的客户端数量小得多。这与Node的“一个线程对应多个客户端”的理念保持一致,这是其具备高可扩展性的关键点。
如上所述,每一个Worker在继续执行Worker Pool队列中的下一个Task以前,会先完成当前Task。
如今,处理客户请求所需的任务成本会有所不一样。某些任务能够快速完成(例如,读取短文件或缓存文件,或产生少许随机字节);而其余任务则须要更长时间(例如,读取较大或未缓存的文件,或生成更多随机字节)。您的目标应该是最小化任务时间的变化,能够经过区分不一样任务分区来达成上述目标。
若是Worker的当前处理的任务比其余任务耗费资源比较多,那么它将没法用于其余待处理的任务。换句话说,每一个相对较长的任务会减少工做池的大小直到完成。这是不可取的,由于在某种程度上,工做者池中的工做者越多,工做者池吞吐量(任务/秒)就越大,所以服务器吞吐量(客户端请求/秒)就越大。耗时较长的任务将下降工做池的吞吐量,从而下降服务器的吞吐量。
为避免这种状况,您应该尽可能减小提交给工做池的任务长度的变化。虽然将I/O请求(DB,FS等)访问的外部系统视为黑盒是合适的,但您应该知道这些I/O请求的相对成本,而且应该避免提交可能耗时比较长的请求。
下面两个例子应该能够说明任务时间的可能变化。
时间变化示例一:长时间的文件读取
假设您的服务器必须读取文件以处理某些客户端请求。在咨询Node的文件系统 API以后,您选择使用fs.readFile()以简化操做。可是,fs.readFile()(当前)未分区:它提交fs.read()跨越整个文件的单个任务。若是您为某些用户阅读较短的文件而为其余用户阅读较长的文件,则fs.readFile()可能会致使任务长度的显着变化,从而损害工做人员池的吞吐量。
对于最坏的状况,假设攻击者可让服务器读取任意文件(这是一个目录遍历漏洞)。若是您的服务器运行Linux,攻击者能够命名一个很是慢的文件:/dev/random。出于全部实际目的,它/dev/random是无限慢的,而且每一个工做人员要求阅读/dev/random将永远不会完成该任务。而后k
工做池提交攻击者的请求。每一个工做一个请求,而且没有其余客户端请求使用工做池将取得进展。
时间变化示例二:长时间运行的加密操做时间变化示例
假设您的服务器使用生成加密安全随机字节crypto.randomBytes()。 crypto.randomBytes()未分区:它建立一个randomBytes()Task来生成所请求的字节数。若是为某些用户建立更少的字节,为其余用户建立更多字节,则crypto.randomBytes()是任务时间长度变化的另外一个来源。
具备可变时间成本的任务可能会损害工做池的吞吐量。为了尽可能减小任务时间的变化,您应尽量将每一个任务划分为时间可较少的子任务。当每一个子任务完成时,它应该提交下一个子任务,而且当最后的子任务完成时,它应该通知提交者。
继续说上面fs.readFile()的例子,您应该使用fs.read()(手动分区)或ReadStream(自动分区)。
一样的原则适用于CPU绑定任务; 该asyncAvg示例可能不适合事件循环,但它很是适合工做池。
将任务划分为子任务时,较短的任务会扩展为少许的子任务,较长的任务会扩展为更多的子任务。在较长任务的每一个子任务之间,分配给它的工做者能够处理另外一个较短的任务的子任务,从而提升工做池的总体任务吞吐量。
请注意,已完成的子任务数量对于工做线程池的吞吐量而言并非一个有用的度量标准。相反,最终完成任务的数量才是关注点。
回想一下,任务分区的目的是最小化任务时间的变化。若是您能够区分较短的任务和较长的任务(例如,对数组进行求和与对数组进行排序),则能够为每一个任务类建立一个工做池。将较短的任务和较长的任务路由到单独的工做池是另外一种最小化任务时间变化的方法。
之因此要支持这种方法,是由于切割的任务会产生额外开销(建立工做池任务表示和操做工做池队列的成本)。而且这样还能够避免没必要要的任务拆分,从而节省额外的访问工做池的成本。它还能够防止您在分区任务时出错。
这种方法的缺点是全部这些工做池中的worker都会产生空间和时间开销,而且会相互竞争CPU时间。请记住,每一个受CPU限制的任务仅在计划时才进行。所以,您应该在仔细分析后才考虑这种方法。
Worker Pool:结论
不管您是仅使用Node工做池仍是维护单独的工做池,您都应该优化池的任务吞吐量。
为此,请使用任务拆分 以最小化任务时间的变化。
虽然Node核心模块为各类应用程序提供了构建块,但有时须要更多的东西。Node开发人员从npm生态系统中获益匪浅,数十万个模块提供了加速开发过程的功能。
但请记住,大多数这些模块都是由第三方开发人员编写的,而且一般只发布尽力而为的保证。使用npm模块的开发人员应该关注两件事,尽管后者常常被遗忘。
对于简单的API,您能够估算API的成本, 例如字符串操做的成本并不难理解。但在许多状况下,很难搞清楚API可能会花费多少成本。
若是您正在调用可能会执行昂贵操做的API,请仔细检查成本。要求开发人员记录它,或者本身检查源代码(并提交记录成本的PR)。
请记住,即便API是异步的,您也不知道它可能花费多少时间在Worker或每一个分区的Event Loop上。例如,假设在asyncAvg上面给出的示例中,对助手函数的每次调用将一半的数字相加而不是其中一个。那么这个函数仍然是异步的,但每一个拆分的任务时间复杂度仍然是O(n),而不是O(1)。因此在使用任意值的n时,会使安全性下降不少。
Node有两种类型的线程:一个Event Loop
和k Workers
。Event Loop负责JavaScript回调和非阻塞I/O,而且Worker执行与完成异步请求的C++代码相对应的任务,包括阻止I/O和CPU密集型工做。两种类型的线程一次只能处理一个活动。若是任何回调或任务须要很长时间,则运行它的线程将被阻止。若是您的应用程序进行阻塞回调或任务,则可能致使吞吐量(客户端/秒)降级最多,而且最坏状况下会致使彻底拒绝服务。
要编写高吞吐量,更多防DoS的Web服务器,您必须确保在良性或恶意输入上,您的事件循环和工做者都不会被阻塞。