以前的一篇文章写了浏览器中的 JavaScript 的运行机制,谈到浏览器和 Node 关于事件循环的实现有着很大不一样。本文将理清 Node 的事件循环机制,以做对比。 另一个重大变动须要知道的,自从 Node V11 后,Node 中的事件循环已经和浏览器中表现一致了!具体见文。前端
从官网的介绍中得知,Node 自己依赖于好几个类库构建而成的,底层都是 C/C++ 编写的,用于调用系统层级的 API和处理异步、网络等工做。node
由这张总体的架构图能够看到,Node 最重要的是 V8 和 libuv 这两部分,V8 不用说,是解析运行 JavaScript 的引擎,没有 V8 就没有 Node 的今天,而 libuv 是 Node 另外一个重要的基石,提供事件循环和线程池,负责全部 I/O 任务的分发与执行,对开发者来讲不可见,只须要调用封装好的 Node API 就能够了。git
下面是另外一张相似的原理图,可见要想深刻理解 Node,必需要了解 libuv 的设计(深刻理解须要学习 C++、操做系统)。github
在 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 是一个跨平台的类库,提供了事件循环(event-loop)机制和异步 I/O 机制,下图是其架构图:安全
libuv 做为底层的基石,将上层传递下来的 I/O 请求分配线程池,好比定时器请求、读取文件请求、网络请求等,待其完成后,再由事件循环机制分配各个请求的注册回调事件到任务队列(或者说消息队列)中,通知上层的 JS 引擎,空闲时捞起队列中的回调函数。服务器
上图中描绘了事件循环机制的具体过程:网络
事件循环机制将如上所示不停地保持运行状态,管理任务队列,调度各种事件,协调上下层之间的工做。而对于前端开发者来讲,只须要考虑 JS 代码运行的模型,实现异步非阻塞的具体过程将交由 libuv 负责。
具体到事件循环(event loop)中,也是分为好几个工做阶段:
setTimeout
和 setInterval
的 callbacksetImmediate
设定的 callbacksocket.on('close', ...)
最重要的是 timers、poll、check 阶段:
指定一个下限时间,而不是精确时间,在到达下限时间后,timers 会尽量执行回调,但系统调度或者其它回调的执行可能会延迟它们。
从技术上来讲,poll 阶段会影响 timers 阶段的执行,因此 setTimeout(callback, time)
中, time
参数只是一个下限时间,是指到了这个时间后将 callback 置于 timers 阶段的队列中,至于真正的执行须要看事件循环有没有轮到这一阶段。
轮询(poll)阶段是事件循环最重要的部分。它有两个重要功能:
事件循环将同步执行 poll 队列里的回调,直到队列为空或执行的回调达到系统上限。若是没有其余阶段的事要处理,事件循环将会一直阻塞在这个阶段,等待新的 I/O 事件加入 poll 队列中。
若是其余阶段出现了事件,则有如下状况:
setImmediate
设定了回调, 事件循环将结束 poll 阶段往下进入 check 阶段来执行 check 队列(里面的回调 callback)。setTimeout
或者 setInterval
回调,则事件循环将往上绕回 timers 阶段,并执行 timer 队列这个阶段容许在 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 阶段。因此必定是 setImmediate
比 setTimeout
早setImmediate
和 setTimeout
,而后事件循环离开 timers 阶段,往下先进入 check 阶段,再回回过头来进入 timers 阶段。输出结果必然也是肯定的。在 Node 中,还有另外一个很是重要的概念是微任务和宏任务,微任务是指不在事件循环阶段中的任务,Node 中只有 process.nextTick 和 Promise callbacks 两个微任务,它们都有各自的队列,不属于事件循环的一部分。
那么它们的运行时机是在何时呢?
很简单,无论在什么地方调用,它们老是在各个阶段之间执行,上一阶段完成,进入下一阶段前,会清空 nextTick 和 promsie 队列!并且 nextTick 要比 promsie 要高!
下面是具体的一个案例,能够看到微任务 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
复制代码
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。
随着 Node V11 的发布,nextTick 回调和 Promise 微任务将会在各个独立的 setTimeout
and setImmediate
之间运行,即使当前的 timers 队列或者 check 队列不为空。这就跟浏览器的事件循环表现保持了一致!
固然,这对于从 Node V11 如下升级到 V11 以上的代码来讲,多是个潜在的风险。
前面谈了微任务中的 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 的机制。从一个坑挖向另外一个坑~