Node 事件循环机制

以前的一篇文章写了浏览器中的 JavaScript 的运行机制,谈到浏览器和 Node 关于事件循环的实现有着很大不一样。本文将理清 Node 的事件循环机制,以做对比。 另一个重大变动须要知道的,自从 Node V11 后,Node 中的事件循环已经和浏览器中表现一致了!具体见文。前端

目录

Node 的设计架构

从官网的介绍中得知,Node 自己依赖于好几个类库构建而成的,底层都是 C/C++ 编写的,用于调用系统层级的 API和处理异步、网络等工做。node

  • V8: JavaScript 引擎,由谷歌公司维护
  • libuv:它是一个 C 编写的高性能、事件驱动、非阻塞型 I/O 类库,而且提供了跨平台的API
  • llhttp:C 编写的 HTTP 解析类库
  • c-ares:C 编写的用于处理某些异步的 DNS 请求
  • OpenSSL:安全相关
  • zlib:压缩

Node 架构图一

Node 架构图二

由这张总体的架构图能够看到,Node 最重要的是 V8 和 libuv 这两部分,V8 不用说,是解析运行 JavaScript 的引擎,没有 V8 就没有 Node 的今天,而 libuv 是 Node 另外一个重要的基石,提供事件循环和线程池,负责全部 I/O 任务的分发与执行,对开发者来讲不可见,只须要调用封装好的 Node API 就能够了。git

下面是另外一张相似的原理图,可见要想深刻理解 Node,必需要了解 libuv 的设计(深刻理解须要学习 C++、操做系统)。github

Node 架构图三

阻塞和非阻塞

在 I/O 操做中,代码有阻塞和非阻塞之分,也就是同步和异步代码,Node 一样提供了两套 API:数据库

// 同步阻塞
const fs = require('fs');
const data = fs.readFileSync('/file.md');

// 异步非阻塞
const fs = require('fs');
fs.readFile('/file.md', (err, data) => {
  if (err) throw err;
});
复制代码

因为在 Node 中 JavaScript 的执行是单线程的,因此一旦发生长时间阻塞,会严重影响后面代码的运行,不像其余语言能够新开一个线程处理。若是选择了非阻塞的方式,就能够释放当前的 JS 引擎的工做,用于处理后面的请求,好比一个服务器请求要花 50ms,其中 45ms 花在了数据库 I/O 上,选择了非阻塞代码,能够将这 45ms 处理其余请求。这极大了提升了并发性能promise

另一个要注意的是,千万不要混用阻塞和非阻塞代码:浏览器

// !此代码有严重问题,会形成 BUG
const fs = require('fs');
fs.readFile('/file.md', (err, data) => {
  if (err) throw err;
  console.log(data);
});
fs.unlinkSync('/file.md');

// 正确作法!
fs.readFile('/file.md', (readFileErr, data) => {
  if (readFileErr) throw readFileErr;
  console.log(data);
  fs.unlink('/file.md', (unlinkErr) => {
    if (unlinkErr) throw unlinkErr;
  });
});
复制代码

关于 libuv

libuv 是一个跨平台的类库,提供了事件循环(event-loop)机制异步 I/O 机制,下图是其架构图:安全

Libuv 架构图

libuv 做为底层的基石,将上层传递下来的 I/O 请求分配线程池,好比定时器请求、读取文件请求、网络请求等,待其完成后,再由事件循环机制分配各个请求的注册回调事件到任务队列(或者说消息队列)中,通知上层的 JS 引擎,空闲时捞起队列中的回调函数。服务器

事件循环

上图中描绘了事件循环机制的具体过程:网络

  1. 事件调度器(Event demultiplexer )接受 I/O 请求(如文件、网络),将其发给各个具体的线程或者专门的模块进行处理(底层实现比较复杂,具体见 NodeJS Event Loop Series)
  2. 等 I/O 处理完了,事件调度器将注册的回调事件放在任务队列(或者也能够叫 Event Queue)中
  3. 循环往复

