事件循环
JavaScript 语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。为了协调事件、用户交互、脚本、UI 渲染和网络处理等行为,防止主线程的不阻塞,Event Loop 的方案应用而生。Event Loop 包含两类:一类是基于 Browsing Context,一种是基于 Worker。二者的运行是独立的,也就是说,每一个 JavaScript 运行的"线程环境"都有一个独立的 Event Loop,每一个 Web Worker 也有一个独立的 Event Loop。
任务队列
根据规范,事件循环是通过任务队列的机制来进行协调的。一个 Event Loop 中,可以有一个或者多个任务队列(task queue),一个任务队列便是一系列有序任务(task)的集合;每个任务都有一个任务源(task source),源自同一个任务源的 task 必须放到同一个任务队列,从不同源来的则被添加到不同队列。setTimeout/Promise 等API便是任务源,而进入任务队列的是他们指定的具体执行任务。
在事件循环中,每进行一次循环操作称为 tick,每一次 tick 的任务处理模型是比较复杂的,但关键步骤如下:
- 在此次 tick 中选择最先进入队列的任务(oldest task),如果有则执行(一次)
- 检查是否存在 Microtasks,如果存在则不停地执行,直至清空 Microtasks Queue
- 更新 render
- 主线程重复执行上述步骤
在上诉tick的基础上需要了解几点:
- JS分为同步任务和异步任务
- 同步任务都在主线程上执行,形成一个执行栈
- 主线程之外,事件触发线程管理着一个任务队列,只要异步任务有了运行结果,就在任务队列之中放置一个事件。
- 一旦执行栈中的所有同步任务执行完毕(此时JS引擎空闲),系统就会读取任务队列,将可运行的异步任务添加到可执行栈中,开始执行。
宏任务
(macro)task,可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。
浏览器为了能够使得JS内部(macro)task与DOM任务能够有序的执行,会在一个(macro)task执行结束后,在下一个(macro)task 执行开始前,对页面进行重新渲染,流程如下:
(macro)task->渲染->(macro)task->...
宏任务包含:
script(整体代码)
setTimeout
setInterval
I/O
UI交互事件
postMessage
MessageChannel
setImmediate(Node.js 环境)
注意:new Promise在实例化的过程中所执行的代码都是同步进行的,而then中注册的回调才是异步执行的。
async/await底层是基于Promise封装的,所以await前面的代码相当于new Promise,是同步进行的,await后面的代码相当于then,才是异步进行的。
微任务
microtask,可以理解是在当前 task 执行结束后立即执行的任务。也就是说,在当前task任务后,下一个task之前,在渲染之前。
所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染。也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)。
微任务包含:
Promise.then
Object.observe
MutationObserver
process.nextTick(Node.js 环境)
运行机制
在事件循环中,每进行一次循环操作称为 tick,每一次 tick 的任务处理模型是比较复杂的,但关键步骤如下:
- 执行一个宏任务(栈中没有就从事件队列中获取)
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
- 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
- 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)
如图:
事件循环的过程如下:
- JS引擎(唯一主线程)按顺序解析代码,遇到函数声明,直接跳过,遇到函数调用,入栈;
- 如果是同步函数调用,直接执行得到结果,同步函数弹出栈,继续下一个函数调用;
- 如果是异步函数调用,分发给Web API(多个辅助线程),异步函数弹出栈,继续下一个函数调用;
- Web API中,异步函数在相应辅助线程中处理完成后,即异步函数达到触发条件了(比如setTimeout设置的10s后),如果异步函数是宏任务,则入宏任务消息队列,如果是微任务,则入微任务消息队列;
- Event Loop不停地检查主线程的调用栈与回调队列,当调用栈空时,就把微任务消息队列中的第一个任务推入栈中执行,执行完成后,再取第二个微任务,直到微任务消息队列为空;然后
去宏任务消息队列中取第一个宏任务推入栈中执行,当该宏任务执行完成后,在下一个宏任务执行前,再依次取出微任务消息队列中的所有微任务入栈执行。 - 上述过程不断循环,每当微任务队列清空,可作为本轮事件循环的结束。
有几个关键点如下:
- 所有微任务总会在下一个宏任务之前全部执行完毕,宏任务必然是在微任务之后才执行的(因为微任务实际上是宏任务的其中一个步骤)。
- 宏任务按顺序执行,且浏览器在每个宏任务之间渲染页面
- 所有微任务也按顺序执行,且在以下场景会立即执行所有微任务
- 每个回调之后且js执行栈中为空。
- 每个宏任务结束后。
const macroList = [ ['task1'],
['task2', 'task3'],
['task4'],
]
for (let macroIndex = 0; macroIndex < macroList.length; macroIndex++) {
const microList = macroList[macroIndex]
for (let microIndex = 0; microIndex < microList.length; microIndex++) {
const microTask = microList[microIndex]
// 再添加一个微任务
if (microIndex === 1) microList.push('special micro task')
// 执行任务
console.log(microTask)
}
// 再添加一个宏任务
if (macroIndex === 2) macroList.push(['special macro task'])
}
// > task1
// > task2
// > task3
// > special micro task
// > task4
// > special macro task
我们通过几个示例来加深一下理解:
setTimeout(_ => console.log(4))
new Promise(resolve => {
resolve()
console.log(1)
}).then(_ => {
console.log(3)
})
console.log(2)
流程如下:
- 整体script作为第一个宏任务进入主线程,遇到setTimeout入栈处理,发现是异步函数(宏任务),出栈,移交给Web API处理,0秒等待后,将回调函数加到宏任务队列尾部;
- 遇到new Promise,入栈处理,发现是同步任务,直接执行,console输出1;
- 遇到then,入栈处理,发现是异步函数(微任务),出栈,移交给Web API处理,将回调函数加入微任务队列尾部;
- 遇到console.log(2),入栈处理,同步任务,直接console输出2, 出栈;
- 栈已清空,检查微任务队列;
- 取出第一个回调函数,入栈处理,发现是同步任务,直接console输出3, 出栈;
- 继续从取微任务队列中取下一个,发现微任务队列已清空,结束第一轮事件循环;
- 从宏任务队列中取出第一个宏任务,入栈处理,发现是同步任务,直接console输出4;
所以,最终输出结果为:1 > 2 > 3 > 4
我们先稍微改变一下:
setTimeout(_ => console.log(4))
new Promise(resolve => {
resolve()
console.log(1)
}).then(_ => {
console.log(3)
Promise.resolve().then(_ => {
console.log('before timeout')
}).then(_ => {
Promise.resolve().then(_ => {
console.log('also before timeout')
})
})
})
console.log(2)
最终输出结果为:1 > 2 > 3 > before timeout > also before timeout > 4
before timeout与also before timeout在4之前输出的原因是,在微任务执行的过程中,新产生的微任务会被直接添加到微任务队列尾部,并在下一宏任务执行之前,全部执行掉。
而如果在微任务执行的过程中,新产生了宏任务,则会进入到宏任务队列尾部,按照宏任务顺序在后面的事件循环中执行。
再来看一个嵌套的示例:
Promise.resolve().then(()=>{
console.log('Promise1')
setTimeout(()=>{
console.log('setTimeout2')
},0)
})
setTimeout(()=>{
console.log('setTimeout1')
Promise.resolve().then(()=>{
console.log('Promise2')
})
},0)
最后输出结果是Promise1 > setTimeout1 > Promise2 > setTimeout2
- 一开始执行栈的同步任务执行完毕,会去 microtasks queues 找,清空 microtasks queues ,输出Promise1,同时会生成一个异步任务 setTimeout1
- 去宏任务队列查看此时队列是 setTimeout1 在 setTimeout2 之前,因为setTimeout1执行栈一开始的时候就开始异步执行,所以输出 setTimeout1
- 在执行setTimeout1时会生成Promise2的一个 microtasks ,放入 microtasks queues 中,接着又是一个循环,去清空 microtasks queues ,输出 Promise2
- 清空完 microtasks queues ,就又会去宏任务队列取一个,这回取的是 setTimeout2
最后来一个复杂的示例,检测一下是否真正掌握了事件循环的机制。
console.log('1');
setTimeout(function() {
console.log('2');
process.nextTick(function() {
console.log('3');
})
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5')
})
})
process.nextTick(function() {
console.log('6');
})
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
})
setTimeout(function() {
console.log('9');
process.nextTick(function() {
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12')
})
})
最终的输出结果为1,7,6,8,2,4,3,5,9,11,10,12。和你的答案一致吗?
Node中事件循环
关于Node详细内容会在后面篇章中专门讲解,这里只作简单介绍。
Node用V8作为js的解析引擎,而I/O处理方面使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现。
Node也是单线程,但是在处理Event Loop上与浏览器稍微有些不同。
setImmediate为一次Event Loop执行完毕后立刻调用。
setTimeout则是通过计算一个延迟时间后进行执行。
所以如下示例,不能保证输出顺序。
setTimeout(_ => console.log('setTimeout'))
setImmediate(_ => console.log('setImmediate'))
而如果是下面这样,则一定是setImmediate先输出。
setTimeout(_ => console.log('setTimeout'), 20)
setImmediate(_ => console.log('setImmediate'))
process.nextTick方法可以在当前"执行栈"的尾部----下一次Event Loop(主线程读取"任务队列")之前----触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。
process.nextTick和setImmediate的一个重要区别:多个process.nextTick语句总是在当前"执行栈"一次执行完,多个setImmediate可能则需要多次loop才能执行完。
事实上,这正是Node.js 10.0版添加setImmediate方法的原因,否则像下面这样的递归调用process.nextTick,将会没完没了,主线程根本不会去读取"事件队列"!
process.nextTick(function foo() {
process.nextTick(foo);
});