Node.js.不要堵塞线程(一)

总结

Node.js在消息循环中执行js代码、初始化和回调,并提供工作线程池执行I/O之类的重量任务。Node有良好的伸缩性,有些时候比更重的比如apache之类的方案更好。Node伸缩性的密码是使用一小部分线程处理更多的客户端。如果Node可以用更少的线程工作,那么他可以在花费更多系统时间和内存在客户端上工作而不是线程的开销(内存,上下文切换)。但是因为Node只有很少的线程,你必须好好利用他们来构建应用。
当每个客户端任何时间相关的工作很小时,Node是很快的。
这适用于消息循环上的回调方法和工作线程池上的任务。

为什么我应该避免堵塞?

Node使用很少的线程处理客户端。在Node中有2类线程:一个消息循环(又叫主循环,主线程,消息线程),k个工作者的工作池(又叫线程池)
如果线程使用太长时间执行回调(消息循环)或者任务(工作池),称为堵塞。如果一个线程在一个客户端上堵塞,他将无法处理其他客户端的请求。这提供2个不要堵塞消息循环和工作线程池的动机:

  1. 性能:如果你在任一类型线程上执行重量级任务,吞吐量将会很糟糕
  2. 安全:如果某一个输入会导致你的线程堵塞,恶意的客户端可能会故意提交这类输入让你的线程堵塞,使他们无法为其他客户端工作。这是DOS攻击。

Node review

Node使用消息驱动的架构:用于编排的消息循环和处理重量任务的工作池。

什么代码跑在消息循环?

开始时,Node首先完成初始化阶段,require模块并为消息注册回调。应用后续进入消息循环,通过执行适当的回调处理客户端请求。回调同步执行,可能在完成后注册异步请求继续处理。这些异步请求的回调也将在消息循环上执行。
消息循环也执行回调发出的非堵塞异步请求,比如网络IO。
总之,消息循环执行消息注册的回调,也负责执行非堵塞异步请求比如网络IO

什么代码跑在工作池?

Node工作池使用libuv实现,libuv提供了常见的任务提交api。
Node使用工作池处理重量任务。包括操作系统未提供非堵塞版本的IO,还有CPU密集型任务。
这些是使用工作池的Node模块api。
1.IO密集

  1. DNS:dns.lookup()
  2. .File System:所有文件系统api除了fs.FSWatcher(),和显式同步使用libuv线程池的api

2.CPU密集

  1. Crypto:crypto.pdkdf2(),crypto.scrypt()
  2. Zlib:除了显式使用同步libuv线程池的所有api

在一些Node应用中,这些api是工作池任务唯一来源。应用可以用C++ add-on提交其他任务给工作池。
当你在回调中调用这些api时,消息循环为api输入Node C++绑定并提交任务给工作池需要一点开销。这些开销比起任务的开销不算什么,因此消息循环卸载他们。当提交这些任务到工作池,Node在Node C++绑定中提供对应的C++方法指针

Node怎么决定接下来跑哪些代码?

抽象来说,消息循环和线程池为等待的消息和任务维护了队列。
真实情况,消息循环没有维护队列。他有文件描述符的集合,用他来要求操作系统监控,使用类似epoll(Linux)的机制。这些文件描述符对应网络套接字,文件。当操作系统说其中一个文件描述符准备好了,消息循环将他翻译成合适的消息并调用消息相关的回调。More https://www.youtube.com/watch?v=P9csgxBgaZ8。
相反,工作池使用真正的队列,实体是将要处理的任务。工作池pop一个任务并处理,当完成时工人为消息循环产生一个完成任务的消息。

这对应用设计意味着什么?

在每个客户端一个线程的系统比如apache,每个等待的客户端有他自己的线程,如果线程堵塞,操作系统中断线程提供给另一个客户端。操作系统因此保证需要少量工作的客户端不受需要大量工作的客户端的影响。
因为Node使用很少线程处理更多客户端,如果一个线程堵塞在处理一个客户端请求,那么直到线程完成他的回调或任务,等待的客户端请求无法得到CPU时间片。公平对待客户端是你的应用的职责这意味着你不应该给任何客户端在一个单一的回调/任务中做太多工作。
这是为什么Node良好伸缩的一部分,但他意味着你有责任保证公平调度。下一节讨论怎样保证工作调度。