事件循环机制将如上所示不停地保持运行状态,管理任务队列,调度各种事件,协调上下层之间的工做。而对于前端开发者来讲,只须要考虑 JS 代码运行的模型,实现异步非阻塞的具体过程将交由 libuv 负责。

事件循环的几个阶段

具体到事件循环(event loop)中,也是分为好几个工做阶段:

事件循环各个阶段

  • 定时器(timers)阶段:这个阶段执行 setTimeoutsetInterval 的 callback
  • 待定回调(pending I/O callbacks)阶段:这个阶段执行一些系统操做的回调,好比 TCP 错误。名字会让人误解为执行 I/O 回调处理程序, 实际上 I/O 回调会由 poll 阶段处理。
  • idle, prepare 阶段:仅 node 内部使用
  • 轮询(poll)阶段:获取新的 I/O 事件, 例如操做读取文件等等,适当的条件下 node 将阻塞在这里
  • 检查(check)阶段:执行 setImmediate 设定的 callback
  • 关闭的回调函数(close callbacks)阶段:一些关闭的回调函数,如:socket.on('close', ...)

最重要的是 timers、poll、check 阶段:

  1. 定时器(timers)阶段:

指定一个下限时间,而不是精确时间,在到达下限时间后,timers 会尽量执行回调,但系统调度或者其它回调的执行可能会延迟它们。

从技术上来讲,poll 阶段会影响 timers 阶段的执行,因此 setTimeout(callback, time) 中, time 参数只是一个下限时间,是指到了这个时间后将 callback 置于 timers 阶段的队列中,至于真正的执行须要看事件循环有没有轮到这一阶段。

  1. 轮询(poll)阶段

轮询(poll)阶段是事件循环最重要的部分。它有两个重要功能:

  • 处理 poll 队列的事件
  • 当有已超时的 timer,执行它的回调函数

事件循环将同步执行 poll 队列里的回调,直到队列为空或执行的回调达到系统上限。若是没有其余阶段的事要处理,事件循环将会一直阻塞在这个阶段,等待新的 I/O 事件加入 poll 队列中。

若是其余阶段出现了事件,则有如下状况:

  • 若是 check 队列已经被 setImmediate 设定了回调, 事件循环将结束 poll 阶段往下进入 check 阶段来执行 check 队列(里面的回调 callback)。
  • 若是 timers 队列有到时的 setTimeout 或者 setInterval 回调,则事件循环将往上绕回 timers 阶段,并执行 timer 队列
  1. 检查(check)阶段

这个阶段容许在 poll 阶段结束后当即执行回调。若是 poll 阶段空闲,而且有被 setImmediate 设定的回调,事件循环会转到 check 阶段而不是继续等待。

setImmediate 能够看做一个特殊的定时器,libuv 专门为它设置了一个独立阶段。

一般上来说,随着代码执行,事件循环终将进入 poll 阶段,在这个阶段等待 incoming connection, request 等等。可是,只要有被 setImmediate 设定了回调,一旦 poll 阶段空闲,那么程序将结束 poll 阶段并进入 check 阶段,而不是继续等待 poll 事件。

这里能够用伪代码说明状况:

// 事件循环自己至关于一个死循环,当代码开始执行的时候,事件循环就已经启动了
// 而后顺序调用不一样阶段的方法
while(true){
// timer阶段
	timer()
// I/O callbacks阶段
	IO()
// idle阶段
	IDLE()
// poll阶段,大部分时候,事件循环将会停留在此阶段,等待 I/O 事件
	poll()
// check阶段
	check()
// close阶段
	close()
}
复制代码

另一个要理解的是,事件循环就像这段伪代码指明的,是一轮又一轮循环往复,因此事件回调函数进入的时机也很重要,如下面的代码举例:

// 案例一
setTimeout(() => {
  console.log('setTimeout')
})

setImmediate(() => {
  console.log('setImmediate')
})
// 输出结果:
// 不肯定,setTimeout 和 setImmediate 没法肯定进入时机


// 案例二:
fs.readFile('file.path', (err, file) => {
  setTimeout(() => {
    console.log('setTimeout')
  })

  setImmediate(() => {
    console.log('setImmediate')
  })
})
// 输出结果:setImmediate setTimeout

