一、为什么JavaScript必须是单线程
所谓单线程,就是同一个时间只能做一件事。JavaScript从诞生之初就是作为浏览器的一种脚本语言,其主要用途是与用户互动,以及操作DOM,而这就决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这个时候浏览器就不知道该如何处理了,到底是应该在节点上添加内容还是应该删除这个节点呢?
虽然为了利用CPU的多核计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM,所以,这个新标准并没有改变JavaScript单线程的本质。
二、为什么单线程还能实现异步
对于大多数语言而言,实现异步可以通过启动额外的进程、线程或协程来实现,也就是说仅仅靠一个进程或线程是无法实现异步功能的。而我们的JavaScript实现异步功能也不例外,也是将一些异步操作交给其他线程进行处理,比如最常用的setTimeout,就是要将异步操作交给定时器线程进行处理。然后通过事件循环机制来处理异步操作的结果。发起异步操作的时候通常会传入一个回调函数,当执行异步操作的线程处理完成后,会将回调函数放入到任务队列中,最后在未来的某轮事件循环tick中获取并执行这个回调函数。
三、浏览器中的事件循环
浏览器中的事件循环会涉及到一个任务队列(task queue)的概念。所有的异步任务执行完毕后都会将对应的回调函数放到任务队列中,并进行排队依次处理。而任务队列又分为宏任务队列和微任务队列,并且只有在主线程的调用栈被清空的时候,才会执行任务队列中的任务,这也就是所谓的JavaScript的运行机制。
浏览器宏任务主要有setTimeout()、setInterval()。
浏览器微任务主要有Promise.then()、requestAnimationFrame()。
并且微任务优先级要高于宏任务。当主线程调用栈空闲时,就会检测任务队列中是否有任务要执行,首先看一下微任务队列中是否有任务要执行,如果有则执行微任务队列中的任务,直到微任务队列清空为止,然后接着开始执行宏任务队列中的任务,宏任务清空后,又接着检测微任务队列中是否有任务,如此往复下去形成事件循环。
这里需要注意的就是浏览器是多线程的,主要为UI渲染线程、JS引擎线程、GUI线程(主要用于处理事件交互),其中,JS引擎线程和UI渲染线程是互斥的,即,如果JS引擎主线程在执行,那么UI将无法进行渲染,因为JS引擎线程是可以进行DOM操作的,只有互斥才能保证不会出现UI引擎在渲染的同时,JS引擎线程同时在修改DOM,如页面中有一个按钮,点击按钮后会开始一段耗时比较长的计算,这里要求实现点击按钮后按钮文字显示"计算中",计算完成后,按钮文字显示"计算完成"。
<body>
<button id="btn">点我</button>
</body>
let btn = document.getElementById("btn");
function long_running() {
console.log("long_running");
var result = 0;
for(var i = 0; i < 1000; i++) {
for(var j= 0; j < 1000; j++) {
for(var k=0; k< 1000; k++) {
result = result + i + j + k;
}
}
}
btn.innerHTML = "计算完成";
}
btn.addEventListener("click", (e) => {
btn.innerHTML = "计算中...";
long_running();
});
运行如上代码,我们可以发现点击按钮后并没有先变成"计算中",然后再变成"计算完成",而是点击之后无变化,然后等计算完成后直接变成了"计算完成"。因为btn.innerHTML = "计算中...";是进行DOM操作,使用的UI渲染线程,此时,JS引擎线程调用栈还未清空(还需要往下执行long_running()方法),所以还不能立即执行任务队列中的方法,然后执行long_running(),long_running()不是异步任务,不进入到任务队列中,直接进入到主线程的调用栈中执行,由于耗时比较长,等long_running()中的耗时计算执行完成后,接着执行btn.innerHTML = "计算完成",同样是UI渲染,此时long_running()执行完毕,主线程调用栈被清空,开始检测是否有微任务需要执行,发现没有,接着将线程执行权限交给UI渲染线程,UI渲染需要执行btn.innerHTML = "计算中..."; btn.innerHTML = "计算完成";两个任务,执行速度非常快,所以直接显示"计算完成"了,此时UI渲染线程工作执行完毕,接着将线程执行权限交回给JS引擎线程,接着检测是否有宏任务要执行。要实现btn.innerHTML = "计算中..."先执行,再进行计算,最后执行btn.innerHTML = "计算完成",我们需要给long_running()添加一个延时,让计算过程进入到宏任务队列中,不再占用主线程调用栈,用户点击之后发起了UI渲染和一个宏任务,click事件结束后发现没有微任务,线程权限交给UI渲染线程,渲染出计算中...,UI渲染线程工作完毕,线程权限交回JS引擎线程,发现有宏任务要执行,执行长时间的计算过程后,又发起了一个UI渲染,long_running执行完毕,发现没有微任务,线程权限再次交给UI渲染引擎,渲染出计算完成,线程权限再次交回JS引擎线程。如:
btn.addEventListener("click", (e) => {
btn.innerHTML = "计算中...";
setTimeout(() => {
long_running();
}, 0);
});
添加延时后,long_running();也进入到了任务队列中,所以会先执行btn.innerHTML = "计算中...";再执行long_running();等计算完成后再更新为"计算完成"。
四、NodeJS中的事件循环
NodeJS的运行环境与浏览器的运行环境有些许不同,所以事件循环也有些许不同。同样的NodeJS的核心是V8,V8本身没有异步运行的能力,需要借助其他线程来实现,NodeJS的异步能力是依赖于libuv库。我们先来看看NodeJS每一轮事件循环经历的过程。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
- timers: 计时器阶段,用于处理setTimeout以及setInterval的回调函数
- pending callbacks: 用于执行某些系统操作的回调,例如TCP错误
- idle, prepare: Node内部使用,不用做过多的了解
- poll: 轮询阶段,执行队列中的 I/O 队列,并检查定时器是否到时
- check: 执行setImmediate的回调
- close callbacks: 处理关闭的回调,例如 socket.destroy()
我们平时经常用到的也就是timers 、poll 、check三个阶段,这三个阶段都有对应的宏任务队列,并且在进入下一个阶段之前要先检测微任务队列中有没有微任务,只有先清空微任务中的任务才能进入事件循环的下一个阶段,注意不是下一轮事件循环而是事件循环的下一个阶段。
NodeJS中宏任务和微任务类型也有所不同:
NodeJS中的宏任务主要有setTimeout 、setInterval 、setImmediate。
NodeJS中的微任务主要有Promise.then 、process.nextTick,并且nextTick的优先级要高于Promise.then。因为nextTick微任务有一个单独的队列,称为 next tick queue,所以可以做到先清空next tick queue。
我们先看一个例子,以便了解promise.then,process.nextTick, setTimeout以及setImmediate的执行顺序。如:
// NodeJS运行环境
setImmediate(function(){ // 宏任务1
console.log(1);
},0);
setTimeout(function(){ // 宏任务2
console.log(2);
},0);
new Promise(function(resolve){
console.log(3);
resolve();
console.log(4);
}).then(function(){ // 微任务1
console.log(5);
});
console.log(6);
process.nextTick(function(){ // 微任务2
console.log(7);
});
console.log(8);
根据上面提到的NodeJS中的事件循环过程可知,首先promise.then和process.nextTick属于微任务,setTimeout和setImmediate属于宏任务,并且process.nextTick的优先级要高于promise.then,setTimeout的优先级高于setIImmediate。
首先执行整体代码,产生了两个宏任务,然后创建Promise执行同步代码输出3和4,此时产生了一个微任务1,接着输出6,然后再产生了一个微任务2,接着输出8,此时整体代码执行完毕,然后检测微任务队列并执行(进入事件循环各阶段前需要先清空微任务队列),此时微任务队列中有两个,虽然微任务2后面添加进去,但是微任务2是由process.nextTick创建具有更高优先级,所以先执行微任务2,依次输出7和5,接着再执行宏任务,由于setTimeout比setImmediate具有更高优先级,所以先执行宏任务2,依次输出2和1,故最终结果为3、4、6、8、7、5、2、1。
那么是不是setTimeout一定比setImmediate先执行呢?答案是不一定,因为setImmediate属于check阶段,而check阶段在I/O poll阶段之后,所以如果是在I/O poll阶段之后加入任务队列,那么就会先执行setImmediate了。
const fs = require("fs");
fs.readFile("./a.js", (err, data) => {
// 执行I/O poll阶段时加入timer和check阶段任务队列
setTimeout(() => {
console.log("setTimeout");
}, 0);
setImmediate(() => {
console.log("setImmediate");
}, 0);
});
当a.js文件读取完毕之后,发现I/O poll阶段任务队列中有任务要执行,于是开始执行readFile的回调函数,在执行过程中产生两个宏任务队列,分别加入到timer是任务队列、check任务队列中,此时I/O poll阶段任务队列清空,接着进入到check阶段的任务队列,发现有任务要执行,所以会先执行setImmediate回调,然后在下一轮事件循环中执行setTimeout回调。
五、常见示例
① 示例1
在宏任务执行过程中产生了微任务,优先执行微任务再执行下一个宏任务。
setTimeout(() => console.log('setTimeout1'), 0); //1宏任务
setTimeout(() => { //2宏任务
console.log('setTimeout2');
Promise.resolve().then(() => { // 2微任务
console.log('promise3');
Promise.resolve().then(() => { // 3微任务
console.log('promise4'); // 4微任务
})
console.log(5)
})
setTimeout(() => console.log('setTimeout4'), 0); //4宏任务
}, 0);
setTimeout(() => console.log('setTimeout3'), 0); //3宏任务
Promise.resolve().then(() => {//1微任务
console.log('promise1');
})
首先按顺序执行主线程代码,产生了1、2、3三个宏任务,进入宏任务队列,然后执行到最后一行Promise的时候产生了一个微任务,进入微任务队列,在执行宏任务前会先检查微任务队列中是否有任务,发现有一个,所以首先输出promise1,微任务清空后,接着进入到宏任务队列,执行第一个宏任务,输出setTimeout1,接着执行第二个宏任务,输出setTimeout2,同时产生了一个微任务2和宏任务4,执行宏任务过程中产生了微任务2,所以先执行微任务2,输出promise3,又产生了一个微任务4,接着输出5,还有微任务,继续执行微任务4,输出promise4,此时微任务已清空,可以继续执行宏任务3,输出setTimeout3和setTimeout4。
② 示例2
new Promise((resolve,reject)=>{ // new创建的Promise对象P0
console.log("promise1")
resolve()
}).then(()=>{ // then方法创建的Promise对象P1,微任务1
console.log("then1-1-start")
new Promise((resolve,reject)=>{ // // new创建的Promise对象P3
console.log("promise2")
resolve()
}).then(()=>{ // then方法创建的Promise对象P4 微任务3
console.log("then2-1")
}).then(()=>{ // then方法创建的Promise对象P5 微任务4
console.log("then2-2")
})
console.log("then1-1-end")
}).then(()=>{ // then方法创建的Promise对象P2 微任务2
console.log("then1-2")
})
首先执行整体代码,立即输出promise1,之后同步执行了resolve,P0变为完成状态,调用then()方法创建了一个P1,并产生微任务1,接着执行then方法又创建了一个P2,处于等待状态,并产生微任务2,由于P0已经处于完成状态,所以执行微任务1,输出then1-1-start,接着创建P3,输出promise2,同时产生微任务3,接着输出then1-1-end,这里主要分清微任务2和微任务3哪个先执行,虽然微任务2先注册,但是由于注册的时候P1处于pending状态,所以会先将微任务2放到P1的一个数组中(不是微任务队列),而P3已经处于完成状态,调用then的时候会立即将微任务3推到微任务队列中,此时微任务1执行完成,然后根据微任务1的返回值改变P1的状态,因为返回值为undefined,所以可以执行P1的resolve方法变为完成状态,在执行P1的resolve的时候会遍历刚刚放到P1数组中的微任务2,将微任务2推到微任务队列中,所以会先执行微任务3再执行微任务2,输出then2-1,then1-2,微任务3执行完成后,执行P4的resolve()方法,遍历P4数组中的微任务4,将微任务4推入微任务队列中,最后输出then2-2。
③ 示例3
new Promise((resolve,reject)=>{ // new创建的Promise对象P0
console.log("promise1")
resolve()
}).then(()=>{ // then方法创建的Promise对象P1 微任务1
console.log("then1-1-start")
return new Promise((resolve,reject)=>{ // new创建的Promise对象P3
console.log("promise2")
resolve()
}).then(()=>{ // then方法创建的Promise对象P4 微任务3
console.log("then2-1")
}).then(()=>{ // then方法创建的Promise对象P5 微任务4
console.log("then2-2")
})
console.log("then1-1-end")
}).then(()=>{ // then方法创建的Promise对象P2 微任务2
console.log("then1-2")
})
示例3和示例2只是在示例2的基础上,在then1中添加了一个return,那么其输出也会跟着发生一些变化,前面还是一样,依次输出promise1、then1-1-start,加了return后,那么then1-1的返回值就变成了内部Promise的最后一个then返回的Promise对象P5了,此时返回的P5为Pending状态,所以then1-2回调必须等待P5对象变为resolve状态才能执行。所以依次输出promise2、then2-1、then2-2,最后输出then1-2,因为then1-1-end在return之后,是不可达代码,所以无法输出then1-1-end。这题的关键点就是,then1-2这个微任务的执行,需要等待P1变成完成状态,而P1要想变为完成状态又必须依赖P5,而P5也是一个Promise对象,所以需要根据P5的value来决定是否能够将P1变成完成状态,P5要想拿到value,就必须等待P4变成完成状态。
④ 示例4
console.log(1);
setTimeout(function() { // 宏任务1
console.log('2');
process.nextTick(function() { // 微任务3
console.log('3');
});
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() { // 微任务4
console.log('5');
});
}, 0);
process.nextTick(function() { // 微任务1
console.log('6');
});
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() { // 微任务2
console.log('8')
});
setTimeout(() => { // 宏任务2
console.log('9');
process.nextTick(function() { // 微任务5
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() { // 微任务6
console.log('12');
});
}, 0);
首先执行整体代码,输出1,并产生宏任务1,接着产生一个微任务1,接着创建Promise执行同步代码输出7,并产生微任务2,最后产生一个宏任务2;接着清空微任务队列,微任务队列中有两个任务,并且nextTick优先级比Promise.then优先级更高,故依次输出6和8;然后再执行宏任务队列,先执行宏任务1,输出2,并产生微任务3,接着创建Promise执行同步代码输出4,然后产生微任务4,由于产生了微任务3和微任务4,故输出3和5,继续执行宏任务2,输出9,产生微任务5,接着创建Promise执行同步代码输出11,并产生微任务6,此时宏任务1和2执行完毕,期间又产生了微任务5和6,接着清空微任务队列,输出10和12。故最终输出结果为1、7、6、8、2、4、3、5、9、11、10、12
⑤ 示例5
async function async1() {
console.log('async1 start')
await async2();
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('script start')
setTimeout(() => {
console.log('setTimeout0')
},0)
setTimeout(() => {
console.log('setTimeout3')
},3)
setImmediate(() => {
console.log("setImmediate");
});
async1();
new Promise((resolve) => {
console.log("promise1");
resolve();
console.log("promise2");
}).then(() => {
console.log("promise3");
});
process.nextTick(() => {
console.log("nextTick");
});
console.log("scritp end.");
这道题主要考察的是async函数的执行原理,async函数会返回一个Promise对象,当函数执行的时候,一旦遇到await就会立即返回,但是要等到await后的代码执行完成后才能回到主线程,即接着执行函数外的同步代码,函数外的同步代码执行完成后再回到async函数内接着执行,并且之间如果产生了nextTic微任务,那么需要先执行nextTic微任务,但是Promise.then微任务在await之后执行。
首先执行整体代码,输出script start,然后setTimeout0、setTimeout3、setImmediate进入到宏任务队列,接着执行async1函数,输出async1 start,然后遇到await,async1函数立即返回,但是还要等到await之后的代码async2执行完毕,async2执行完成输出async2,此时回到主线程继续执行,即执行Promise中的同步代码,输出promise1、promise2,然后产生一个promise3微任务,接着nextTick也进入到微任务对列,接着输出scritp end,此时主线程执行完毕,即主线程调用栈已经被清空,接着检测是否有nextTick微任务队列,发现有,开始执行nextTick微任务队列,输出nextTick,此时再次回到async1()执行剩余的代码,输出async1 end,此时还有Promise.then微任务,清空微任务队列,输出promise3,接着再执行宏任务队列中的代码,setTimeout0和setImmediate时间都是0,并且setTimeout0优先级更高,依次输出setTimeout0、setImmediate,最后输出setTimeout3。
六、整个JS执行过程
当JS中引入了宏任务和微任务后,渲染引擎的执行时机是介于微任务和宏任务之间的,也就是说微任务执行结束后再执行JS渲染接着再执行宏任务。如图所示:
let total = 100000;
const startTime = Date.now();
const container = document.getElementById("container");
Promise.resolve().then(() => {
console.log("微任务执行完毕,UI渲染线程开始渲染UI,操作DOM。");
});
setTimeout(() => {
console.log("在长时间计算前加入宏任务队列");
console.log(`执行时间---: ${Date.now() - startTime}`);
}, 0);
let result = 0;
for(var i = 0; i < 1000; i++) {
for(var j= 0; j < 1000; j++) {
for(var k=0; k< 1000; k++) {
result = result + i + j + k;
}
}
}
console.log("计算结束");
for (let i = 0; i < total; i++) {
const li = document.createElement("li");
li.innerHTML = i;
container.appendChild(li);
}
console.log("发起UI渲染结束");
setTimeout(() => {
console.log("UI渲染线程渲染UI完毕,开始执行宏任务。");
console.log(`UI渲染线程执行时间: ${Date.now() - startTime}`);
}, 0);
console.log(`JS主线程代码执行结束,其主线程JS执行时间: ${Date.now() - startTime}`);
首先JS主线程代码开始执行,遇到一个Promise产生一个微任务,加入到微任务队列中,接着遇到一个宏任务,加入到宏任务队列中,需要注意的是setTimeout时间为0也不是立即进入宏任务队列,而是要等待4ms,为保证该宏任务在UI渲染前加入宏任务队列,引入了一个耗时很长的计算,接着执行长计算,输出计算结束,接着执行for循环操作,发起UI渲染,其中包含了DOM操作,即UI渲染,但此时无法立即渲染,需要等到JS主线程代码和微任务代码执行完毕之后再进行UI渲染,for循环结束后输出发起UI渲染结束,接着遇到一个宏任务,加入到宏任务队列中,此时输出JS主线程代码执行结束,其主线程JS执行时间: 13351,JS主线程代码执行完毕,接着清空微任务队列,输出微任务执行完毕,UI渲染线程开始渲染UI,操作DOM。然后线程执行权限交给UI渲染线程,开始执行UI渲染,等待一段时间后UI渲染完毕,线程执行权限交回给JS引擎线程,开始执行宏任务,此时输出在长时间计算前加入宏任务队列和执行时间---: 16334,UI渲染线程渲染UI完毕,开始执行宏任务和UI渲染线程执行时间: 16475。可以看到不管setTimeout在for循环(UI渲染)前还是后,setTimeout回调都是在UI渲染完成后执行。
有些文章说UI渲染也属于宏任务队列是不对的,从上面的输出结果可以看出,如果UI渲染也属于宏任务队列的话,那么第一次setTimeout肯定比UI渲染先进入宏任务队列,应该先执行第一个setTimeout回调,但是两次setTimeout输出的时间相差不大,所以说UI渲染是在宏任务之前执行的。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。