乐趣区

关于node.js:不要在nodejs中阻塞event-loop

简介

咱们晓得 event loop 是 nodejs 中事件处理的根底,event loop 中次要运行的初始化和 callback 事件。除了 event loop 之外,nodejs 中还有 Worker Pool 用来解决一些耗时的操作,比方 I / O 操作。

nodejs 高效运行的秘诀就是应用异步 IO 从而能够应用大量的线程来解决大量的客户端申请。

而同时,因为应用了大量的线程,所以咱们在编写 nodejs 程序的时候,肯定要特地小心。

event loop 和 worker pool

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

如果这两种线程执行 callback 破费了太多的工夫,那么咱们就能够认为这两个线程被阻塞了。

线程阻塞第一方面会影响程序的性能,因为某些线程被阻塞,就会导致系统资源的占用。因为总的资源是无限的,这样就会导致解决其余业务的资源变少,从而影响程序的总体性能。

第二方面,如果常常会有线程阻塞的状况,很有可能被歹意攻击者发动 DOS 攻打,导致失常业务无奈进行。

nodejs 应用的是事件驱动的框架,Event Loop 次要用来解决为各种事件注册的 callback,同时也负责解决非阻塞的异步申请,比方网络 I /O。

而由 libuv 实现的 Worker Pool 次要对外裸露了提交 task 的 API,从而用来解决一些比拟低廉的 task 工作。这些工作包含 CPU 密集性操作和一些阻塞型 IO 操作。

而 nodejs 自身就有很多模块应用的是 Worker Pool。

比方 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 的博客

欢送关注我的公众号:「程序那些事」最艰深的解读,最粗浅的干货,最简洁的教程,泛滥你不晓得的小技巧等你来发现!

退出移动版