Event Loop 那些事儿
我们通常说 JavaScript 是单线程的,实际上是指在 JS 引擎中负责解释和执行 JS 代码的线程只有一个,一般成为主线程,在这种前提下,为了让用户的操作不存在阻塞感,前端 APP 的运行需要依赖于大量的异步过程,所以当然浏览器中还存在一些其他的线程,比如处理 http 请求的线程、处理 DOM 事件的线程、定时器线程、处理文件读写的 I/O 线程等等。
异步过程
一个异步过程通常是这样的:
- 主线程发起一个异步请求,相应的工作线程接收请求并告知主线程已收到;
- 主线程可以继续执行后面的代码,同时工作线程执行异步任务;
- 工作线程完成工作后通知主线程,主线程收到通知后调用回调函数。
消息队列和事件循环
异步过程中,工作线程在异步操作完成后需要通知主线程,那么这个通知机制是怎样实现的呢?答案是利用消息队列和事件循环。消息队列是一个先进先出的队列,里面存放着各种消息,我们可以简单的理解为消息就是注册异步任务时添加的回调函数;事件循环是指主线程重复从消息队列中获取消息、执行回调的过程,之所以称为事件循环,就是因为它经常被用类似如下方式来实现:
while (queue.waitForMessage()) {
queue.processNextMessage();
}
消息队列是一个存储着待执行任务的队列,其中的任务严格按照时间先后顺序执行,排在队头的任务将会率先执行,而排在队尾的任务会最后执行。消息队列每次仅执行一个任务,在该任务执行完毕之后,再执行下一个任务。执行栈则是一个类似于函数调用栈的运行容器,当执行栈为空时 JS 引擎便检查消息队列,如果不为空消息队列便将第一个任务压入执行栈中运行。
下面我们来看下述代码来验证我们的想法:
setTimeout(function() {
console.log(4)
}, 0);
new Promise(function(resolve) {
console.log(1)
for (var i = 0; i < 10000; i++) {
i == 9999 && resolve()
}
console.log(2)
}).then(function() {
console.log(5)
});
console.log(3);
比较吊诡的事情出现了,为什么结果是“1, 2, 3, 5, 4”,而不是“1, 2, 3, 4, 5”呢?!
按道理来说,执行 setTimeout 时因为延迟为0,所以 console.log(4) 直接插入至消息队列;创建 Promise 实例时同步执行其函数体内的代码,先打印 1,再循环10000次后执行 resolve 将 then 中的回调函数 console.log(5) 插入至消息队列,然后打印 2;最后执行 console.log(3) 打印 3;在主线程执行完成后读取消息队列,依次打印4和5。
上面的想法当然是比较天真的,实际上浏览器中仅有一个事件循环,然后消息队列是可以有多个的。
macro-queue: script (整体代码), setTimeout, setInterval, setImmediate, I/O, UI Rendering
micro-queue: process.nextTick, Promise, Object.observe, MutationObserver
并且 micro-queue 的任务优先级高于 macro-queue 的任务优先级,这两个任务队列执行顺序如下:取1个 macro-task 执行之,然后把所有 micro-task 顺序执行完,再取 macro-task 中的下一个任务,以此类推依次进行。
优先级:process.nextTick > promise.then > setTimeout > setImmediate
Tip:process.nextTick 永远大于 promise.then 原因其实很简单:在 NodeJS 中,_tickCallback 在每一次执行完 TaskQueue 中的一个任务后被调用,而在这个_tickCallback 中实质上干了两件事:
- 执行掉 nextTickQueue 中所有任务
- 第一步执行完后,执行 _runMicrotasks 函数(执行 micro-task 中的部分,即 promise.then 注册的回调)
总结:浏览器环境一般只能有一个事件循环(实际上有两类:browsing contexts 和 web workers),而一个事件循环可以多个任务队列,每个任务都有一个任务源。相同任务源的任务,只能放到一个任务队列中;不同任务源的任务,可以放到不同任务队列中。举个栗子,客户端可能实现一个包含鼠标键盘事件的任务队列,还有其他的任务队列,而给鼠标键盘事件的任务队列更高优先级,例如75%的可能性执行它,这样就能保证流畅的交互性,而且别的任务也能执行到。
至此,再返回去看之前的代码就不难分析出:代码执行开始把 setTimeout 的回调插入至 macro-queue 中,而打印完1后把 promise.then 的回调函数插入至 micro-queue 中,整体代码执行完后,按照消息队列的优先级,先执行 micro-task 即打印5,最后执行 macro-task 即打印4。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。