JavaScript事件循环机制
1. 前言
- JavaScript 拥有基于“事件循环”的运行时模型。
- JavaScript 是一个单线程语言;JavaScript 中的所有任务被分为同步任务和异步任务。
- JavaScript 的事件循环机制是 JavaScript 异步编程的核心,它确保 JavaScript 单线程环境中的任务能够按照正确的顺序执行,同时允许异步操作的存在。
2. 基本概念
事件循环(event loop)又叫做消息循环(message loop),是浏览器渲染主线程的工作方式,是浏览器处理异步的一种调度策略。
在 Chrome 的源码中,它开启一个不会结束的 for 循环,每次循环从消息队列中取出第一个任务执行,而其他线程只需要在合适的时候将任务加入到队列末尾即可。
过去把消息队列简单分为宏队列和微队列,这种说法目前已无法满足复杂的浏览器环境,取而代之的是一种更加灵活多变的处理方式。
根据 W3C 官方的解释,每个任务有不同的类型,同类型的任务必须在同一个队列,不同的任务可以属于不同的队列。不同任务队列有不同的优先级,在一次事件循环中,由浏览器自行决定取哪一个队列的任务。但浏览器必须有一个微队列,微队列的任务一定具有最高的优先级,必须优先调度执行。
3. 浏览器的进程与线程
进程是计算机操作系统资源分配的最小单位。现代浏览器,以 Chrome 为例,广泛采用的是多进程架构。为了减少连环崩溃的几率,启动浏览器后,会将各个任务拆分为多个进程。
- 浏览器进程(Browser process):我负责界面显示、用户交互、子进程管理等业务。
- 网络进程(Network process):我负责加载网络资源业务。
- 渲染进程(Rendering process):我是渲染的主角,负责执行 HTML、CSS、JS 代码。默认情况,浏览器会为每一个标签页签开启一个渲染进程,以确保相互不影响。
- 。。。
频繁开启进程会造成内存占用过多的情况,Chrome 后续会进行改进,考虑相同站点(相同顶级域名和二级域名)共用一个渲染进程。(官方说明文档)
渲染主线程
渲染进程会开启一个渲染主线程,用于无阻塞渲染任务。其余线程协助主线程完成任务。
渲染主线程(Main thread) 负责执行代码、样式与布局(通过优先级、百分比换算、宽高等几何信息高动态计算、相对位置等计算最终样式表)、处理图层、控制 60 帧刷新、执行各种回调函数等,是最忙的一个,一刻也没有停歇。
此时就有一个问题,主渲染任务都交给一个线程,效率会高吗?为什么不多线程一起渲染?
这是因为浏览器渲染和 JavaScript 执行共用一个线程,而默认情况下, JavaScript 执行又会影响 CSSOM/DOM 树的渲染和结构,所以必须阻塞渲染,他们也必须是单线程操作。在构建 DOM 时,HTML 解析器若遇到了 JavaScript,它会暂停构建DOM,将控制权移交给 JavaScript 引擎,等 JavaScript 引擎运行完毕,浏览器再从中断的地方恢复 DOM 构建。如果使用多线程处理可能会导致渲染DOM的冲突,复杂度会大幅度提升。
4. 消息循环
使用单线程,使得在编写代码时无需考虑复杂的线程同步和互斥问题,从而降低了开发的复杂性,但是终归效率是上不去的。为了解决这个问题,这里巧妙地引入了消息循环来统一解决这个问题。
工作原理
核心思想:渲染主线程永久轮询执行任务;单独开辟一个消息队列(Message queue)空间用于存放各个线程加入的任务;渲染主线程只是简单的拿取任务执行,而不用操心这个任务是谁送过来的。在 Chrome 的源码中,使用的是一个死循环来完成的:
for(;;) {}
消息队列的无限轮询就叫消息循环,消息循环就发生在渲染主线程里。
- 最开始,渲染主线程开始轮询
- 每一次循环检查消息队列是否有任务,有则取出第一个任务执行。
- 其他所有线程都可以追加任务进消息队列的末尾。
注意事项:
- 绘制任务出现在循环的每一次迭代之后。
- 最需要注意的细节是,事件循环每一次只执行一个“宏”任务。
优先级
任务本身没有优先级,渲染主线程依次从对头拿取任务执行。有优先级的是消息队列。
在目前 Chrome 的实现中,消息队列其实不是一个队列,而是多个,他们有优先级,这个优先级决定了谁先进入渲染主线程被执行。至少包含了下面的队列:
- 延时队列(Delay queue):用于存放计时器到达后的回调任务,优先级「中」。
- 交互队列(Interactive queue):用于存放用户操作后产生的事件处理任务,优先级「高」。
- 微队列(Micro queue):用户存放需要最快执行的任务,优先级「最高」。
随着浏览器的复杂度急剧提升,W3C 不再使用宏队列的说法。
5. 总结
- 事件循环又叫消息循环,是浏览器渲染主线程的工作方式,是浏览器处理异步的一种调度策略。
- 渲染主线程会反复轮询(for 循环),不断拿取消息队列的任务来执行;其他线程获取到任务后,在合适的时机将其追加在消息队列末尾。
- 任务队列分为延时队列、交互队列、微队列;不同任务队列有不同的优先级。
测试
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')
// 新版输出(新版的chrome浏览器优化了,await变得更快了,输出为)
// script start => async2 end => Promise => script end => async1 end => promise1 => promise2 => setTimeout
// 注意一个点await async2() 执行完后面的任务才会注册到微任务中
// 旧版输出如下,但是请继续看完本文下面的注意那里,新版有改动
// script start => async2 end => Promise => script end => promise1 => promise2 => **async1 end** => setTimeout
//第一个宏任务
setTimeout(() => {
console.log(1); //宏任务中的同步任务
Promise.resolve().then(() => { console.log(7) }) //宏任务中的微任务
}, 0); //异步任务 - 宏任务
console.log(2); //同步任务
Promise.resolve().then(() => { console.log(3) }) //异步任务 - 微任务
//第二个宏任务
setTimeout(() => {
console.log(8); //宏任务中的同步任务
setTimeout(() => { console.log(5) }, 0) //宏任务中的宏任务 第四个宏任务
}, 0);
//第三个宏任务
setTimeout(() => {
Promise.resolve().then(() => { console.log(4) }) //宏任务中的微任务
}, 0);
console.log(6); //同步任务
// 执行结果:2 6 3 1 7 8 4 5