不要在nodejs中阻塞event loop

简介

咱们知道event loop是nodejs中事件处理的基础,event loop中主要运行的初始化和callback事件。除了event loop以外,nodejs中还有Worker Pool用来处理一些耗时的操做,好比I/O操做。node

nodejs高效运行的秘诀就是使用异步IO从而可使用少许的线程来处理大量的客户端请求。web

而同时,由于使用了少许的线程,因此咱们在编写nodejs程序的时候,必定要特别当心。正则表达式

event loop和worker pool

在nodejs中有两种类型的线程。第一类线程就是Event Loop也能够被称为主线程,第二类就是一个Worker Pool中的n个Workers线程。算法

若是这两种线程执行callback花费了太多的时间,那么咱们就能够认为这两个线程被阻塞了。json

线程阻塞第一方面会影响程序的性能,由于某些线程被阻塞,就会致使系统资源的占用。由于总的资源是有限的,这样就会致使处理其余业务的资源变少,从而影响程序的整体性能。服务器

第二方面,若是常常会有线程阻塞的状况,颇有可能被恶意攻击者发起DOS攻击,致使正常业务没法进行。网络

nodejs使用的是事件驱动的框架,Event Loop主要用来处理为各类事件注册的callback,同时也负责处理非阻塞的异步请求,好比网络I/O。app

而由libuv实现的Worker Pool主要对外暴露了提交task的API,从而用来处理一些比较昂贵的task任务。这些任务包括CPU密集性操做和一些阻塞型IO操做。框架

而nodejs自己就有不少模块使用的是Worker Pool。dom

好比IO密集型操做:

DNS模块中的dns.lookup(), dns.lookupService()。

和除了fs.FSWatcher()和 显式同步的文件系统的API以外,其余多有的File system模块都是使用的Worker Pool。

CPU密集型操做:

Crypto模块:crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(), crypto.generateKeyPair()。

Zlib模块:除了显示同步的API以外,其余的API都是用的是worker pool。

通常来讲使用Worker Pool的模块就是这些了,除此以外,你还可使用nodejs的C++ add-on来自行提交任务到Worker Pool。

event loop和worker pool中的queue

在以前的文件中,咱们讲到了event loop中使用queue来存储event的callback,实际上这种描述是不许确的。

event loop实际上维护的是一个文件描述符集合。这些文件描述符使用的是操做系统内核的 epoll (Linux), kqueue (OSX), event ports (Solaris), 或者 IOCP (Windows)来对事件进行监听。

当操做系统检测到事件准备好以后,event loop就会调用event所绑定的callback事件,最终执行callback。

相反的,worker pool就真的是保存了要执行的任务队列,这些任务队列中的任务由各个worker来执行。当执行完毕以后,Woker将会通知Event Loop该任务已经执行完毕。

阻塞event loop

由于nodejs中的线程有限,若是某个线程被阻塞,就可能会影响到整个应用程序的执行,因此咱们在程序设计的过程当中,必定要当心的考虑event loop和worker pool,避免阻塞他们。

event loop主要关注的是用户的链接和响应用户的请求,若是event loop被阻塞,那么用户的请求将会得不到及时响应。

由于event loop主要执行的是callback,因此,咱们的callback执行时间必定要短。

event loop的时间复杂度

时间复杂度通常用在判断一个算法的运行速度上,这里咱们也能够借助时间复杂度这个概念来分析一下event loop中的callback。

若是全部的callback中的时间复杂度都是一个常量的话,那么咱们能够保证全部的callback均可以很公平的被执行。

可是若是有些callback的时间复杂度是变化的,那么就须要咱们仔细考虑了。

app.get('/constant-time', (req, res) => {
  res.sendStatus(200);
});

先看一个常量时间复杂度的状况,上面的例子中咱们直接设置了respose的status,是一个常量时间操做。

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);
});

上面的例子是一个O(n)的时间复杂度,根据request中传入的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);
});

上面的例子是一个O(n^2)的时间复杂度。

这种状况应该怎么处理呢?首先咱们须要估算出系统可以承受的响应极限值,而且设定用户传入的参数极限值,若是用户传入的数据太长,超出了咱们的处理范围,则能够直接从用户输入端进行限制,从而保证咱们的程序的正常运行。

Event Loop中不推荐使用的Node.js核心模块

在nodejs中的核心模块中,有一些方法是同步的阻塞API,使用起来开销比较大,好比压缩,加密,同步IO,子进程等等。

这些API的目的是供咱们在REPL环境中使用的,咱们不该该直接在服务器端程序中使用他们。

有哪些不推荐在server端使用的API呢?

  • Encryption:

crypto.randomBytes (同步版本)
crypto.randomFillSync
crypto.pbkdf2Sync

  • Compression:

zlib.inflateSync
zlib.deflateSync

  • File system:

不要使用fs的同步API

  • Child process:

child_process.spawnSync
child_process.execSync
child_process.execFileSync

partitioning 或者 offloading

为了避免阻塞event loop,同时给其余event一些运行机会,咱们实际上有两种解决办法,那就是partitioning和offloading。

partitioning就是分而治之,把一个长的任务,分红几块,每次执行一块,同时给其余的event一些运行时间,从而再也不阻塞event loop。

举个例子:

for (let i = 0; i < n; i++)
  sum += i;
let avg = sum / n;
console.log('avg: ' + avg);

好比咱们要计算n个数的平均数。上面的例子中咱们的时间复杂度是O(n)。

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);
});

