前面的总结:
每个渲染进程都有一个主线程,处理 DOM、计算样式、处理布局,还要处理同步/异步 JS 任务和各种输入事件。要让这些不同类型的任务在主线程中有条不紊地执行,需要一个统筹调度系统来统筹调度这些任务------消息队列和事件循环系统。
1.使用单线程处理安排好的任务:按顺序依次执行
2.事件循环机制(引入循环+事件): 线程运行过程中处理新任务
3.通过消息队列实现了线程之间的消息通信
4.IO 线程: 处理其他进程发送过来的任务
微任务:处理高优先级的任务,权衡效率和实时性
统筹调度系统:消息队列和事件循环系统
每个渲染进程都有一个主线程,处理 DOM 、计算样式 、处理布局 ,还要处理 JS 任务 和各种输入事件 。要让这些不同类型的任务在主线程中有条不紊地执行,就需要一个系统来统筹调度这些任务,这个统筹调度系统就是消息队列和事件循环系统。
事件循环非常底层非常重要,让你理解页面是如何运行的。
为了深刻理解事件循环机制,从最简单的场景分析,一步步了解浏览器页面主线程是如何运作的。
使用单线程处理安排好的任务:按顺序依次执行
有一系列任务:1+2; 20/5; 7*8; 打印运算结果
把所有任务依次写进主线程。线程执行时,这些任务在线程中依次被执行。等所有任务执行完成后,线程会自动退出:
事件循环机制(引入循环+事件): 线程运行过程中处理新任务
要在线程运行过程中能接收并执行新任务,就要用事件循环机制。通过 for 循环监听新任务:
第二版:在线程中引入事件循环
1.引入循环机制 ,在线程语句最后添加了 for 循环语句 ,线程会一直循环执行。
2.引入事件:线程运行过程中等待用户输入的数字,等待过程中线程处于暂停状态,接收到用户输入的信息线程就会被激活,然后执行相加运算、输出结果。
通过引入事件循环机制,线程就"活"起来了,每次输入两个数字都会打印出两数字相加的结果:
消息队列:处理其他线程发来的任务,通过消息队列实现了线程之间的消息通信
但模型中所有任务都来自线程内部,如果其他线程想让主线程执行一个任务,就做不到了。
渲染主线程会频繁接收到来自于 IO 线程的任务,如
- 接收到资源加载完成的消息后渲染进程就要着手进行 DOM 解析了;
- 接收到鼠标点击的消息后,渲染主线程就要执行相应脚本处理该点击事件。
如何设计一个线程模型让它能够接收其他线程发送的消息呢?通用模式是使用消息队列(存放要执行的任务,"先进先出"),添加任务到队列尾部、从队列头部取出任务。
渲染进程线程之间发送任务/第三版线程模型:队列 + 循环
有了队列就可以继续改造线程模型了,分为三步:
- 添加一个消息队列;
- IO 线程产生的新任务添加进消息队列尾部;
- 渲染主线程会循环地从消息队列头部中读取和执行任务。
按步骤使用代码来实现第三版的线程模型:
1.构造队列的接口(不考虑实现细节)
class TaskQueue{
public:
Task takeTask(); //取出队列头部的一个任务
void pushTask(Task task); //添加一个任务到队列尾部
};
2.改造主线程,让主线程从队列中读取任务:
TaskQueue task_queue;
void ProcessTask();
void MainThread(){
for(;;){
Task task= task_queue.takeTask();
ProcessTask(task);
}
}
添加了一个消息队列对象,在主线程的 for 循环代码块中从消息队列中读取一个任务然后执行,主线程就一直循环往下执行,只要消息队列中有任务主线程就会去执行。
改造后主线程执行的任务都全部从消息队列中获取。如果有其他线程想要发送任务让主线程去执行,只需将任务添加到消息队列就可以:
Task clickTask;
task_queue.pushTask(clickTask)
由于多个线程操作同一个消息队列,所以在添加和取出任务时还会加上一个同步锁。
IO 线程: 处理其他进程发送过来的任务
通过消息队列实现了线程间的通信。Chrome 中跨进程的任务也频繁发生。渲染进程中的 IO 线程专门用来接收其他进程传进来的消息,收到消息后会将这些消息组装成任务发送给渲染主线程,后续步骤就和"处理其他线程发送的任务"一样了:
跨进程发送消息
消息队列中的任务类型
Chromium 的官方源码包含很多内部消息类型:输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JS 定时器等等。
消息队列中还包含了很多与页面相关的事件:JS 执行、解析 DOM、样式计算、布局计算、CSS 动画等。
以上事件都在主线程中执行,编写 Web 应用时需衡量这些事件占用的时长,并想办法解决单个任务占用主线程过久的问题。
如何安全退出
如何保证页面主线程执行后能安全退出呢?
Chrome 确定要退出当前页面时,页面主线程会设置一个退出标志的变量,每次执行完一个任务时判断是否有设置退出标志。
如果设置了,就直接中断当前的所有任务,退出线程:
TaskQueue task_queue;
void ProcessTask();
bool keep_running=true;
void MainThread(){
for(;;){
Task task= task_queue.takeTask();
ProcessTask(task);
if(!keep_running) //如果设置了退出标志,那么直接退出线程循环
break;
}
}
页面使用单线程的缺点
页面线程所有执行的任务都来自消息队列。消息队列"先进先出"的属性(放入队列中的任务需要等待前面的任务被执行完才会被执行),有两个问题需要解决。
微任务:处理高优先级的任务,权衡效率和实时性
场景:监控 DOM 节点变化情况,执行业务逻辑
如果 DOM 频繁发生变化,
- 同步方式 会影响当前任务的 执行效率。
- 异步方式 会影响到 监控的 实时性 : 将 DOM 变化做成异步的消息事件添加到消息队列的尾部,会影响到监控的实时性,因为添加到消息队列时,前面可能有很多任务在排队了。
- 微任务 :消息队列中每个宏任务 都包含一个微任务队列。 如果执行宏任务时 DOM 有变化:
-
- 将该变化添加到微任务列表中,不会影响宏任务的执行,解决了执行效率问题。
- 宏任务中的功能完成后,渲染引擎并不着急执行下个宏任务,而是执行当前宏任务中的微任务。因为 DOM 变化的事件都保存在微任务队列中,就解决了实时性问题。
2.解决单个任务执行时长过久的问题
如果在执行动画过程中有个 JS 任务执行时间过久占用了动画单帧的时间,用户就会感觉卡顿。JS 可以通过回调功能来规避,也就是让要执行的 JS 任务滞后执行。至于浏览器是如何实现回调功能的,后面章节再详细介绍。
实践:浏览器页面是如何运行的
打开开发者工具"Performance"标签,选择左上角的"start porfiling and load page"来记录整个页面加载过程中的事件执行情况:
Performance 页面
图中我们点击展开了 Main 这个项目,它记录了主线程执行过程中的所有任务。图中灰色的就是一个个任务,每个任务下面还有子任务,其中的 Parse HTML 任务,是把 HTML 解析为 DOM 的任务。值得注意的是,在执行 Parse HTML 时,如果遇到 JS 脚本,会暂停当前的 HTML 解析而去执行 JS 脚本。
后面还会详细介绍 Performance 工具,这里建立一个直观的印象就可以。
总结
如果有一些确定好的任务,可以使用一个单线程来按照顺序处理这些任务,这是第一版线程模型。
要在线程执行过程中接收并处理新的任务,就需要引入循环语句和事件系统,这是第二版线程模型。
如果要接收其他线程发送过来的任务,就需要引入消息队列,这是第三版线程模型。
如果其他进程想要发送任务给页面主线程,那么先通过 IPC 把任务发送给渲染进程的 IO 线程,IO 线程再把任务发送给页面主线程。
消息队列机制并不是太灵活,为了适应效率和实时性,引入了微任务。
基于消息队列的设计是目前使用最广的消息架构,无论是安卓还是 Chrome 都采用了类似的任务机制,所以理解了本篇文章的内容后,你再理解其他项目的任务机制也会比较轻松。
思考:结合消息队列和事件循环,你认为微任务是什么?引入微任务能带来什么优势呢?
宏任务是开会分配的工作内容,微任务是工作过程中被临时安排的内容
宏任务是开会时PM定好的需求,微任务是开发过程中PM新加的加急需求。
假设一场迭代会议定下来3个宏任务,在开发第2个宏任务到60%进度的时候,PM新提了一些小的微任务。执行时间可以表示为:第2个宏任务完成之后---[执行新加入的微任务]---第3个宏任务开始之前。