不要堵塞消息循环

消息循环发现每一个新的客户端连接并协调响应的生成。所有新来的请求和出去的响应都经过消息循环。这意味着如果消息循环在任何点花费太长时间,所有当前新客户端都无法得到时间片。
你应该保证永远不堵塞消息循环。换句话说,任何js回调都应该快速完成。这同样适用于await和Promise.then等等。
一个保证这个的好方法是计算回调的时间复杂度(https://en.wikipedia.org/wiki/Time_complexity)。如果你的回调使用固定数目步骤无论参数如何,那你将给所有等待的客户端公平轮转。如果你的回调步骤数量依赖于参数,那你应该考虑参数可能多长。
Example 1: A constant-time callback.

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

Example 2: An O(n) callback. This callback will run quickly for small n and more slowly for large 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);
});

Example 3: An O(n^2) callback. This callback will still run quickly for small n, but for large n it will run much more slowly than the previous O(n) example.

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,对很多平常操作都很快。特例是正则和JSON操作。
然而,对复杂的操作你应该考虑限制输入并拒绝太长的输入。即使你的回调有很高的复杂度,通过限制输入你可以保证回调不会超过可接受最长输入最坏情况。你可以评估回调最坏情况开销,决定在你的上下文他的运行时间是否可接受。

堵塞消息循环:REDOS

一个常见的堵塞消息循环的路子是使用易受攻击的正则。

避免易受攻击的正则

正则使用一个pattern匹配一个输入字符串。我们通常认为正则匹配需要一次遍历字符串——O(n)时间。在很多情况,一次遍历确实是所有他需要的。然而,在一些情况正则匹配可能需要O(2^n)
脆弱的正则表达式是,正则表达式使用指数时间的,在恶意输入上会产生RE DOS。是否你的正则模式是脆弱的(指数时间)是一个难以回答的问题,这随着你使用不同语言变化,但这些规则适用于所有语言:

  1. 避免nested quantifiers比如(a+)*。Node正则引擎可以快速处理一些,其他的是脆弱的。
  2. 避免OR’s with overlapping clauses,比如(a|a)*
  3. 避免使用backreferences,(a.*) \1
  4. 如果是简单字符串匹配,使用indexOf。他更快并且永远不可能超过O(n)
    如果你不确定你的正则是否脆弱,记住Node没有故障报告脆弱正则和长输入字符串。发生不匹配时将触发指数行为,但Node直到尝试输入字符串的许多路径时才能确定。

A REDOS example

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

这是危险的因为他触犯了规则1:他有双层嵌套量词。
如果客户端用路径///.../\n (100 /'s followed by a newline character that the regexp’s “.” won’t match)查询字符串,消息循环将一直工作,堵塞消息循环。这个客户端的REDOS攻击导致完成正则匹配前所有其他客户端无法得到轮转。
因此你应该避免用复杂的正则验证用户输入。

防REDOS资源

这里有一些检查正则是否安全的工具,

  • safe-regex
  • rxxr2
    然而,没有哪个可以捕获所有脆弱正则。
    另一个方法是使用不同的正则引擎。你应该使用node-re2模块,他使用了google blazing-fast ER2 正则引擎。但是,RE2不100%兼容Node正则,所以检查回归如果你切换node-re2模块来处理你的正则。复杂的正则node-re2不支持。
    如果你匹配一些明显的串,比如URL或文件路径,在正则lib(http://www.regexlib.com/?AspxAutoDetectCookieSupport=1)中寻找例子或使用npm模块比如ip-regex。

堵塞消息循环:Node核心模块

一些Node核心模块有一些同步高消耗api,包括:

  • Encryption
  • Compression
  • File Sytem
  • Child process
    这些api是重的,因为他们调用重量的计算(encryption,compression),需要IO(file IO),或者潜在两者(child process)。这些api用于脚本化便利,但不适用于server环境。如果你在消息循环中执行他们,他们将比常规js操作使用更长时间完成,堵塞消息循环。
    在server中,不应该使用下述同步api:
  • Encryption:
    • crypto.randomBytes(同步版本)
    • randomFillSync
    • pdkdf2Sync
    • 应该小心提供大输入给加密解密例程
  • Compresion:
    • zlib.inflateSync
    • zlib.deflateSync
  • File system:
    • 不要使用同步api
  • Cild process:
    • child_process.spawnSync
    • execSync

堵塞消息循环:JSON DOS

JSON.parseJSON.stringfy都是潜在昂贵操作。尽管他们是O(n),对很大的n他们使用很长时间。
如果服务器操作JSON对象,尤其从客户端来,你应该注意对象/字符串的size。
例子:JSON堵塞。创建一个size为2^21的objJSON.stringify他,indexOf这个字符串,JSON.parse他。字符串是50M,需要0.7s字符串化obj,0.03s来indexOf字符串,1.3s去解析字符串。

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

var before, res, took;

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

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

before = process.hrtime();
res = 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);

