写点什么

不要在 nodejs 中阻塞 event loop

发布于: 2021 年 02 月 02 日
不要在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 来进行匹配。/来和字符串 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 的博客

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


发布于: 2021 年 02 月 02 日阅读数: 14
用户头像

关注公众号:程序那些事,更多精彩等着你! 2020.06.07 加入

最通俗的解读,最深刻的干货,最简洁的教程,众多你不知道的小技巧,尽在公众号:程序那些事!

评论

发布
暂无评论
不要在nodejs中阻塞event loop