这里咱们用到了setImmediate,将sum的任务分解成一步一步的。虽然asyncAvg须要执行不少次,可是每一次的event loop均可以保证不被阻塞。

partitioning虽然逻辑简单,可是对于一些大型的计算任务来讲,并不合适。而且partitioning自己仍是运行在event loop中的,它并无享受到多核系统带来的优点。

这个时候咱们就须要将任务offloading到worker Pool中。

使用Worker Pool有两种方式,第一种就是使用nodejs自带的Worker Pool,咱们能够自行开发C++ addon或者node-webworker-threads。

第二种方式就是自行建立Worker Pool,咱们可使用Child Process 或者 Cluster来实现。

固然offloading也有缺点,它的最大缺点就是和Event Loop的交互损失。

V8引擎的限制

nodejs是运行在V8引擎上的,一般来讲V8引擎已经足够优秀足够快了,可是仍是存在两个例外,那就是正则表达式和JSON操做。

REDOS正则表达式DOS攻击

正则表达式有什么问题呢?正则表达式有一个悲观回溯的问题。

什么是悲观回溯呢?

咱们举个例子,假如你们对正则表达式已经很熟悉了。

假如咱们使用/^(x*)y$/ 来和字符串xxxxxxy来进行匹配。

匹配以后第一个分组(也就是括号里面的匹配值)是xxxxxx。

若是咱们把正则表达式改写为 /^(x*)xy$/ 再来和字符串xxxxxxy来进行匹配。 匹配的结果就是xxxxx。

这个过程是怎么样的呢?

首先(x)会尽量的匹配更多的x,知道遇到字符y。 这时候(x)已经匹配了6个x。

接着正则表达式继续执行(x)以后的xy,发现不能匹配,这时候(x)须要从已经匹配的6个x中,吐出一个x,而后从新执行正则表达式中的xy,发现可以匹配,正则表达式结束。

这个过程就是一个回溯的过程。

若是正则表达式写的很差,那么就有可能会出现悲观回溯。

仍是上面的例子,可是此次咱们用/^(x*)y$/ 来和字符串xxxxxx来进行匹配。

按照上面的流程,咱们知道正则表达式须要进行6次回溯,最后匹配失败。

考虑一些极端的状况,可能会致使回溯一个很是大的次数,从而致使CPU占用率飙升。

咱们称正则表达式的DOS攻击为REDOS。

举个nodejs中REDOS的例子:

app.get('/redos-me', (req, res) => {
  let filePath = req.query.filePath;

  // REDOS
  if (filePath.match(/(\/.+)+$/)) {
    console.log('valid path');
  }
  else {
    console.log('invalid path');
  }

  res.sendStatus(200);
});

上面的callback中,咱们本意是想匹配 /a/b/c这样的路径。可是若是用户输入filePath=///.../n,假若有100个/,最后跟着换行符。

那么将会致使正则表达式的悲观回溯。由于.表示的是匹配除换行符 n 以外的任何单字符。可是咱们只到最后才发现不可以匹配,因此产生了REDOS攻击。

如何避免REDOS攻击呢?

一方面有一些现成的正则表达式模块,咱们能够直接使用,好比safe-regex,rxxr2和node-re2等。

一方面能够到www.regexlib.com网站上查找要使用的正则表达式规则,这些规则是通过验证的,能够减小本身编写正则表达式的失误。

JSON DOS攻击

一般咱们会使用JSON.parse 和 JSON.stringify 这两个JSON经常使用的操做,可是这两个操做的时间是和输入的JSON长度相关的。

举个例子:

var obj = { a: 1 };
var niter = 20;

var before, str, pos, res, took;

for (var i = 0; i < niter; i++) {
  obj = { obj1: obj, obj2: obj }; // Doubles in size each iter
}

before = process.hrtime();
str = JSON.stringify(obj);
took = process.hrtime(before);
console.log('JSON.stringify took ' + took);

before = process.hrtime();
pos = str.indexOf('nomatch');
took = process.hrtime(before);
console.log('Pure indexof took ' + took);

before = process.hrtime();
res = JSON.parse(str);
took = process.hrtime(before);
console.log('JSON.parse took ' + took);

上面的例子中咱们对obj进行解析操做,固然这个obj比较简单,若是用户传入了一个超大的json文件,那么就会致使event loop的阻塞。

解决办法就是限制用户的输入长度。或者使用异步的JSON API:好比JSONStream和Big-Friendly JSON。

阻塞Worker Pool

nodejs的理念就是用最小的线程来处理最大的客户链接。上面咱们也讲过了要把复杂的操做放到Worker Pool中来借助线程池的优点来运行。

可是线程池中的线程个数也是有限的。若是某一个线程执行了一个long run task,那么就等于线程池中少了一个worker线程。

恶意攻击者其实是能够抓住系统的这个弱点,来实施DOS攻击。

因此对Worker Pool中long run task的最优解决办法就是partitioning。从而让全部的任务都有平等的执行机会。

固然,若是你能够很清楚的区分short task和long run task,那么咱们实际上能够分别构造不一样的worker Pool来分别为不一样的task任务类型服务。

总结

event loop和worker pool是nodejs中两种不一样的事件处理机制,咱们须要在程序中根据实际问题来选用。

本文做者:flydean程序那些事

本文连接:http://www.flydean.com/nodejs-block-eventloop/

本文来源:flydean的博客

欢迎关注个人公众号:「程序那些事」最通俗的解读,最深入的干货,最简洁的教程,众多你不知道的小技巧等你来发现!

相关文章
相关标签/搜索