Node.js Guides(Node.js core concepts)

这里接上一篇《Node.js Guides(General)》,继续介绍第二部分,Node.js的核心理念,这一部分非常重要,是Node.js最核心的东西,主要讲述了内部的运行机制是怎样的,一定要耐心看下去,原始地址点这里

  • 2.1、Overview of Blocking vs Non-Blocking 阻塞和非阻塞概述
    • 在Node中阻塞是指非JS操作所要花费的时间,这段时间无法运行JS
    • CPU密集型操作不被称为阻塞,libuv中的同步方法是常用的阻塞操作
    • Node中所有I/O操作都提供非阻塞的异步版本并接受回调函数
    • Node中JS执行是单线程的,因此并发性是指事件循环在完成其他工作后执行JS回调函数的能力
  • 2.2、The Node.js Event Loop, Timers, and process.nextTick() 事件循环、计时器和process.nextTick()

    • 2.2.1、什么是事件循环:现代CPU内核大多是多线程的,因此他们可以处理在后台执行的多个操作,当其中一个操作完成时,内核会告诉Node以便将相应的回调添加到轮询队列中以最终执行
    • 2.2.2、事件循环解释:Node启动后会初始化事件循环,处理输入的脚本,此时可能会调用异步API、计时器、process.nextTick等,然后开始处理事件循环,下图中的每个框被指代事件循环的一个阶段

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
          ┌───────────────────────────┐
      ┌─>│ timers │
      │ └─────────────┬─────────────┘
      │ ┌─────────────┴─────────────┐
      │ │ pending callbacks │
      │ └─────────────┬─────────────┘
      │ ┌─────────────┴─────────────┐
      │ │ idle, prepare │
      │ └─────────────┬─────────────┘ ┌───────────────┐
      │ ┌─────────────┴─────────────┐ │ incoming: │
      │ │ poll │<─────┤ connections, │
      │ └─────────────┬─────────────┘ │ data, etc. │
      │ ┌─────────────┴─────────────┐ └───────────────┘
      │ │ check
      │ └─────────────┬─────────────┘
      │ ┌─────────────┴─────────────┐
      └──┤ close callbacks │
      └───────────────────────────┘
      • Each phase has a FIFO queue of callbacks to execute. While each phase is special in its own way, generally, when the event loop enters a given phase, it will perform any operations specific to that phase, then execute callbacks in that phase’s queue until the queue has been exhausted or the maximum number of callbacks has executed. When the queue has been exhausted or the callback limit is reached, the event loop will move to the next phase, and so on.
        这一段的意思:上图中每个阶段都有一个要执行的FIFO队列,每个阶段都有其特有的执行方式,通常来说当事件循环进入到某个阶段时,他可以执行在此阶段内的任何操作,然后在该阶段的队列中执行回调,直到队列耗尽或达到回调的最大数量限制,然后进入下一个阶段。
      • Since any of these operations may schedule more operations and new events processed in the poll phase are queued by the kernel, poll events can be queued while polling events are being processed. As a result, long running callbacks can allow the poll phase to run much longer than a timer’s threshold. See the timers and poll sections for more details.
        由于每一个阶段都可以通过向poll中加入更多的操作来将新的事件添加到内核队列当中,poll事件可以在自己被处理时加入更多的轮询,因此长时间的回调可以允许轮询阶段运行的时间比计时器的阀值长的多。
    • 2.2.3、事件循环各阶段分析

      • timers

        • 此阶段执行setTimeout和setInterval的回调
        • 计时器提供一个阀值,在该值之后将回调放入到队列中,而不是指定的时间去执行,定时器会在指定的时间之后尽早安排,但是操作系统的调度或者其他回调的运行可能会造成延迟
        • 从技术上讲,poll阶段控制timer何时执行,因为poll是本循环内先执行的一方,而timer总是下个循环执行,所有要等待poll
        • 例子:当时间循环进入poll时,他的队列为空,因此它将等待剩余的一段时间,直到到达最快的timer计时器阀值,当等待至95ms时fs.readFile完成文件读取,并且完成回调函数被添加到poll队列并执行需要10ms,当回调结束后队列中不再有回调,此时事件循环看到还有一个100ms的timer未执行并且等待时间已到,那么程序将绕回到timer阶段并执行定时器的回调,也就是说定时器的回调真正被执行中间跨过了105ms,而不是指定的100ms,为了防止程序不断处于poll阶段,libuv有对poll阶段最大执行时间做限制,腾出来去执行其他阶段
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          const fs = require('fs');
          function someAsyncOperation(callback) {
          // Assume this takes 95ms to complete
          fs.readFile('/path/to/file', callback);
          }
          const timeoutScheduled = Date.now();
          setTimeout(() => {
          const delay = Date.now() - timeoutScheduled;
          console.log(`${delay}ms have passed since I was scheduled`);
          }, 100);
          // do someAsyncOperation which takes 95 ms to complete
          someAsyncOperation(() => {
          const startCallback = Date.now();
          // do something that will take 10ms...
          while (Date.now() - startCallback < 10) {
          // do nothing
          }
          });
      • pending callbacks

        • 执行延迟到下一个循环的I/O回调
        • 此阶段执行某些系统操作(例如TCP错误类型)的回调。 例如,如果TCP套接字在尝试连接时收到ECONNREFUSED,则某些 *nix 系统希望等待报告错误。那么这将在pending callbacks阶段的队列中执行
      • idle,prepare
        • 仅在内部使用
      • poll
        • 检索新的I/O事件,执行I/O相关回调,除了回调异常退出、定时器、setImmediate之外的所有东西,Node会在恰当的时间阻止它对循环的占用
        • 这个阶段主要有两个函数:
          • 计算阻塞和poll I/O所要花费的时间
          • 处理poll队列中的事件
        • 当事件循环进入poll阶段并且没有timer定时器时将会发生下列两种情况之一:
          • 如果队列不为空,则事件循环将遍历执行里面的回调方法,直到队列为空或者达到最大的限制条件
          • 如果队列为空,将会发生下面两种情况:
            • 如果setImmediate有指定执行脚本,那么事件循环将结束poll阶段并继续执行check阶段以执行指定的脚本
            • 如果setImmediate没有指定脚本,则事件循环将等待直到队列中被添加了回调,然后立即执行他们
        • poll队列为空之后,事件循环将检查y已达到时间阀值的计时器,此时如果有定时器阀值到了,那么立即回绕至timer阶段执行队列
      • check
        • 调用setImmediate的回调函数
        • 此阶段允许在poll阶段结束之后立即执行回调,如果poll queue变为空闲并且已经使用setImmediatet添加脚本,那么事件循环将来到check阶段而不是等待poll或者timer添加回调
        • setImmedlate实际上是一个特殊的计时器,它在事件循环的一个单独阶段运行,使用libuv APIl来调度在poll阶段完成后执行的回调
        • 在执行代码时,事件循环到达poll阶段,将等待传入连接、请求等,但是若使用setImmediatet添加了回调并且事件循环进入空闲,那么就会进入check阶段,而不是等待poll的事件回调
      • close callbacks
        • 一些close事件的回调,比如socket.on(‘close’, …)
        • 如果socket或者handle突然关闭,那么将会执行close事件的回调,否则将会触发process.nextTick()
    • 2.2.4、setImmediate vs setTimeout

      • setImmediate用于在当前poll阶段完成后执行脚本
      • setTimeout在经过最小阀值后执行脚本
      • 两者的执行顺序与调用他们的上下文环境不同而不同,如果从主模块中调用两者,他们的执行顺序将受到执行环境的性能限制,例如下面两个的执行顺序是不确定的

        1
        2
        3
        4
        5
        6
        7
        8
        // timeout_vs_immediate.js
        setTimeout(() => {
        console.log('timeout');
        }, 0);

        setImmediate(() => {
        console.log('immediate');
        });
      • 下面例子中setImmediate肯定先执行

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        // timeout_vs_immediate.js
        const fs = require('fs');

        fs.readFile(__filename, () => {
        setTimeout(() => {
        console.log('timeout');
        }, 0);
        setImmediate(() => {
        console.log('immediate');
        });
        });
    • 2.2.5、process.nextTick

      • 虽然nextTick是异步API的一部分,但是从技术上讲他并不是事件循环的一部分,nextTickQueue将在当前操作完成后处理,而不管事件循环的当前阶段如何,可以看做在每个阶段后面都加了一个尾巴,这个尾巴就是nextTick,执行了nextTick之后才开始执行事件循环内的函数
      • 无论何时在给定阶段调用nextTick,传递给nextTick的所有回调都将在事件循环继续之前得到解决,这可能会产生一些特殊操作,比如在nextTick内部调用nextTick
      • 为什么要允许这样做?

        • 看一个例子:

          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          function apiCall(arg, callback) {
          if (typeof arg !== 'string'){
          // return process.nextTick(callback, new TypeError('argument should be string'));
          return callback(new TypeError('argument should be string'))
          }
          }

          apiCall({}, (s) => {
          console.log(s);
          });

          console.log('12313123');

          // nextTick会后打印异常
          // 非nextTick会先打印异常

          上面例子中发生错误之后将错误回传给用户,但是这个回传是在用户的其余代码执行完毕之后才发生的,也就是说在主代码运行之后,事件循环执行之前,上面例子中nextTick写在return中是一个尾递归的概念,这样可以保证不超栈

        • 下面看另一个牛逼的例子:

          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          19
          20
          // 1
          let bar;
          // this has an asynchronous signature, but calls callback synchronously
          function someAsyncApiCall(callback) { callback(); }
          // the callback is called before `someAsyncApiCall` completes.
          someAsyncApiCall(() => {
          // since someAsyncApiCall has completed, bar hasn't been assigned any value
          console.log('bar', bar); // undefined
          });
          bar = 1;

          // 2
          let bar;
          function someAsyncApiCall(callback) {
          process.nextTick(callback);
          }
          someAsyncApiCall(() => {
          console.log('bar', bar); // 1
          });
          bar = 1;

          通过对比可以看到,nextTick确实是在main code完成之后才去执行的,bar被初始化了

        • 下面看一个实际的例子:

          1
          2
          const server = net.createServer(() => {}).listen(8080);
          server.on('listening', () => {});

          官网说listen时还未设置回调,可是实际运行情况是监听的listen回调会执行,这里的具体情况我猜测应该是Node内部有特殊实现

          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          const net = require('net');
          const server = net.createServer(() => {
          }).listen(8080, () => {
          console.log('876');
          });
          process.nextTick(() => {
          console.log('234');
          });
          server.on('listening', () => {
          console.log('123');
          });
          // 876
          // 123
          // 234
    • 2.2.6、process.nextTick vs setImmediate

      • 先行解释:
        • nextTick会在当前阶段完成后立即触发
        • setImmediate会在当前迭代或nextTick之后的事件循环中触发
      • 实际上这两个方法的名称应该交换,因为nextTick比setImmediate更快的触发,但是这个属于历史遗留问题不会改变,因此建议在所有情况下都使用setImmediate,因为它更容易进行推理
    • 2.2.7、为什么使用process.nextTick

      • reason
        • 允许用户处理错误,清除不需要的资源,或者在事件循环开始之前再次发起请求
        • 有时需要允许回调在调用栈展开之后,但事件循环继续之前运行
      • 例子:
        使用process.nextTick让构造函数中发出的事件可以被接受到,前一个在emit事件时event事件还未被监听

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        const EventEmitter = require('events');
        const util = require('util');
        function MyEmitter() {
        EventEmitter.call(this);
        this.emit('event');
        }
        util.inherits(MyEmitter, EventEmitter);
        const myEmitter = new MyEmitter();
        myEmitter.on('event', () => {
        console.log('an event occurred!');
        });

        修改为

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        const EventEmitter = require('events');
        const util = require('util');
        function MyEmitter() {
        EventEmitter.call(this);
        // use nextTick to emit the event once a handler is assigned
        process.nextTick(() => {
        this.emit('event');
        });
        }
        util.inherits(MyEmitter, EventEmitter);
        const myEmitter = new MyEmitter();
        myEmitter.on('event', () => {
        console.log('an event occurred!');
        });
    • 2.2.8、场景解释:nextTick先输出,另外两个随机,setTim比较大会先setImm,0会先setTim,2会比较随机,和执行速度有关

      1
      2
      3
      4
      5
      6
      7
      8
      9
      setImmediate(() => {
      console.log('asdasdasdasd');
      });
      process.nextTick(() => {
      console.log('1111111');
      });
      setTimeout(() => {
      console.log('settimeout');
      }, 2);
  • 2.3、Don’t Block the Event Loop (or the Worker Pool) 非阻塞的事件循环

    • 如果你写的东西比命令行脚本复杂,那么这部分可以帮助你编写性能更高,更安全的应用程序
    • TL; DR
      • Node在事件循环中运行JS代码并提供工作池来处理消耗高昂的任务,Node的扩展性很好,秘诀在于使用很少的线程来处理很多的客户端,此时就可以将更多的系统资源和内存用于处理客户端请求,而不是为线程(内存,上下文切换)支付额外的k空间和时间开销,在给的时间内与客户端的关联性越小,速度就越快
    • 避免阻塞事件循环和工作池
      • 在Node中有两种类型的线程,一个事件循环线程,也称为主线程,以及一个线程池中的N个线程
      • 如果一个线程需要很长时间来执行回调或者任务,那就是阻塞,虽然线程被阻塞代表正在为一个客户端工作,但它无法处理来自任何其他客户端的请求
      • 如果经常在线程上执行重量级代码,那服务器的吞吐量将会受到影响
      • 如果某个输入会阻塞某个线程,那么恶意的客户端可能会借此来发动攻击,即DDos攻击
    • 快速回顾
      • Node采用事件驱动的架构,它有一个用于任务编排的事件循环和一个处理高消耗任务的线程池
      • 什么代码运行在事件循环上
        • 开始运行后,Node首先完成初始化,加载模块并注册事件回调,然后Node进入事件循环,通过执行适当的回调来相应传入的客户端请求,此时是同步执行的,可以在内部注册异步请求以完成后续处理,这些异步请求的回调将在事件循环上执行
      • 什么代码运行在线程池中
        • Node线程池的实现依赖于libuv库
        • 使用线程池来处理消耗过大的任务,包括系统和未提供的非阻塞I/O操作以及CPU密集型的任务
        • I/O密集型
          • DNS:dns.lookup dns.lookupService
          • 文件系统:除了fs.FSWatcher外的所有文件API都使用libuv提供的线程池
        • CPU密集型
          • Crypto: crypto.pbkdf2(), crypto.randomBytes(), crypto.randomFill().
          • Zlib:除了明确的标明同步外所有的API都使用libuv的线程池
        • 当调用一个异步API时,Node需要付出一些设置的成本将操作转到C++部分,但是相比与其增加的执行效率来说,这些设置的时间可以忽略,Node在Node-C++绑定中提供了指向相应C++函数的指针
      • Node如何确定接下来要运行的代码
        • 事件循环和工作池分别维护待处理的事件和待处理的任务队列
        • 事件循环并不维护队列,相反,他有一组文件描述符来告诉操作系统通过epoll(Linux),kqueue(OSX),事件端口(Solaris)或IOCP(Windows)等机制来进行监听操作,这些文件描述符相当于套接字(sockets),任何文件都在他的监控之下,当操作系统通知其中某个文件描述符准备就绪时,事件循环会将其转换为相应的事件并调用与该事件关联的回调函数
        • 工作池是一个队列,里面存储的是其将要处理的任务,每次pop一个任务对其进行处理,处理完成后,worker会触发一个事件告知事件循环,某个任务已经完成,接下来就是事件循环的事情了,这里其实就是说来了一堆异步任务,但是线程池内线程数量是有限的,所以先放到worker队列中准备处理,处理完一个就告诉事件循环的文件描述符组说有一个任务搞定了,然后事件循环进行回调的操作和处理
      • 对应用程序来说这意味着什么
        • 在apache这样每个客户端一个线程的系统中,每个等待中的客户端都被分配了自己的线程,如果一个客户端阻塞,操作系统会中断他并转向去处理另一个客户端,因此操作需要确保请求量少的客户不会受到请求量大的客户的挤压,也就是说请求量少的客户迟迟得不到资源去解决请求,防止请求被不断的挂起,这样会导致一个资源不断被占用但是却得不到处理
        • 因为Node处理客户端请求的线程很少,如果一个线程在处理请求时阻塞了,那么这个客户端请求就会一直挂起直到完成他的回调或者任务,因此公平的对待每个客户端是你的责任,这就意味着你不应该在任何单个回调或任务中为客户端做过多的工作,这会导致线程阻塞,使Node线程池资源变得紧张
    • 不要阻塞事件循环

      • 事件循环会为每一个客户端连接产生一个响应,所有请求和响应都会通过事件循环进行传递,如果事件循环在某一点上耗费过多的时间,那当前和新来的客户都无法得到处理,你应该确保不会阻塞到事件循环的运行,让每个回调都尽快的完成
      • Node使用V8引擎解析JS,对大多数操作来说都是很快的,但是正则和Json除外,对于很复杂的字符串处理,可以考虑对内容进行限制
      • 阻塞事件循环的一种常见方式是使用危险的正则表达式,正则表达式在处理一些特殊情况时,复杂度会急剧增加,下面是使用正则的一些注意点:

        • 避免使用子表达式(a+)*
        • 避免使用重复的表达式(a|a)*
        • 避免使用反向引用(a.*)\1
        • 如果要进行字符串匹配,尽量使用语言层面提供的API
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          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);
          });
      • 核心模块阻塞事件循环

        • 具有耗时超长同步API的几个核心模块,这些模块的API涉及到大量的计算,将消耗很多资源
          • Encryption
          • Compression
          • File System
          • Child process
      • 大量的JSON操作也会消耗很多的资源,要避免字符串过长,JSONStream包提供了异步的API进行JSON操作
      • 如果你想在JS中进行复杂计算但是又不阻塞事件循环,有两种方式

        • partitioning 通过算法降低复杂度

          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          19
          20
          21
          22
          23
          24
          25
          26
          27
          28
          29
          30
          31
          32
          // O(n)
          for (let i = 0; i < n; i++)
          sum += i;
          let avg = sum / n;
          console.log('avg: ' + avg);

          // 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 将复杂计算包成异步的,比如让子进程或者线程池去做这件事情,不过要避免造成fork bomb,这种方式的缺点是会在与进程的通信上造成一些开销,如果你要提供的服务需要大量的计算,那就要考虑Node是否适合做这项工作

    • 不要阻塞工作池
      • 在工作池中的任务,作为开发者要尽可能的让任务可以快速完成,而不是耗费大量的时间,虽然是异步的,但是一个线程会被一直占用,而无法处理其他任务,典型的操作例如文件系统、加密操作等都会耗费大量的时间,Node每一个线程执行的时间很短才能极大的增加吞吐量,因此要尽可能避免长时间的操作
      • 提高吞吐量的一种方式是将一个长时间的操作分解几个操作以增加吞吐量,比如将fs.readFile分解为fs.read和ReadStream,上面一个例子中将累加操作放入setImmediate中执行,就是典型的将任务分解为极小操作,使Node不会产生短时间的阻塞
      • 任务分解的目的是最小化任务时间,但是如果可以明确的区分长任务和短任务,就可以为较长的任务创建一个单独的工作池,这样去掉了分解任务造成的开销和容易出现的错误,增加的开销是工作池队列的开销
      • 无论使用Node的工作池还是维护单独的工作池,都应该优化池的吞吐量,最好最小化每一个任务
    • npm模块的风险
      所有的模块都是开发人员自由发布的,所以其中可能会存在不良模块
    • 结论
  • 2.4、Timers in Node.js node的计时器
    • 2.4.1、setTimeout
      • 定时器与系统紧密集成,Node看似镜像实现了Web端的API,但是实现方法有差异
      • Node中setTimeout无法传递一串代码来执行,必须传递一个函数
      • setTimeout还可以包含其他参数,并将这些参数传递给函数
      • setTimeout唯一的保证是函数的执行时间一定不会比设置的时间更早执行,不保证精确性,可以肯定的是一定会晚于设置的阀值
    • 2.4.2、setImmediate
      • 在当前事件循环周期结束时执行代码,此代码将在当前事件循环中的任何I/O操作之后以及为下一个事件循环调度的任何计时器之前执行,这意味着这个函数调用之后的任何代码都将在setImmediate函数参数之前执行
      • setImmediate后续的参数将作为回调的参数
      • setImmediate返回一个对象,用于取消已调度的immediate
    • 2.4.3、setInterval
      • 和setTimeout一样也是不准滴
    • 2.4.4、清除
      • clearTimeout(timeoutObj);
      • clearImmediate(immediateObj);
      • clearInterval(intervalObj);
    • unref和ref方法
      • setTimeout返回一个对象,在这个对象上调用unref将阻止超时调用
      • 在timer对象上调用ref将继续超时调用
      • 如果unref是要执行的最后一段异步代码,则不会调用timeout对象,这里要注意,一定是最后一段异步代码,也就是说unref之后如果后面没有异步代码,那这个timeout就不会在执行了,但是只要有一个异步代码加入到事件循环中,timeout对象就会继续执行
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        const t1 = setTimeout(() => {
        console.log('1');
        });
        console.log('123');
        setTimeout(() => {
        console.log('2');
        // t1.ref();
        }, 2000);
        t1.unref();
        // 立即输出
        // 123
        // 1
        // 2s后输出
        // 2
◀        
        ▶