前言
总所周知,JavaScript
这门语言是单线程的。那也就是说,JavaScript
在同一时间只能做一件事,后面的事情必须等前面的事情做完之后才能得到执行。
任务队列
JavaScript
单线程这件事乍一看好像没毛病,代码本来就是需要按顺序执行的嘛,先来后到,后面的你就先等着。如果是计算量导致的排队,那没办法,老老实实排吧。但如果是因为 I/O
很慢(比如发一个 Ajax
请求,需要 200ms 才能返回结果),那这个等待时间就没太必要了,完全可以先执行后面其他的任务,等你请求的数据回来了再执行 Ajax
后面的操作嘛。
由此,
JavaScript
中的任务分成了两种,第一种是同步任务,指的是在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;第二种是异步任务,指的是不进入主线程、而进入“任务队列”的任务,只有“任务队列”通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
其执行过程如下:
JavaScript
引擎运行JavaScript
时,有一个主线程和一个任务队列。- 同步任务跑在主线程上面,异步任务扔进任务队列中进行等待。
- 主线程中的任务执行完毕之后,回去看看任务队列中有没有异步任务到了需要触发的时机。如果有,那就开始执行异步任务。
- 重复的执行主线程的任务和轮询任务队列。
Event Loop
这种主线程不断地从任务队列中读取任务的机制称为 Event Loop
(事件循环)。
在讲 Event Loop
之前,我们先来了解一下 macrotask
(宏任务)和 microtask
(微任务)。
宏任务
包括 setTimeout
、setInterval
、setImmediate
(浏览器仅 IE10 支持)、I/O
、UI Rendering
。
微任务
包括 process.nextTick
(node
独有)、Promise
、Object.observe
(已废弃)、MutatinObserver
。
这里多说一句,Promise
的执行函数(也就是 new Promise(fn)
中的 fn
)是同步任务。
浏览器中的 Event Loop
Event Loop
的实现在浏览器和 node
中是不一样的,我们先看浏览器。
- 开始执行主线程的任务
- 主线程的任务执行完毕之后去检查
microtask
队列,将已经到了触发时机的任务放进主线程。- 主线程开始执行任务
- 主线程的任务执行完毕之后去检查
macrotask
队列,将已经到了触发时机的任务放进主线程。- 主线程开始执行任务
- 轮询
microtask
和microtask
看一下例子
好,讲完了流程,来看下🌰。
console.log('script start'); // 同步任务
setTimeout(function() {
console.log('setTimeout'); // 放入 宏任务 队列
}, 0);
new Promise((resolve, reject) => {
console.log('promise'); // 同步任务
resolve();
})
.then(function() {
console.log('promise1'); // 放进 微任务 队列
})
.then(function() {
console.log('promise2'); // 放进 微任务 队列
});
console.log('script end'); // 同步任务
复制代码
根据上面的标识,先执行同步任务,打印出 “script start” 、 “promise” 、 “script end”,然后开始检查 microtask
队列,打印出 “promise1” 和 “promise2”,然后去检查 macrotask
队列,打印出 “setTimeout”。
这里 setTimeout
虽然它的延迟时间为 0,但它是个宏任务,所以必须等同步任务和微任务执行完毕之后才轮到它。
在看一个🌰。
console.log('script start'); // 同步任务
async function async1() {
await async2();
console.log('async1 end'); // 这里就是 then 里面的代码,放入 微任务 队列
}
async function async2() {
console.log('async2 end'); // 同步任务
}
async1();
setTimeout(function() {
console.log('setTimeout'); // 放入 宏任务 队列
}, 0);
new Promise((resolve) => {
console.log('Promise'); // 同步任务
setTimeout(() => {
console.log('setTimeout promise'); // 放入 宏任务 队列
resolve();
});
})
.then(function() {
console.log('promise1'); // 放入 微任务 队列
})
.then(function() {
console.log('promise2'); // 放入 微任务 队列
});
console.log('script end'); // 同步任务
复制代码
这里的 async
和 await
就是 Promise
的语法糖,要懂得转换,其实上述 async
和 await
代码等价于:
new Promise((resolve) => {
new Promise((resolve) => {
console.log('async2 end');
resolve();
});
resolve();
}).then(() => {
console.log('async1 end');
});
复制代码
所以执行顺序为
script start
async2 end
promise
script end
async1 end
setTimeout
setTimeout promise
promise1
promise2
复制代码
有人可能会有疑惑了,打印 “promise1” 和 “promise2” 是微任务,怎么还晚于 setTimeout
宏任务呢?
虽然它们是微任务,但是由于触发它们的 resolve()
处于 setTimeout
宏任务之中,所以它们其实是在第二轮微任务的轮询中被触发的。
好了,浏览器的 Event Loop
就说到这个,接下来讲一下 node
的 Event Loop
。
node 中的 Event Loop
node
中的 Event Loop
就比较复杂了,英语好的可以去看官方文档。
引用官文档中的一张图,了解一下 Event Loop
的六个阶段。
每个阶段都有自己的任务队列。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
复制代码
6 个阶段
-
timer:执行
setTimeout
和setInterval
中到期的 callback。 -
pending callback:上一轮循环中少数的 callback 会放在这一阶段执行。
-
idle, prepare:仅在内部使用。
-
poll:最重要的阶段,执行 pending callback,在适当的情况下回阻塞在这个阶段。
-
check:执行
setImmediate
(setImmediate()
是将事件插入到事件队列尾部,主线程和事件队列的函数执行完成之后立即执行setImmediate
指定的回调函数)的 callback。 -
close callbacks:执行 close 事件的 callback,例如
socket.on('close'[,fn])
或者http.server.on('close, fn)
。
我们重点关注 timer
、poll
、check
这三个阶段。
timer
这个阶段执行该阶段任务队列中 setTimeout
和 setInterval
到期的回调,这两者需要设置一个时间。按规则来说,是到了设定的时间之后就应该执行回调,但在实际情况中,回调函数并不是一到设定的时间就能得到执行的,有可能被其他的任务阻塞了,需要等其他任务执行完成之后回调才能得到执行。
比如你设定一个 100ms 之后的 setTimeout
A 回调,但是在 95ms 时执行了一个其他的 B 任务,需要耗时 10ms,那么在时间来到 100ms 的时候,B 任务还在执行当中,那么此时并不会立即执行 A 回调,而是会再等 5ms,等 B 回调完成之后,然后系统发现 A 回调的触发时机已经到了,那赶紧去执行 A 回调。也就是说在这种情况下,A 回调会在 105ms 的时间被执行。
poll
poll 阶段主要有两个事情要做:
- 执行
I/O
回调。 - 处理轮询队列中的任务。
当事件循环到达 poll
阶段时,会有下面两种情况:
poll
队列不为空,那就开始执行队列中的任务,直到队列为空或者达到系统限制。poll
队列为空,那么这种情况又分两种情况;- 如果
check
阶段有setImmediate
任务需要执行,那么就立即结束当前阶段,转到check
阶段执行该阶段队列中的回调。 - 如果
check
阶段没有setImmediate
任务需要执行,那么此时会停留在poll
阶段进行等待,等待有任务进到任务队列中进行执行。
- 如果
在 2.2 的情况中,还会去检查 timer
阶段有没有任务到了执行时间,如果有,那么转入 timer
阶段执行队列中到期的任务。
check
此阶段会执行 setImmediate
回调,一旦此阶段的任务队列中有了 setImmediate
回调任务,且 poll
阶段的任务执行完了,处于空闲状态,那么就会立即转到 check
阶段执行此阶段任务队列中的任务。
转入此阶段的条件:check
任务队列中有了任务,poll
阶段处于闲置状态,或者 poll
阶段等待超时。
setTimeout 和 setImmediate
这两者很相似,也有些不同。
setImmediate
设计用于在当前poll
阶段完成后check
阶段执行脚本 。setTimeout
安排在经过最小设定时间后运行的脚本,在timers
阶段执行。
大部分时间 setImmediate
会比 setTimeout
先执行,但也有例外。比如下列代码:
setImmediate(() => {
console.log('setImmediate');
});
setTimeout(() => {
console.log('setTimeout');
});
复制代码
如果这两个任务是在 check
之后 timer
之前加入到各自阶段的任务队列中的,那么会先执行 setTimeout
,其他情况会先执行 setImmediate
。
总的来说,setImmediate
在大部分的情况下会比 setTimeout
先执行。
process.nextTick
从技术上来说,process.nextTick
并不属于 Event Loop
的一部分,它会在每个阶段执行完毕转入下一个阶段的之前执行。如果有多个 process.nextTick
语句(不管它们是否嵌套),都会在当前阶段结束之后全部执行。
比如:
process.nextTick(function A() {
console.log(1);
process.nextTick(function B() {
console.log(2);
});
});
setTimeout(() => {
console.log('setTimeout');
}, 0);
复制代码
这段代码会输出:“1 => 2 => setTimeout”
再来看下 setImmediate
的
setImmediate(function A() {
console.log(1);
setImmediate(function B() {
console.log(2);
});
});
setTimeout(() => {
console.log('setTimeout');
}, 0);
复制代码
这段代码的总是在最后输出 2,说明 setImmediate
会将它里面的事件注册到下一个循环中。
由于 process.nextTick
里面的 process.nextTick
也会在当前阶段执行,那么如果 process.nextTick
发生了嵌套,那么就会产生无限循环,再也不会转入其他阶段。
process.nextTick(function foo() {
process.nextTick(foo);
});
复制代码
promise
node
中的 promise
和 process.nextTick
都属于微任务,它也会在每个阶段执行完毕之后调用,但是它的优先级会比 process.nextTick
低。