我们都知道在浏览器中由于dom操作,js是单线程的。
为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。
多线程并不意味着速度就快,因为切换时间片需要时间。因为单线程的原因,事件循环就应运而生了。由于nodejs基于js,所以两者都在处理异步事件时都依赖于事件循环,不过两者的事件循环机制有相同又有不同。在了解两者的事件循环机制之前先了解关于异步任务的两个概念:
微任务(Microtask)
通常来说就是在当前 task 执行结束后立即执行的任务(也就是总是先于宏任务),例如需要对一系列的任务做出回应,或者是需要异步的执行任务而又不需要分配一个新的 task,这样便可以减小一点性能的开销。microtask 任务队列是一个与 task 任务队列相互独立的队列,microtask 任务将会在每一个 task 任务执行结束之后执行。每一个 task 中产生的 microtask 都将会添加到 microtask 队列中,microtask 中产生的 microtask 将会添加至当前队列的尾部,并且 microtask 会按序的处理完队列中的所有任务。
vue中的nextTick实现:Vue 在内部尝试对异步队列使用原生的setImmediate Promise.then和MessageChannel(旧版本采用mutationObserver,因为兼容性问题后面弃用),如果当前执行环境不支持,就采用setTimeout(fn, 0)代替。
// MutationObserver示例:
let observe = new MutationObserver(function () {
console.log('dom全部塞进去了');
});
// 也是一个微任务
observe.observe(div,{childList:true});
for (let i = 0; i < 100; i++) {
let p = document.createElement('p');
div.appendChild(p);
}
console.log(1);
let img = document.createElement('p');
div.appendChild(img);
// MessageChannel用法:
console.log(1);
let channel = new MessageChannel();
let port1 = channel.port1;
let port2 = channel.port2;
// 异步代码 vue 就是宏任务
port1.postMessage('hello');
port2.onmessage = function (e) {
console.log(e.data);
}
console.log(2);
// 1 2 hello
// webwork(不能操作dom)相当于开了一个线程,用法:
// index.html中的js
console.log(1);
let worker = new Worker('./worker.js');
worker.postMessage(1000); // 发消息
worker.onmessage = function (e) { // 接收消息
console.log(e.data); // 消息中的数据
}
console.log(2);
// worker.js
onmessage = function (e) {
let sum = 0;
for(var i = 0;i<e.data;i++){
sum += i;
}
this.postMessage(sum)
}
通过代码运行来理解浏览器和node事件循环机制的不同:
// 在浏览器,node下分别测试下面的一段代码:
setTimeout(() => {
console.log('1')
Promise.resolve('123').then(data => {
console.log(2)
});
});
setTimeout(() => {
console.log('3');
});
//浏览器下 1 2 3
//node环境下:node 11及以上的版本一直输出: 1 2 3,跟浏览器一样。node 10 及以下的输出既有 1 2 3 也有 1 3 2,但是 1 3 2的次数比较多。
原理:
浏览器 :执行栈中内容执行后执行微任务,微任务清空后再执行宏任务,到达条件的宏任务最终会在栈中执行,不停的循环event loop。所以上面的代码在输出1后,会先执行Promise微任务,然后再去执行任务队列里面;
node:微任务总是在新一轮事件循环开始之前执行,所以先执行完所有到达时间的setTimeout,然后在进入下一轮事件循环之前再执行Promise.resolve。下面的node事件环可以加强理解:
node启动过程
- 1、调用platformInit方法 ,初始化 nodejs 的运行环境。
- 2、调用 performance_node_start 方法,对 nodejs 进行性能统计。
- 3、openssl设置的判断。
- 4、调用v8_platform.Initialize,初始化 libuv 线程池。
- 5、调用 V8::Initialize,初始化 V8 环境。
- 6、创建一个nodejs运行实例。
- 7、启动上一步创建好的实例。
- 8、开始执行js文件,同步代码执行完毕后,进入事件循环。
- 9、在没有任何可监听的事件时,销毁 nodejs 实例,程序执行完毕。
在libuv(是用C语言实现的一套异步功能库,nodejs高效的异步编程模型很大程度上归功于libuv的实现)内部有这样一个事件环机制。在node启动时会初始化事件环
┌───────────────────────┐
┌─>│ timers(计时器) │
| | 执行setTimeout以及 |
| | setInterval的回调。 |
│ └──────────┬────────────┘
微任务
│ ┌──────────┴────────────┐
│ │ I/O callbacks |
│ | 处理网络、流、tcp的错误 |
| | callback |
│ └──────────┬────────────┘
微任务
│ ┌──────────┴────────────┐
│ │ idle, prepare │
| | node内部使用 |
│ └──────────┬────────────┘
微任务
│ ┌──────────┴────────────┐ ┌───────────────┐
│ │ poll(轮询) │ │ incoming: │
| | 执行poll中的i/o队列 | <─────┤ connections, │
| | 检查定时器是否到时 | │ data, etc.读取文件 |
│ └──────────┬────────────┘ └───────────────┘
微任务
│ ┌──────────┴────────────┐
│ │ check(检查) │
| | 存放setImmediate回调 |
│ └──────────┬────────────┘
微任务
│ ┌──────────┴────────────┐
└──┤ close callbacks |
│ 关闭的回调例如 |
| sockect.on('close') |
└───────────────────────┘
这里每一个阶段都对应一个事件队列,当event loop执行到某个阶段时会将当前阶段对应的队列依次执行。当队列执行完毕或者执行的数量超过上线时,会转入下一个阶段。微任务总是在开始新一轮循环时执行。
结合上面的流程图,可以总结出node事件循环原理:
-
node 的初始化
- 初始化 node 环境。
- 执行输入代码。
- 执行 process.nextTick 回调。
- 执行 microtasks。
-
进入 event-loop
-
进入 timers 阶段
- 检查 timer 队列是否有到期的 timer 回调,如果有,将到期的 timer 回调按照 timerId 升序执行。(node 10 及以下的版本:会清空所有的 timer 回调,再去执行检查下面的微任务。node 11 及以上的版本:每执行一个 timer 回调就会去检查一次微任务队列,如果有,全部执行;然后再继续执行下一个 timer ,跟浏览器一致。)
- 检查是否有 process.nextTick 任务,如果有,全部执行。
- 检查是否有microtask,如果有,全部执行。
- 退出该阶段。
-
进入IO callbacks阶段。
- 检查是否有 pending 的 I/O 回调。如果有,执行回调。如果没有,退出该阶段。
- 检查是否有 process.nextTick 任务,如果有,全部执行。
- 检查是否有microtask,如果有,全部执行。
- 退出该阶段。
-
进入 idle,prepare 阶段:
- 这两个阶段与我们编程关系不大,暂且按下不表。
-
进入 poll 阶段
- 首先检查是否存在尚未完成的回调,如果存在,那么分两种情况。
- 第一种情况:
- 如果有可用回调(可用回调包含到期的定时器还有一些IO事件等),执行所有可用回调。
- 检查是否有 process.nextTick 回调,如果有,全部执行。
- 检查是否有 microtaks,如果有,全部执行。
- 退出该阶段。
- 第二种情况:
- 如果没有可用回调。
- 检查是否有 immediate 回调,如果有,退出 poll 阶段。如果没有,阻塞在此阶段,等待新的事件通知。
- 第一种情况:
- 如果不存在尚未完成的回调,退出poll阶段。
- 首先检查是否存在尚未完成的回调,如果存在,那么分两种情况。
-
进入 check 阶段。
- 如果有immediate回调,则执行所有immediate回调。
- 检查是否有 process.nextTick 回调,如果有,全部执行。
- 检查是否有 microtaks,如果有,全部执行。
- 退出 check 阶段
-
进入 closing 阶段。
- 如果有immediate回调,则执行所有immediate回调。
- 检查是否有 process.nextTick 回调,如果有,全部执行。
- 检查是否有 microtaks,如果有,全部执行。
- 退出 closing 阶段
-
检查是否有活跃的 handles(定时器、IO等事件句柄)。
- 如果有,继续下一轮循环。
- 如果没有,结束事件循环,退出程序。
-
在事件循环的每一个子阶段退出之前都会按顺序执行如下过程: 检查是否有 process.nextTick 回调,如果有,全部执行。 检查是否有 microtaks,如果有,全部执行。 退出当前阶段。
下面通过一些可能遇到的面试题加强理解:
// node下执行以下代码
setImmediate(function(){
console.log('1');
});
setTimeout(function(){
console.log('2');
});
// 由于node存在准备时间,两者的输出顺序是不一定的。
// 但是稍做修改:
let fs =require('fs')
fs.readFile('./1.txt','utf8',()=>{
setImmediate(function(){
console.log('1');
});
setTimeout(function(){
console.log('2');
});
})
// 1 2 结果永远是 1 2,即使setTimeout放在setImmediate前面。因为poll(轮询) 后就执行setImmediate。而setTimeout必须是在新一轮循环中才会执行
//node下执行:
process.nextTick(function A() {
console.log(1);
process.nextTick(function B(){console.log(2);});
});
setTimeout(function timeout() {
console.log('TIMEOUT FIRED');
}, 0)
// 1 2 TIMEOUT FIRED ;不管有多少个process.nextTick语句(不管它们是否嵌套),将全部在当前"执行栈"执行完之后执行。也就是说微任务总是在新一轮事件循环之前执行。
//node下执行:
setImmediate(function () {
console.log('4')
})
setImmediate(function () {
console.log('5')
})
process.nextTick(function () {
console.log('1')
process.nextTick(function () {
console.log('2')
process.nextTick(function () {
console.log('3')
})
})
})
console.log('next')
// 总是输出 next 1 2 3 4 5。在同步代码执行完后,执行微任务,然后进入下一轮事件循环
总结:
异步任务分为:微任务和宏任务
- 浏览器端:宏任务主要有setTimeout,setInterval,setImmediate(ie),messageChannel,ajax请求,click事件等。微任务主要有Promise.then,mutationObserver。先执行栈里面的内容,栈执行完后,执行微任务,然后再读取异步队列,有到达执行条件的,读取到执行栈中执行。如此循环。
- node端:宏任务主要有setTimeout,setInterval,setImmediate,文件读写等。微任务主要有Promise.then,process.nextTick(比Promise.then更快执行)。
参考:
译文:JS事件循环机制(event loop)之宏任务、微任务: segmentfault.com/a/119000001…
[译] 深入理解 JavaScript 事件循环(二)— task and microtask:
www.cnblogs.com/dong-xu/p/7…
剖析nodejs的事件循环:juejin.cn/post/684490…
从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理:juejin.cn/post/684490…
阮一峰 node循环:www.ruanyifeng.com/blog/2018/0…