// 案例三:
setTimeout(() => {
  console.log('setTimeout1')
  setImmediate(() => {
    console.log('setImmediate2')
  })
  setTimeout(() => {
    console.log('setTimeout2')
  })
})
// 输出结果:setTimeout1 setImmediate2 setTimeout2
复制代码

这几个案例中,咱们经过分析得以一窥事件循环的机制:

  • 案例一:在因为系统运行性能的不一样,因此 setTimeout 进入 timers 队列时机不肯定,可能比 setImmediate 早,也可能晚,这就致使了循环下来,输出的不肯定
  • 案例二:进入事件循环的时候 fs.readFile 是在 I/O poll 阶段,因此事件循环的下一个阶段必然是 check 阶段,而后再绕回开头的 timers 阶段。因此必定是 setImmediatesetTimeout
  • 案例三:跟案例二类似,进入事件循环是在 timers 阶段,当此队列的回调执行完成后,又新增了 setImmediatesetTimeout,而后事件循环离开 timers 阶段,往下先进入 check 阶段,再回回过头来进入 timers 阶段。输出结果必然也是肯定的。

微任务和宏任务

在 Node 中,还有另外一个很是重要的概念是微任务和宏任务,微任务是指不在事件循环阶段中的任务,Node 中只有 process.nextTick 和 Promise callbacks 两个微任务,它们都有各自的队列,不属于事件循环的一部分。

事件循环和微任务队列

那么它们的运行时机是在何时呢?

很简单,无论在什么地方调用,它们老是在各个阶段之间执行,上一阶段完成,进入下一阶段前,会清空 nextTick 和 promsie 队列!并且 nextTick 要比 promsie 要高!

旧版本Node微任务处理时机

下面是具体的一个案例,能够看到微任务 Promise,是在 timers 阶段以后才清空。说明微任务是在阶段之间才会处理,与浏览器只要有微任务就清空很不同!

setTimeout(() => {
  console.log('timeout1');
  Promise.resolve(1).then(() => {
    console.log('Promise1')
  })
});

setTimeout(() => {
  console.log('timeout2');
  Promise.resolve(1).then(() => {
    console.log('Promise2')
  })
});

// 输出
// timeout1
// timeout2
// Promise1
// Promise2
复制代码

nextTick 的问题

process.nextTick 有一个很大的问题,它会发生“饿死” I/O 的潜在风险:

fs.readFile('file.path', (err, file) => {})

const loopTick = () => {
   process.nextTick(loopTick)
}
复制代码

这段代码将会一直停留在 nextTick 阶段,没法进入到 fs.readFile 的回调中,这就是所谓的 I/O starving

要解决这个问题,使用 setImmediate 替代,由于 setImmediate 属于事件循环,就算不停地循环,也不会阻塞整个事件循环机制,由于事件循环会在一轮又一轮处理 check 阶段的回调,而不像 nextTick 那么霸道,必须当即清空!

这特性在一些基础库中用得多。好比替代原生 Promise 的库 Q.js 和 Bluebird.js,相比于原生 Promise 的底层实现,Q 是基于 process.nextTick 来作流程控制,而 Bluebird 使用了 setImmediate。

定时器和微任务在NodeV11版本后的变动

随着 Node V11 的发布,nextTick 回调和 Promise 微任务将会在各个独立的 setTimeout and setImmediate 之间运行,即使当前的 timers 队列或者 check 队列不为空。这就跟浏览器的事件循环表现保持了一致!

固然,这对于从 Node V11 如下升级到 V11 以上的代码来讲,多是个潜在的风险。

async 代码运行机制

前面谈了微任务中的 Promise 队列,那么对于一样是异步的 async 函数代码应该如何解释呢?

本段将简单说明如下 async 代码机制。

async 函数本质上是 Promise 的语法糖。每次咱们使用 await, 解释器都建立一个 Promise 对象,而后把剩下的 async 函数中的操做放到 then 回调函数中。async/await 的实现,离不开 Promise。从字面意思来理解,async 是“异步”的简写,而 await 是 async wait 的简写能够认为是等待异步方法执行完成。

