15-手写Node模块系统-Event Loop(事件环)
JS是单线程的
JavaScript语言最大特点就是单线程,但是这里的单线程指的是主线程是单线程的。那为什么js要单线程呢? 因为,JS主要用于操作DOM,如果是有两个线程,一个在DOM上添加内容,一个在DOM上删除内容,此时浏览器该以哪个为准呢? 所以为了避免复杂性,JavaScript从诞生起就是单线程的。
同步和异步
同步和异步关注的是消息通知机制
- 1)同步在发出调用后,没有结果前是不返回的,一旦调用返回,就得到返回值。调用者会主动等待这个调用结果。
- 2)异步是发出调用后,调用者不会立刻得到结果,而是被调用者通过状态或回调函数来处理这个调用。
任务队列
- 因为JavaScript是单线程的。就意味着所有任务都需要排队,前一个任务结束,后一个任务才能执行。前一个任务耗时很长,后一个任务也得一直等着。但是IO设备(比如ajax网络请求)很慢,CPU一直处于等待状态,这样就很不合理了。
- 所以,其实主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。于是有了同步任务和异步任务。
同步任务是指在主线程上执行的任务,只有前一个任务执行完毕,下一个任务才能执行。 异步任务是指不进入主线程,而是进入任务队列(task queue)的任务,只有主线程任务执行完毕,任务队列的任务才会进入主线程执行。
浏览器中的Event Loop
从上图看到:
- 主线程运行的时候产生堆(heap)和栈(stack)
- 栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)
- 只要栈中的代码执行完毕,主线程就会去读取"任务队列",将队列中的事件放到执行栈中依次执行。
- 主线程继续执行,当再调用外部API时又加入到任务队列中,等主线程执行完毕又会接着将任务队列中的事件放到主线程中。
- 上面整个过程是循环不断的。
宏任务(MacroTask)和微任务(MicroTask)
宏任务和微任务可以说都是异步任务。
宏任务: 宏/大的意思, 可以理解为比较费时比较慢的任务
微任务: 微/小的意思, 可以理解为相对没那么费时没那么慢的任务
【注意】所有的宏任务和微任务都会放到自己的执行队列中, 也就是有一个宏任务队列和一个微任务队列
所有放到队列中的任务都采用"先进先出原则", 也就是多个任务同时满足条件, 那么会先执行先放进去的
- 常见的宏任务
macrotask
有:setTimeout、setInterval、 setImmediate(ie浏览器才支持,node中自己也实现了)、MessageChannel - 常见的微任务
microtask
有:Promise、MutationObserver、process.nextTick(node独有)
这里有三个任务应该是比较陌生的, 分别是setImmediate、MutationObserver和MessageChannel, 下面来介绍一下setImmediate、MutationObserver的具体用法, MessageChannel可以移步到这篇博客。
setImmediate
该方法用来把一些需要长时间运行的操作放在一个回调函数里,在浏览器完成后面的其他语句后,就立刻执行这个回调函数。
【注意】该方法可能不会被批准成为标准,目前只有最新版本的 Internet Explorer 和Node.js 0.10+实现了该方法。它遇到了 Gecko(Firefox) 和Webkit (Google/Apple) 的阻力.
setImmediate(function () {
console.log("执行了setImmediate");
});
console.log("同步代码Start");
console.log("同步代码End");
setImmediate 与 setTimeout, setInterval的区别:
setImmediate不能设置延迟时间, 并且只能执行一次
补充:
clearImmediate
方法可以用来取消通过setImmediate设置的将要执行的语句, 就像 clearTimeout
对应于 setTimeout
一样.
MutationObserver
Mutation observer 是用于代替 Mutation events 作为观察DOM树结构发生变化时,做出相应处理的API。
Mutation Observer 是在DOM4中定义的,用于替代 mutation events 的新API,它的不同于events的是,所有监听操作以及相应处理都是在其他脚本执行完成之后异步执行的,并且是所以变动触发之后,将变得记录在数组中,统一进行回调的,也就是说,当你使用observer监听多个DOM变化时,并且这若干个DOM发生了变化,那么observer会将变化记录到变化数组中,等待一起都结束了,然后一次性的从变化数组中执行其对应的回调函数。
Mutation Observer有以下特点:
- 它等待所有脚本任务完成后,才会运行,即采用异步方式
- 它把DOM变动记录封装成一个数组进行处理,而不是一条条地个别处理DOM变动。
- 它即可以观察发生在DOM节点的所有变动,也可以观察某一类变动
使用方法
首先,使用MutationObserver构造函数,新建一个实例,同时指定这个实例的回调函数。
var observer = new MutationObserver(callback);
observer方法
observer方法指定所要观察的DOM元素,以及要观察的特定变动。
var article = document.querySelector('article');
var options = {
'childList': true,
'arrtibutes': true
};
observer.observer(article, options);
上面代码首先指定,所要观察的DOM元素是article,然后指定所要观察的变动是子元素的变动和属性变动。最后,将这两个限定条件作为参数,传入observer对象的observer方法。
MutationObserver所观察的DOM变动(即上面代码的option对象),包含以下类型:
- childList:子元素的变动
- attributes:属性的变动
- characterData:节点内容或节点文本的变动
- subtree:所有下属节点(包括子节点和子节点的子节点)的变动
想要观察哪一种变动类型,就在option对象中指定它的值为true。需要注意的是,不能单独观察subtree变动,必须同时指定childList、attributes和characterData中的一种或多种。
disconnect方法和takeRecord方法
disconnect方法用来停止观察。发生相应变动时,不再调用回调函数。
observer.disconnect();
takeRecord方法用来清除变动记录,即不再处理未处理的变动。
observer.takeRecord();
完整的执行顺序
浏览器中,事件环的运行机制是,先会执行栈中的内容,栈中的内容执行后执行微任务,微任务清空后再执行宏任务,先取出一个宏任务,再去执行微任务,然后在取宏任务清微任务这样不停的循环,我们可以看下面这张图理解一下:
从图中可以看出,同步任务会进入执行栈,而异步任务会进入任务队列(callback queue)等待执行。一旦执行栈中的内容执行完毕,就会读取任务队列中等待的任务放入执行栈开始执行。(图中缺少微任务)
总结一下, 完整的流程就是:
- 1. 从上至下执行所有同步代码
- 2. 在执行过程中遇到宏任务就放到宏任务队列中,遇到微任务就放到微任务队列中
- 3. 当所有同步代码执行完毕之后, 就执行微任务队列中满足需求所有回调
- 4. 当微任务队列所有满足需求回调执行完毕之后, 就执行宏任务队列中满足需求所有回调
- .......
那么,我们来道面试题检验一下,当我们在浏览器中运行下面的代码,输出的结果是什么呢?
setTimeout(() => {
console.log('setTimeout1');
Promise.resolve().then(data => {
console.log('then3');
});
},1000);
Promise.resolve().then(data => {
console.log('then1');
});
Promise.resolve().then(data => {
console.log('then2');
setTimeout(() => {
console.log('setTimeout2');
},1000);
});
console.log(2);
// 输出结果:2 then1 then2 setTimeout1 then3 setTimeout2
- 先执行栈中的内容,也就是同步代码,所以2被输出出来;
- 然后清空微任务,所以依次输出的是 then1 then2;
- 因代码是从上到下执行的,所以1s后 setTimeout1 被执行输出;
- 接着再次清空微任务,then3被输出;
- 最后执行输出setTimeout2
【注意】每执行完一个宏任务都会立刻检查微任务队列有没有被清空, 如果没有就立刻清空, 会立即执行微任务队列中的任务。
Node中的Event Loop
Node.js也是单线程的Event Loop,但是由于执行代码的宿主环境和应用场景不同, 所以它的运行机制不同于浏览器环境。
根据上图,Node.js的运行机制如下:
- 写的JavaScript脚本会交给V8引擎解析
- 解析后的代码,调用Node API,Node会交给Libuv库处理
- Libuv库将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎
- V8引擎再将结果返回给用户
其实本质是在libuv(一个高性能的,事件驱动的I/O库)内部有这样一个事件环机制。
当Node.js启动时会初始化event loop, 每一个event loop都会包含按如下顺序六个循环阶段
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
- timers 阶段: 这个阶段执行setTimeout(callback) 和 setInterval(callback)预定的callback;
- I/O callbacks 阶段: 执行除了 close事件的callbacks、被timers(定时器,setTimeout、setInterval等)设定的callbacks、setImmediate()设定的callbacks之外的callbacks;
- idle, prepare 阶段: 仅node内部使用;
- poll 阶段: 获取新的I/O事件, 适当的条件下node将阻塞在这里;
- check 阶段: 执行setImmediate() 设定的callbacks;
- close callbacks 阶段: 比如socket.on(‘close’, callback)的callback会在这个阶段执行.
每一个阶段都有一个装有callbacks的fifo queue(队列),当event loop运行到一个指定阶段时, node将执行该阶段的fifo queue(队列),当队列callback执行完或者执行callbacks数量超过该阶段的上限时, event loop会转入下一下阶段.
【注意】上面六个阶段都不包括 process.nextTick(), process.nextTick()不在event loop的任何阶段执行,而是在各个阶段切换的中间执行,即从一个阶段切换到下个阶段前执行。
下面我们来按照代码第一次进入libuv引擎后的顺序来详细解说这些阶段:
poll阶段
当个v8引擎将js代码解析后传入libuv引擎后,循环首先进入poll阶段。poll阶段的执行逻辑如下: 先查看poll queue中是否有事件,有任务就按先进先出的顺序依次执行回调。 当queue为空时,会检查是否有setImmediate()的callback,如果有就进入check阶段执行这些callback。但同时也会检查是否有到期的timer,如果有,就把这些到期的timer的callback按照调用顺序放到timer queue中,之后循环会进入timer阶段执行queue中的 callback。 这两者的顺序是不固定的,收到代码运行的环境的影响。如果check队列为空, 但timer队列不为空, 那么loop会直接跳转到timer阶段。如果两者的queue都是空的,那么loop会在poll阶段停留,直到有一个i/o事件返回,循环会进入i/o callback阶段并立即执行这个事件的callback。
值得注意的是,poll阶段在执行poll queue中的回调时实际上不会无限的执行下去。有两种情况poll阶段会终止执行poll queue中的下一个回调:1.所有回调执行完毕。2.执行数超过了node的限制。
check阶段
heck阶段专门用来执行setImmediate()方法的回调,当poll阶段进入空闲状态,并且setImmediate queue中有callback时,事件循环进入这个阶段。
close阶段
当一个socket连接或者一个handle被突然关闭时(例如调用了socket.destroy()方法),close事件会被发送到这个阶段执行回调。否则事件会用process.nextTick()方法发送出去。
timer阶段
这个阶段以先进先出的方式执行所有到期的timer加入timer队列里的callback,一个timer callback指得是一个通过setTimeout或者setInterval函数设置的回调函数。
I/O callback阶段
如上文所言,这个阶段主要执行大部分I/O事件的回调,包括一些为操作系统执行的回调。例如一个TCP连接生错误时,系统需要执行回调来获得这个错误的报告。
NodeJS事件环和浏览器事件环区别
1.任务队列个数不同
浏览器事件环有2个事件队列(宏任务队列和微任务队列)
NodeJS事件环有6个事件队列
2.微任务队列不同
浏览器事件环中有专门存储微任务的队列
NodeJS事件环中没有专门存储微任务的队列
3.微任务执行时机不同
浏览器事件环中每执行完一个宏任务都会去清空微任务队列
NodeJS事件环中只有同步代码执行完毕和其它队列之间切换的时候会去清空微任务队列
4. 微任务优先级不同
浏览器事件环中如果多个微任务同时满足执行条件, 采用先进先出
NodeJS事件环中如果多个微任务同时满足执行条件, 会按照优先级执行
【注意】在NodeJS中process.nextTick微任务的优先级高于Promise.resolve微任务
5.没有宏任务队列和微任务队列的概念
宏任务被放到了不同的队列中, 但是没有队列是存放微任务的队列
微任务会在执行完同步代码和队列切换的时候执行
6.什么时候切换队列?
当队列为空(已经执行完毕或者没有满足条件回调)
或者执行的回调函数数量到达系统设定的阈值时任务队列就会切换
好了,接下来我们先来看一道简单的测试题:
setTimeout(function () {
console.log('setTimeout');
});
setImmediate(function () {
console.log('setImmediate');
});
这道题中如果你在node环境中多运行几次,就会发现输出顺序是不固定的。也就是说虽然上图中timers队列在check队列前面,但是setTimeout和setImmediate没有明确的先后顺序的,这是由node的准备时间(准备工作会浪费一定的时间)导致的。并且在NodeJS中指定的延迟时间是有一定误差的。
对应上面这道练习题,我们再来看下面这道题:
let fs = require('fs');
fs.readFile('./1.txt', function () {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
这道题的输出顺序是setImmediate然后setTimeout,无论你运行多少次,结果顺序不会发生改变。这是因为fs文件操作(I/O操作)属于属于poll阶段,poll阶段的下一阶段就是check阶段,所以输出顺序是毋庸置疑的。
process.nextTick()
注意:本文讲解的process.nextTick是基于v0.10及以上版本
process.nextTick()不在event loop的任何阶段执行,而是在各个阶段切换的中间执行,即从一个阶段切换到下个阶段前执行。同步代码执行完毕后也会执行。
var fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
process.nextTick(()=>{
console.log('nextTick3');
})
});
process.nextTick(()=>{
console.log('nextTick1');
})
process.nextTick(()=>{
console.log('nextTick2');
})
});
运行结果是
nextTick1
nextTick2
setImmediate
nextTick3
setTimeout
从poll —> check阶段,先执行process.nextTick, 输出nextTick1 nextTick2 然后进入check队列执行setImmediate,输出setImmediate后,出check队列, 进入close callback前,执行process.nextTick, 输出nextTick3 最后进入timer执行setTimeout , 输出setTimeout
process.nextTick()是node早期版本无setImmediate时的产物,node作者推荐我们尽量使用setImmediate。
最后,让我们来再看一道面试题加深对Node事件环的理解:
setImmediate(() => {
console.log('setImmediate1');
setTimeout(() => {
console.log('setTimeout1')
}, 0);
});
Promise.resolve().then(res=>{
console.log('then');
})
setTimeout(() => {
process.nextTick(() => {
console.log('nextTick');
});
console.log('setTimeout2');
setImmediate(() => {
console.log('setImmediate2');
});
}, 0);
这道题的输出顺序是:then、setTimeout2、nextTick、setImmediate1、setImmediate2、setTimeout1,为什么是这样的顺序呢?微任务nextTick的输出是因为timers队列切换到check队列,setImmediate1和setImmediate2连续输出是因只有当前队列执行完毕后才能进去下一对列。