Node.js 深入浅出 - 第一章

619 阅读6分钟

Node是一个基于 Chrome V8 引擎 的 JavaScript 运行时环境。 打破了过去javascript只能在浏览器中运行的局面。下面先介绍下它的一些特性

异步I/O

完整的异步 I/O 主要包含:

  • 事件循环
  • 观察者 (Node.js 基本上所有的事件机制都是用设计模式中 观察者模式 实现
  • 请求对象。

异步调用对于值的捕获原则是: “Don't call me, I will call you” 执行时间是不被预期的。

fs.readFile('/path', funcyion(err, filr) {
    log('修炼了葵花宝典')
})
log('令狐冲')

上面的程序就是经典的异步I/O例子,每个调用之间无需等待之前的I/O调用结束。

fs.readFile('path1', fn1) 
fs.readFile('path1', fn2) 

上面这种情况要是同步I/O,它们的耗时就是两个任务耗时之和, 而Node耗时取决于那个耗时最慢的值, 优势显而易见。

事件循环

首先我们要理解Node里面的事件队列概念, 当接收到请求时, 就将这个请求作为事件放入队列, 然后继续接收其它请求, 直到没有请求时(主线程空闲时), 开始循环事件队列, 这里要判断, 如果是非I/O任务 就直接处理, 如果是I/O任务 就从 线程池 中拿出一个线程来处理这个事件, 并指定回调函数, 然后继续循环队列其它事件。

在每次运行的事件循环之间,Node.js 检查它是否在等待任何异步 I/O 或计时器,如果没有的话,则完全关闭。

上面有个线程池, 是不是很疑惑地, 不是说单线程吗,哪来的线程池? 其实在Node中,除了Javascript是单线程外, Node自身其实是多线程的。

我们时常提到Node是单线程的, 这里的单线程仅仅只是JavaScript执行在单线程中罢了。

Node中有两种类型的线程:

  1. 事件循环线程 (也叫 主线程、事件线程、主循环)
  2. 工作线程(也叫线程池: 通过libuv来实现的)

Node中 I/O 密集型任务和 CPU 密集型任务都使用线程池

观察者

在事件循环中怎么判断是否还有事件要处理? 这里就要引入观察者概念了: 每个事件循环中有一个或者多个观察者, 通过询问观察者是否还有事件需要处理,

请求对象

可能很多人觉得请求对象不就是我们用户吗? 对于Node中有很多回调, 比如

fs.readFile('/path1', callback)
fs.openFile('/path2', callback)

我们发出读取文件或者打开文件命令后, 回调函数是怎么被执行的? 其实这中间存在一个请求对象

image.png

上面这个图就是Node里经典的调用方式, 通过js调用Node核心模块, 核心模块调用C++内建模块, 内建模块再通过libuv进行系统调用, libuv作为封装层。

那么请求对象在哪呢, 就在uv_fs_open()调用过程中, 创建了请求对象 FSReqWrap 。 js层传入的参数和回调函数都被封装在这个请求对象中,其中回调函数被设置在这个对象的oncomplete_sym属性上。

fs.readFile('/path1', callback)
FSReqWrap.oncomplete_sym = callback

也就是说在读文件这个例子里, 执行回调函数的是 FSReqWrap 这个对象。

单线程

单线程类似进入一个while(true)的事件循环,直到没有事件观察者退出,每个异步事件都生成一个事件观察者,如果有事件发生就调用该回调函数。

优点
  1. 单线程的最大好处是不用像多线程编程那样处处在意状态的同步问题,
  2. 没有 死锁 的存在,也没有线程上下文切换所带来的性能上的开销。

这里解释下上下文切换概念:一个线程被剥夺使用权,另一个线程被选中的过程就是上下文切换

上下文切换:在单处理器时期,操作系统就能处理多线程并发任务,处理器给每个线程分配CPU时间片, 线程在CPU时间片内执行任务。 CPU时间片是CPU分配给每个线程执行的时间段, 一般为几十毫秒,它决定了每个线程可以连续占用处理器运行的时长。 当一个线程时间片用完,或者因自身原因暂停使用, 操作系统就会选中另一个线程占用处理器(进程、线程都是由操作系统控制, 在CPU里执行)。 切入切出的过程中操作系统需要保存和回复相应的进度信息, 这个进度信息就是上下文

缺点
  1. 无法利用多核CPU
  2. 错误会引起整个应用退出, 应用的健壮性得提高
  3. 大量计算占用CPU会阻塞后面的任务
解决方案

早期采用Google 公司开发的Gears, 它启用一个完全独立的进程, 将大计算量的程序分配给这个进程。 后来HTML5定制了Web Worker, 创建工作线程来进行计算, 以解决js大计算阻塞UI渲染问题。 Node采用了相同思路,创造了child_process 子进程。 注意这个只是 进程

非阻塞与阻塞

Node中I/O都是异步非阻塞的, 但是部分也提供了阻塞版本, 只要在后面加 Sync


fs.readFile('/readme.md', function(err, data) {
    if (err) throw err;
    log(data)
})

try {
    const data = fs.readFileSync('/readme.md')
} catch(err) {
    log(err)
}

注意点: 同步版本需要try catch捕获异常, 不然会导致整个程序中断, 健壮性!

底层依赖

graph TD
Node --> V8
Node --> libuv
Node --> llhttp
Node --> c-areas
Node --> OpenSSL
Node --> zlib
  • V8: 为Node 提供 JavaScript 引擎

  • libuv: 一个 C 写成的类库,用于非阻塞型的 I/O 操作,同时在所有支持的操作系统上保持一致的接口。

  • llhttp: Node.js v12 一个非常重磅的功能就是,内核的 HTTP Parser 默认使用 llhttp,取代了老旧的 http-parser,性能提升了 156%。

  • c-areas: 异步DNS解析库

  • OpenSSL: 一个开放源代码的软件库包,应用程序可以使用这个包来进行安全通信,避免窃听,同时确认另一端连接者的身份。

  • zlib: 数据流压缩、解压缩

接下来看一个有意思的例子:

setTimeout(() => {
    console.log(1);
    Promise.resolve().then(function() {
        console.log(2);
    });
}, 0);

setTimeout(() => {
    console.log(3);
    Promise.resolve().then(function() {
      console.log(4);
    });
}, 0);

//这个例子运行在Node10 版本之后 跟之前是不一样的, 后来为了向浏览器靠拢, 改成了现在的执行顺序1234

再看一个 冷知识

setImmediate VS setTimeout

setImmediate 与 setTimeout很类似, 但是基于被调用时机,它们表现不同。

  • setImmediate: 设计为一旦在当前 轮询 阶段完成, 就执行脚本。
  • setTimeout: 在最小阈值(ms 单位)过后运行脚本。

执行计时器的顺序将根据调用它们的上下文而异。

如果两者在一个 I/O 内调用,则 setImmediate 优先于 setTimeout

fs.readFile('/path', () => {
    setImmediate(() => {
        log('immediate')
    })

    setTimeout(() => {
        log('timeout')
    })
}) 
    
// 结果是 immediate, timeout

如果两者不在同一个 I/O 内, 那顺序不确定, 跟性能相关

setTimeout(() => {
    console.log('timeout');
}, 0);

setImmediate(() => {
    console.log('immediate');
});

官网这么说的, 但是我试了好多次还是setImmediate 快 image.png

我单独写了一篇文章 关于 Js EventLoop Vs Node EventLoop