浏览器中的消息队列和事件循环
前面我们讲到了每个渲染进程都有一个主线程,并且主线程非常繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,这个统筹调度系统就是我们今天要讲的消息队列和事件循环系统。
使用单线程处理安排好的任务
我们先从最简单的场景讲起,比如有如下一系列的任务:
- 任务 1:1+2
- 任务 2:20/5
- 任务 3:7*8
- 任务 4:打印出任务 1、任务 2、任务 3 的运算结果
现在要在一个线程中去执行这些任务,通常我们会这样编写代码:
function MainThread(){
const num1 = 1+2; //任务1
const num2 = 20/5; //任务2
const num3 = 7*8; //任务3
console.log("最终计算的值为:",num,num2,num3); //任务4
}
在上面的执行代码中,我们把所有任务代码按照顺序写进主线程里,等线程执行时,这些任务会按照顺序在线程中依次被执行;等所有任务执行完成之后,线程会自动退出。可以参考下图来直观地理解下其执行过程:
第一版:线程的一次执行
在线程运行过程中处理新任务
但是呢,实际情况并不是所有的任务都是在执行之前统一安排好的,大部分情况下,新的任务是在线程运行过程中产生的。比如在线程执行的过程中,又接收到了一个新的任务要求计算 “10 + 2” ,那这种方式就无法处理这种情况。
那么我们如果想处理这种情况,就需要把上面这个过程循环起来。具体来说,就是要在线程运行过程中,能接收并执行新任务,这样就需要采用事件循环机制。
最简单的就是使用一个for循环来监听是否有新的任务,如下面的示例代码:
//getInput
//等待用户从键盘输入一个数字,并返回该输入的数字
function getInput(){
// ...
return input_number;
}
//主线程(Main Thread)
function MainThread(){
for(;;){
const first_num = getInput();
const second_num = getInput();
result_num = first_num + second_num;
console.log("最终计算的值为:%d",result_num);
}
}
相较于第一版的线程,这一版的线程做了两点改进。
- 第一点引入了循环机制,具体的实现就是加了一个for循环,线程会一直循环执行。
- 第二点引入了事件,可以在线程运行过程中,等待用户输入的数字,等待过程中线程处于暂停阶段,一旦接收到用户输入的信息,那么线程会被激活,然后执行相加运算,最后输出结果。
通过引入事件循环机制,就可以让该线程“活”起来了。
第二版:在线程中引入事件循环
处理其他线程发送过来的任务
上面我们改进了线程的执行方式,引入了事件循环机制,可以让其在执行过程中接收新的的任务。不过在第二版的线程模型中,所有的任务都是来自线程内部的,如果另一个线程想让这个线程执行一个任务,上面这种线程模型是无法做到的。
那么下面我们就来看看其他线程是如何发送消息给渲染主线程的。
渲染进程线程之间发送任务
在这个图里可以看出,渲染主进程会频繁收到IO线程的一些任务,接收到这些任务之后,渲染主进程就要处理这些任务。
比如,收到资源加载完成的消息后,渲染主进程就要开始进行DOM解析了;收到鼠标点击的消息后,渲染主进程就要开始执行相应的JS脚本来处理该点击事件。
那么如何设计好一个线程模型,能让其能够接收其他线程发送的消息呢?
一个通用模式是使用消息队列。在解释如何实现之前,我们先说说什么是消息队列。
消息队列是一种数据结构,可以存放要执行的任务。
它符合队列“先进先出”的特点。也就是说要添加任务的话,会被添加到队列的尾部;要取任务的话,会从队列的头部取。
有了队列之后,我们就可以继续改造线程模型了,改造方案如下。
第三版线程模型:队列 + 循环
从图中可以看出,我们的改造可以分为三个步骤:
- 添加一个消息队列
- IO 线程中产生的新任务会添加进消息队列的尾部
- 渲染主线程会循环的从消息队列头部读取任务,执行任务
那么我们现在就可以来改造第三版的线程模型了。
首先,构造一个队列。
const taskQueue = [];
然后,我们再改造主线程,让主线程从队列中读取任务:
function MainThread(){
for(;;){
const task = taskQueue.shift();
if (task) {
ProcessTask(task);
}
}
}
在这段代码中,我们添加了一个消息队列的数组,然后在主线程的 for 循环中,从消息队列中读取一个任务,然后执行该任务,主线程就这样一直循环往下执行。所以只要消息队列中有任务,主线程就会去执行。
主线程的代码就这样改造完了。这样改造后,主线程执行的任务都全部从消息队列中获取。所以如果有其他线程想要发送任务让主线程去执行,只需要将任务添加到该消息队列中就可以了,添加任务的代码如下:
taskQueue.push(Task)
另外,由于是多个线程操作同一个消息队列,所以在添加任务和取出任务的时候还会加上一个同步锁。关于锁呢,在JS的单线程环境下我们基本不会碰到,有兴趣的同学可以了解一下。
处理其他进程发送过来的任务
通过消息队列,我们实现了线程之间的消息通讯。在 Chrome 中,跨进程之间的任务也是频繁发生的,那么进程之间是如何通讯的呢?
跨进程发送消息
渲染进程里有个专门的 IO 线程用来接收其他进程传进来的消息。接收到消息后,它会将这些消息组装成任务发送给渲染主进程。后面的步骤就是我们刚才将的那些。
消息队列中的任务类型
那么消息队列中的任务是有很多中类型的,比如:输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JS定时器等。具体的类型可以参考chromium源码
除此之外,消息队列还包含了很多与页面相关的事件,比如:JS的执行、解析DOM、样式计算、布局计算、CSS动画等。
以上这些事件都是在主线程里去执行的。
如何安全退出队列循环?
这里还有一个场景需要处理,当页面被关掉的时候,怎么才能退出这个循环呢?Chrome 是这样解决的,确定要退出当前页面时,页面主线程会设置一个变量,在每次执行完一个任务的时候,就去判断这个变量是不是需要退出。那么具体的代码如下:
const taskQueue = [];
let keep_running = true;
function MainThread(){
for(;;){
const task = taskQueue.shift();
if (task) {
ProcessTask(task);
}
if(!keep_running) //如果设置了退出标志,那么直接退出线程循环
break;
}
}
消息队列的问题
上面整个的过程就是页面线程的循环系统的工作流程。这个时候你应该清楚了,页面所有执行的任务都来消息队列。消息队列是“先进先出”的属性,但是呢,“先进先出”有两个问题需要解决。
第一个问题是如何处理高优先级的任务
比如一个典型的场景就是监控DOM节点的变化情况(节点的插入、修改、删除等动态变化),然后根据这些变化来处理相应的业务逻辑。一个通用的设计是,用JS设计一套监听接口,当发生变化时,渲染引擎同步调用这些接口,这个是个典型的观察者模式。
但是当 DOM 的变化非常频繁的时候,当前的任务执行时间会被拉长,它的执行效率会下降。那么如果将这些DOM的变化做成异步的事件,那么又会影响到监听的实时性。那么怎么去平衡效率和实时性呢?
这个时候,就出现了微任务。下面我们来看看微任务是如何权衡效率和实时性的。
通常我们把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果DOM有变化,那么就会将该变化添加到微任务列表中,这样将不会影响到宏任务的继续执行,因此也就解决了执行效率的问题。
等宏任务中的任务都执行完成了之后,渲染引擎并不着急去执行下一个宏任务,而是先执行当前宏任务中的微任务,因为DOM变化的事件都保存在这些微任务队列中,这样也解决了实时性问题。
第二个问题是如何解决单个任务执行时间多久的问题
因为所有的任务都是在单线程中执行的,所以每次只能执行一个任务,而其他任务就都处于等待状态。如果其中一个任务执行时间过久,那么下一个任务就要等待很长时间。这样就会引起卡顿的感觉。
单个任务执行时间过久
关于宏任务和微任务,我在后面会有详细的文章进行分析。