setTimeout(function () {
  console.log('setTimeout')
})
Promise.resolve().then(() => {
  console.log('resolved')
})
async function async2() {
  console.log('async2')
}
async function async1() {
  console.log('async')
  await async2()
  console.log('async done')
}
async1()

// => 等同于

setTimeout(function () {
  console.log('setTimeout')
})
Promise.resolve().then(() => {
  console.log('resolved')
})
function async2() {
  console.log('async2')
}
function async1() {
  console.log('async')
  // wait 会将 async2 变为一个 Promise
  new Promise((resolve, reject) => {
    // 执行成功后进入 resolve 阶段
    async2()
    resolve()
  })
    // await 下面的代码运行时机是在上面的 Promise 运行以后
    .then(() => {
      console.log('async done')
    })
}
async1()
复制代码

代码练习

最后整理一些代码练习,注意,这里区分新旧版本!

// 案例一
setImmediate(function(){
  console.log("setImmediate");
  setImmediate(function(){
    console.log("嵌套setImmediate");
  });
  process.nextTick(function(){
    console.log("nextTick");
  })
});
// 旧版本 setImmediate nextTick setImmediate2
// 新版本 setImmediate nextTick setImmediate2

// 案例二
setTimeout(() => {
  console.log('timeout1');
  Promise.resolve(1).then(() => {
    console.log('Promise1')
  })
});

setTimeout(() => {
  console.log('timeout2');
  Promise.resolve(1).then(() => {
    console.log('Promise2')
  })
});
// 旧版本 timeout1 timeout2 Promise1 Promise2
// 新版本 timeout1 Promise1 timeout2 Promise2

// 案例三
setTimeout(() => {
  console.log('timeout0');
  Promise.resolve('resolved').then(res => console.log(res));
  Promise.resolve('time resolved').then(res => console.log(res));
  process.nextTick(() => {
      console.log('nextTick1');
      process.nextTick(() => {
          console.log('nextTick2');
      });
  });
  process.nextTick(() => {
      console.log('nextTick3');
  });
  console.log('sync');
  setTimeout(() => {
      console.log('timeout2');
  });
});

setTimeout(() => {
  console.log('setTimeout3')
  process.nextTick(() => {
    console.log('nextTick4');
  });
  Promise.resolve('resolved3').then(res => console.log(res));
})
// 旧版本:
// timeout0
// sync
// setTimeout3
// nextTick1
// nextTick3
// nextTick4
// nextTick2
// resolved
// time resolved
// resolved3
// timeout2

// 新版本:
// timeout0
// sync
// nextTick1
// nextTick3
// nextTick2
// resolved
// time resolved
// setTimeout3
// nextTick4
// resolved3
// timeout2

// 案例四:
async function async1(){
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}
async function async2(){
  console.log('async2')
}
console.log('script start')
async1();
new Promise(function(resolve){
  console.log('promise1')
  resolve();
  console.log('promise2')
}).then(function(){
  console.log('promise3')
})
console.log('script end')

// 输出
// script start
// async1 start
// async2
// promise1
// promise2
// script end
// async1 end
// promise3

// 案例四:
process.nextTick(() => console.log('nextTick'));
new Promise(function(resolve){
  console.log('promise1')
  resolve();
  console.log('promise2')
}).then(function(){
  console.log('promise3')
})
// 旧版本:promise1 promise2 nextTick promise3
// 新版本:promise1 promise2 nextTick promise3
复制代码

注意这里面的第三个案例有点奇怪,若是你能看出来的话,这点须要深刻了解 async 的机制。

总结

讲了这么多关于 Node 事件循环的机制,跟浏览器怎么个不一样法,到最后发现,新版本 Node 已经跟浏览器特性保持一致了!随着新版本的逐渐普及,之后只要记住一种运行模型就能够了,固然,Node 的事件循环阶段过程也不能忘,有必要的话甚至能够了解一些 libuv 的机制。从一个坑挖向另外一个坑~

参考

相关文章
相关标签/搜索