1.线程和进程
1.1 概念和关系
js是一种单线程语言,指的是一个进程里只有一个线程。那么进程和线程该如何理解它们的含义和区别呢:
进程是 CPU资源分配的最小单位;线程是 CPU调度的最小单位。
通俗地讲,进程类似于工厂,有自己专属的有CPU分配的工厂资源;线程类似于工厂中的工人,单个或多个工人在一个工厂中工作,也就是说一个进程由一个或多个线程组成,线程共享进程的资源和空间。
1.2 多进程和多线程
- 多进程
同一时间内,计算机允许多个进程处于运行状态,比如听歌软件的进程和游戏的进程,等等。 - 多线程
程序中包含多个执行流,一个程序中可以同时运行多个不同的线程来执行不同的任务。
以Chrome浏览器中为例,当打开一个Tab页时,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS引擎线程、HTTP请求线程等等。当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。
2.浏览器中的事件循环
2.1 宏任务和微任务
事件循环由宏任务和执行宏任务期间产生的微任务组成,完成当下的宏任务后,会立刻执行所有在此期间入队的微任务。
这种设计是为了给紧急任务一个插队的机会,否则新入队的任务永远被放在队尾。区分了微任务和宏任务后,本轮循环中的微任务实际上就是在插队,这样微任务中所做的状态修改,在下一轮事件循环中也能得到同步。
宏任务队列可以有多个,微任务队列只有一个。
常见的宏任务包括:
- script(整体代码)
- setTimeout
- setInterval
- setImmediate(node独有)
- I/O
- UI render(浏览器独有)
- requestAnimationFrame(浏览器独有)
常见的微任务包括:
- new Promise().then()
- await后产生的微任务
- process.nextTick(node独有)
- MutationObserver(html5新特性)
2.2 事件循环流程
2.2.1 流程解析
以这个图为范例理解:
详细流程解释:
(1)一开始执行栈为空,微任务队列为空,宏任务里有且仅有一个script脚本(整体代码)。
(2)script从宏任务队列中被推入执行栈,同步代码开始执行。在执行过程中,js判断某些任务是同步还是异步,执行任务时,会产生新的宏任务和微任务,它们分别被推入到各自的任务队列中。
(3)同步代码执行完毕后,script被移出执行栈,移出宏任务队列,这个过程本质上是宏任务队列的执行和出队的过程。
(4)某个宏任务执行完后,开始处理微任务队列,但是微任务队列和宏任务队列的区别在于:前者一队一队执行,后者一个一个执行。因此处理微任务队列时,会处理掉队里的所有微任务直到队列为空。
(5)执行渲染操作,更新页面,检查是否存在Web worder任务,若有则对其进行处理,随后循环往复上述过程,直到两条队列都被清空。
长话短说解释:
先执行某个宏任务(第一个宏任务就是整体的同步代码),执行完后查看微任务队列中是否有微任务:
若有,将队列中的任务全部执行完毕直到清空(若微任务执行途中又产生微任务,则继续将新的微任务加入队列执行);
若队列为空,继续执行宏任务队列中接下来的宏任务,以此类推。
2.2.2 范例
范例一
Promise.resolve().then(() =>{
console.log('promise1');
setTimeout(() =>{
console.log('setTimeOut2');
} , 0)
})
setTimeout(() =>{
console.log('setTimeOut1');
Promise.resolve().then(() =>{
console.log('promise2');
})
} , 0)
console.log('hello world')
//hello world
//promise1
//setTimeOut1
//promise2
//setTimeOut2
-
一开始先执行“script内整体代码”这个宏任务,同步任务执行期间遇到Promise1这个微任务,将其加入微任务队列。随后看到setTimeOut1这个宏任务,将其加入宏任务队列,代码跑到最后一行看到句hello world,将其打印。
-
第一个宏任务执行完后(同步代码跑完)去查看微任务队列,发现有个刚刚加进去的Promise1,因此先打印出promise1,同时生成一个宏任务setTimeOut2加入宏任务队列。
-
微任务队列清空后来看宏任务队列,队列中有着最先加入的setTimeOut1和后加入的setTimeOut2,先执行前者。首先打印setTimeOut1,随后将promise2这个微任务加入到微任务队列中。
-
再次来到微任务队列,执行promise2微任务,打印promise2。
-
微任务队列清空,再回到宏任务队列,剩个setTimeOut2,打印出来。
这里要注意一个点:Promise不是微任务,Promise.then()/catch()/finally()才是,比如这个例子:
console.log(1)
let promise = new Promise(function (resolve, reject) {
console.log(3)
resolve(100)
console.log(4)
}).then(function (data) {
console.log(data)
})
console.log(2)
//1 3 4 2 100
如果Promise自己就是微任务,那么答案就应该是1 2 3 4 100了,很显然答案不符。
范例二,加入async/await
async隐式返回Promise作为结果的函数,那么可以简单理解为,await后面的函数执行完毕时,await会产生一个微任务。但是我们要注意这个微任务产生的时机,它是执行完await之后,直接跳出async函数,执行其他代码。其他代码执行完毕后,再回到async函数去执行剩下的代码,然后把await后面的代码注册到微任务队列当中。
来看这个例子:
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()
setTimeout(function () {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function () {
console.log('promise1')
})
.then(function () {
console.log('promise2')
})
console.log('script end')
-
开始执行script宏任务,打印script start
-
执行async1(),首先调用async2()。进入async2(),打印async2 end。async1中的await等到了结果,但是此时会保留async1()的上下文,并跳出async1函数。
-
代码从第10行async1()开始继续执行,遇到setTimeout,将其添加至宏任务中。
-
遇到Promise,直接执行构造函数内内容,打印Promise,resolve()后遇到then,将微任务添加至微任务队列。
-
继续执行完第一个宏任务的同步代码,打印script end
-
宏任务执行完去找微任务队列,里面装着两个then方法,打印promise1和promise2
-
微任务队列执行完毕,此时回到async1,然后执行await后面的代码注册到微任务队列中,接着执行该微任务,打印async1 end
-
最后这一轮的宏任务和随之伴生的微任务都处理完了,执行下一个宏任务,打印setTimeout。
那么按上面的分析来看,结果应该是这个:
// script start => async2 end => Promise => script end => promise1 => promise2 => async1 end => setTimeout
但是事实上结果是这样的:
也就是把await后的执行提前了,先打印了async1 end。
原因是新版的浏览器中优化了await的输出速度,导致结果改变了,可以分两种情况来理解:
(1)await后面跟的是一个变量
这种情况相当于先把await后面的代码注册为一个微任务(对比旧版是先跳出再注册),然后跳出async函数,执行别的代码。因此这种情况下就是先打印async1 end,再打印接下来两个then里的promise1,promise2。
(2)await后面跟的是个异步函数的调用
如果把上面例子中的async2函数改成这样,返回一个promise.then()方法:
async function async2() {
console.log('async2 end')
return Promise.resolve().then(()=>{
console.log('async2 end2')
})
}
结果会变成这样:
此时await后面的代码又变成先跳出后注册了。所以第一轮微任务的实际顺序是:
-
执行async1(),随后执行async2(),打印语句后将async2中的then方法先推进微任务队列,然后await等到了async2()的执行完毕,跳出async1()
-
注册下方Promise内的两个then微任务
-
别的微任务代码运行完后,回到async1,注册await下面的代码进微任务,因此最后打印async1 end
3.Node中的事件循环
3.1 简介
事件循环是node处理非阻塞I/O操作的机制,node中事件循环的实现是依靠的libuv引擎。
3.2 六个阶段
node事件循环分为6个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。 当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。
六个阶段分别是:
-
timers:这个阶段执行timer(setTimeout、setInterval)的回调
-
I/O callbacks:处理一些上一轮循环中的少数未执行的 I/O 回调
-
idle,prepare:仅node内部使用
-
poll:获取新的I/O事件, 适当的条件下node将阻塞在这里
-
check:执行 setImmediate() 的回调
-
close callbacks:执行 socket 的 close 事件回调
执行顺序大致是这样:
外部输入数据->poll->check->......->poll->......
3.3 重要的三个阶段
3.3.1 timers
timers阶段会执行setTimeout和setInterval回调,并且是由 poll 阶段控制的。
3.3.2 poll
poll阶段执行流程如下:
-
如果当前有定时器且时间到,回到timers阶段执行
-
如果没有定时器,查看poll队列中是否有回调函数,若有则依次同步执行直至队列为空或者受到系统限制
-
如果poll队列为空:
- 如果有setImmediate需要执行,poll阶段会被立刻停止并进入到check阶段进行回调
- 如果没有setImmediate需要执行,会在设置的超时时间内等待回调被加入到队列中,加入后立即执行回调
3.3.3 check
这个阶段直接执行setImmediate的回调
这里看个例子:
console.log('start')
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function () {
console.log('promise1')
})
}, 0)
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(function () {
console.log('promise2')
})
}, 0)
Promise.resolve().then(function () {
console.log('promise3')
})
console.log('end')
//start=>end=>promise3=>timer1=>timer2=>promise1=>promise2
如果是浏览器中,顺序应该是(原理就不写了,比较好理解):
// start=>end=>promise3=>timer1=>promise1=>timer2=>promise2
为什么在node中会有不同呢?讲一下Node的执行流程:
-
一开始都没区别,打印start和end,期间将两个setTimeout依次加入timer队列,然后先去执行微任务,打印出promise3
-
进入timers阶段,先打印timer1,将promise1放入微任务队列,然后打印timer2。(这就是和浏览器端不同之处,timers阶段有几个setTimeout/setInterval回调函数就先执行掉几个,不像浏览器执行掉一个就去执行微任务了)
-
随后打印promise1,promise2就不再赘述了
3.4 process.nextTick
process.nextTick是一个独立于eventLoop的任务队列。
在每一个eventLoop阶段完成后会去检查 nextTick 队列,如果里面有任务,会让这部分任务优先于微任务执行。
setImmediate(() => {
console.log('timeout1')
Promise.resolve().then(() => console.log('promise resolve'))
process.nextTick(() => console.log('next tick1'))
});
setImmediate(() => {
console.log('timeout2')
process.nextTick(() => console.log('next tick2'))
});
setImmediate(() => console.log('timeout3'));
setImmediate(() => console.log('timeout4'));
在 node11 之前,因为每一个 eventLoop 阶段完成后会去检查 nextTick 队列,如果里面有任务,会让这部分任务优先于微任务执行,因此上述代码是先进入 check 阶段,执行所有 setImmediate,完成之后执行 nextTick 队列,最后执行微任务队列,因此输出为timeout1=>timeout2=>timeout3=>timeout4=>next tick1=>next tick2=>promise resolve
在 node11 之后,process.nextTick 是微任务的一种,因此上述代码是先进入 check 阶段,执行一个 setImmediate 宏任务,然后执行其微任务队列,再执行下一个宏任务及其微任务,因此输出为
timeout1=>next tick1=>promise resolve=>timeout2=>next tick2=>timeout3=>timeout4
3.5 node11前后版本差异
node11的变化总的来说就一句话:一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate)就立刻执行对应的微任务队列(有点向浏览器看齐的意思)
3.5.1 timers
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function () {
console.log('promise1')
})
}, 0)
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(function () {
console.log('promise2')
})
}, 0)
//node11 timer1=>promise1=>timer2=>promise2
//node10 第一个定时器执行完 第二个定时器在完成队列中
//timer1=>timer2=>promise1=>promise2
//node10 第一个定时器执行完 第二个定时器不在完成队列中
//timer1=>promise1=>timer2=>promise2
3.5.2 check
setImmediate(() => console.log('immediate1'));
setImmediate(() => {
console.log('immediate2')
Promise.resolve().then(() => console.log('promise resolve'))
});
setImmediate(() => console.log('immediate3'));
setImmediate(() => console.log('immediate4'));
//node11 immediate1=>immediate2=>promise resolve=>immediate3=>immediate4
//node10 immediate1=>immediate2=>immediate3=>immediate4=>promise resolve
3.5.3 nextTick
见3.4中nextTick在版本改变前后的变化
3.5.4 总结
浏览器环境下,微任务的任务队列是每个宏任务执行完之后执行。
Node11之前,微任务会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行微任务队列的任务。
Node11之后,一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate)就立刻执行对应的微任务队列