有npm模块提供异步JSON api。比如:
- JSONStream
- Big-Friendly JSON

复杂的计算不要堵塞消息循环

假设你想不堵塞消息循环进行复杂计算,你有2个选项:分治或者卸载。

分治

你可以拆分你的计算使每个事件都在消息循环运行并会给其他等待消息运行时间。在JS中把正在进行的任务状态保存在闭包中很简单。
简单的例子,计算1到n的平均数。
Example 1: Un-partitioned average, costs O(n)

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

Example 2: Partitioned average, each of the n asynchronous steps costs 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);
});

卸载

如果你需要做更复杂的事,拆分不是一个好选项。因为拆分只使用消息循环,你无法从机器的多核中受益。***消息循环应该协调客户端请求,而不是自己完成。***对一个复杂任务,应该将任务交给工作池。

怎么卸载

你有2个选项来卸载。

  1. 你可以通过开发C++ addon使用Node自建工作池。新版本构建C++ addon使用N-API。node-webworker-threads提供了仅使用JS访问Node工作池的方法。
  2. 你可以创建管理自己的专注于计算的工作池,而不是Node IO为主的工作池。最直接的方法是使用Child process/Cluster。
    你不应该简单的为每个客户端创建child process。你可以比创建管理child更快的接受客户端请求,你的服务器可能变成fork狂魔。
卸载的缺点

卸载的缺点是增加了通信损耗。仅消息循环允许对JS状态可见。对工人,不能管理消息循环命名空间中的JS对象。你需要序列化/反序列化你想要分享的对象。然后工人可以对拷贝进行操作并返回修改后的对象给消息循环。

卸载的建议

你可能希望区分CPU密集和IO密集型任务因为他们有不同的特征。
CPU密集任务仅在工人被安排时有进展,工人一定要安排到机器的一个逻辑core上。如果你有4个逻辑core和5个工人,其中一个工人没有效果。因为,你付出了这个工人的间接费用(内存和调度损耗)却没有回报。
IO密集型包括查询外部服务提供者(DNS,file system)并等待他们的响应。当一个有IO密集型任务的工人等待他的响应,他没有事情可做,会被操作系统重新调度,给其他工人机会来提交他们的请求。**因此,IO密集型任务即使关联线程没在运行也有效。**外部服务提供者,比如db和fs,被高度优化来并发处理大量等待请求。比如,fs将检查大量等待读写请求来合并冲突更新,以最佳顺序检索文件。
如果你只依赖一个工作池,比如Node工作池,CPU密集和IO密集不同的特征可能对应用性能有害。
因此,你期望维护一个独立的计算工作池。

卸载总结

对简单的任务,比如遍历长数组,拆分是一个好选项。如果你的计算很复杂,卸载是一个好方法:通信成本,比如传递序列化对象的间接成本,被使用多core的益处抵消。
然而,如果你的服务器严重依赖复杂计算,你应该考虑Node是否适合。Node擅长IO密集工作,对昂贵的计算可能不是好选择。
如果你选择卸载,不要堵塞工作池。

参考资料:https://nodejs.org/en/docs/guides/dont-block-the-event-loop/

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值