消息队列与事件循环 -- 浏览器系列(5)

878 阅读14分钟

javascript作为一门单线程语言,意味着js在同一时间只能做一件事。每个渲染进程只有一个主线程,主线程非常繁忙,既要负责完成生成页面的必要操作(如构建DOM,样式计算,布局计算等等),还要负责用户的各种交互事件(如按钮点击,鼠标滚动等),以及执行js代码。为了给用户良好的使用体验,js采用了非阻塞I/O操作。那么js是如何实现这一特性的呢?答案就是事件循环(Event Loop)。

JS宿主环境

在正式开始事件循环之前,我们先聊聊js的宿主环境。

我们都知道不管是Chrome还是Nodejs,它们都是通过V8引擎运行js代码。这里提到的Chrome和Node就是V8的宿主,那么V8和其宿主环境的关系是怎样的呢?

在js代码被执行之前,V8需要初始化代码的运行时环境,包括堆空间(Heap),栈空间(Stack),全局执行上下文,全局作用域,内建函数,宿主环境提供的扩展函数和对象,以及事件循环系统。而这些运行时环境都是由V8的宿主环境提供。V8只实现了ECMAScript标准,以及ECMAScript标准所定义了核心函数,除此之外V8还提供了垃圾回收器,协程等内容。

以Chrome为例,他们的关系如图所示:

execute env

我们可以把V8和Chrome看做成病毒和细胞的关系:病毒只包含核心的遗传物质RNA,它生存所需要的资源都由它的宿主细胞Chrome提供。

堆和栈

V8运行时环境包括堆和栈,其中栈是内存中一块连续的空间,采用“先进后出”的策略,在js函数的执行过程中,涉及到执行上下文相关的内容都会存放在栈中,如下文提到的函数执行上下文,当函数开始执行时,该函数的执行上下文将会被压入栈中,执行结束,该函数的执行上下文将会被销毁掉。

栈空间的最大优势是空间连续,所以查找效率特别高,但栈的空间大小具有一定的限制,毕竟很难在内存中找到一块非常大的连续内存空间。如果调用栈超出了大小就会出现栈溢出错误

既然栈的空间大小有限制,那么就不适合存储一些大型数据,此时就需要新的存储结构:堆。堆空间是一种树形存储空间,专用于存储对象类型的数据。

宿主在启动V8时,就会同时创建堆和栈空间,往后执行js生成的数据都会存放在这两个空间中。

在这两个空间初始化完毕之后,紧接着就是初始化全局执行上下文和全局作用域,往后才真正开始执行我们所写的代码,这也是我们能在代码中直接调用如window对象,setTimeout函数之类的全局变量的原因。

stack and heap

我们用一段简单的代码来说明js的执行上下文变化过程,有助于后续理解事件循环:

function foo() {
  bar();
}

function bar() {
  console.log("bar call");
}

foo();
bar();

在这段代码实际执行之前,宿主会为V8初始化环境,供V8的后续使用,所以此时栈和全局执行上下文已经被创建完成(先不考虑其他部分):

execute step 0

然后正式开始执行代码,每当执行一个函数时,为会其生成对应的执行上下文,并压入栈顶,当该函数执行完毕,则将其对应的执行上下文从栈中移除。其执行过程如下所示:

execute steps

事件循环

V8并没有自己的主线程,它使用宿主所提供的的主线程。在浏览器中,则是借用每个渲染进程中的主线程,但只有一个主线程是不够的,为了在执行js代码时,页面依旧有响应I/O操作的能力,所以还需要一个I/O线程和消息对列,I/O线程除了用于接收用户的交互操作,也用于接收其他线程传入的消息,如网络进程返回的API结果,消息队列用于存储I/O事件等任务,主线程通过事件循环系统执行其中的任务。

event loop overview Nodejs作为V8的宿主之一,它也会为V8提供事件循环系统。但Nodejs提供的事件循环系统和浏览器提供的有所不同。这也是为什么我们在讨论js的事件循环机制时,常常会区分Node环境和浏览器环境。

当然,本文只关注浏览器环境

事件循环到底干了啥?

事件循环策略说起来很简单:

  1. 轮询取出消息队列中的任务,然后在主线程中执行
  2. 执行完一个任务,继续获取下一个任务执行
  3. 如果当前消息队列中没有任务,则等待新任务的到来

用伪代码说明这个过程:

while (true) {
  const task = getNextTask(); //如果没有任务,则在此处一直等待
  processTask(task);
}

宏任务,微任务

那么消息队列中的任务是什么,浏览器怎么样产生一个任务?

js将所有任务分为宏任务,和微任务,下面的这些行为会分别产生宏任务和微任务:

  • 宏任务(Macro Task)
    • 浏览器渲染事件(如解析DOM, 样式计算,绘制等)
    • 用户交互事件(如鼠标点击,滚动和页面缩放等)
    • script标签执行
    • setTimeout,setInterval
    • 网络请求回调
  • 微任务(Micro Task):
    • Promise.then
    • MutationObserver

宏任务

所有的宏任务都会被放入消息队列中,然后在事件循环中,被依次取出在主线程上被执行。

我们先来看一个例子:页面上有两个按钮,分别是blocklog。其中,点击block按钮,会执行一段长达5秒的耗时操作,而点击log按钮,会弹出提示框。关键代码如下:

function block() {
  const time = new Date().getTime();
  while (true) {
    if (new Date().getTime() - time > 5000) {
      break;
    }
  }
}

document.getElementById("block").addEventListener("click", function handleBlock() {
  console.log("start calling");
  block();
  console.log("finish calling");
});

document.getElementById("log").addEventListener("click", function handleLog() {
  alert("hello");
});

我们在点击block按钮后,马上点击log按钮会发生什么?

page interaction 可以看到,页面在输出start calling后就失去响应,直到输出finish calling后,才显示弹出窗,整个过程如下:

block interaction 因为block函数执行时间是5秒,所以主线程一直在执行block按钮点击的宏任务,导致log按钮点击事件一直在消息队列中等待被执行。

setTimeout,setTimeInterval

我们常常使用setTimeout让回调函数在指定时间后运行,该函数会返回当前定时器编号,我们可以通过clearTimeout取消该定时器。

下面是setTimeout的一般使用方式:

setTimeout(function callback() {
  console.log("setTimeout callback");
}, 1000);

前面提到,setTimeout会生成一个宏任务,那么它是怎么工作的呢?看看下面代码:

console.log("start");

setTimeout(function cb1() {
  console.log("callback 1");
}, 0);

setTimeout(function cb2() {
  console.log("callback 2");
}, 1000);

console.log("end");

该代码输出的log顺序是:

"start"
"end"
"callback 1"
"callback 2"

好,接下来我们来看看这段代码的执行过程:

setTimeout

渲染进程有专门的定时器线程和定时器队列,定时器队列负责存放所有setTimeout或者setTimeInterval创建的宏任务,而定时器线程则负责将到期的任务放到消息队列中。

setTimeout所设置的时间并不是表示回调函数在多少毫秒后执行,而是表示回调函数在经过多少毫秒后被放入消息队列中,所以即使setTimeout(fn,0)也不是表示fn能马上执行,只是表示立即被放入消息队列,如果在它之前有很多耗时的宏任务,那么它的执行时间也会被推迟。

setTimeInterval则是每隔一定时间,再次将回调函数的宏任务放入消息队列中,除此之外,其过程和setTimeout类似,所以不再赘述。

XMLHttpRequest

在浏览器中,我们可以使用原生XMLHttpRequest函数用于网络请求,并且可以指定网络请求各个阶段的回调函数。渲染进程中也有相应的网络线程,负责处理网络请求,并且在网络请求状态更新后,根据我们设置的回调函数生成宏任务,并放入消息队列中,主线程会在随后的事件循环中执行这些宏任务。

以下面代码为例:

const xhr = new XMLHttpRequest();

xhr.onreadystatechange = function change() {
  console.log(`readyState: ${xhr.readyState}, status: ${xhr.status}`);
};

xhr.onloadend = function loaded() {
  console.log("response:", xhr.response);
};

xhr.open("get", "/api/example");
xhr.send();

该代码将会输出:

"readyState: 1, status: 0"
"readyState: 2, status: 200"
"readyState: 3, status: 200"
"readyState: 4, status: 200"
"response: {"data":[1,2,3,4]}"

好,接下来我们来看看这段代码的执行过程:

xhr 需要注意的是,在调用open方法后会立即同步执行change方法回调,而在send之后,网络线程会实际发送该请求,并检测该请求的状态,在请求状态发生变化后,会将对应的回调任务放入消息队列中。

微任务

宏任务可以胜任大部分日常需求,但如果某些需求对时间的精度要求很高,那么宏任务就难以胜任。比如希望某些回调函数尽可能早的被执行,即使使用setTimeout(fn,0),也可能会被已有的宏任务延迟。

为了满足更高精度的需求,出现了微任务。

微任务有对应的微任务队列,事件循环系统会在当前宏任务结束后,下一个宏任务开始前,检查微任务队列中是否存在微任务,如果存在,则会依次执行微任务,直到微任务队列不存在新的微任务为止;再继续执行下一个宏任务。

此时我们可以将事件循环的过程完善为:

while (true) {
  const task = getNextTask(); //如果没有任务,则在此处一直等待
  processTask(task);

  while (microQueue.hasMicroTask()) {
    const microTask = microQueue.pop();
    processTask(microTask);
  }
}

Promise.then

Promise在resolve后,执行then方法时就会产生一个微任务。

以下面代码为例:

console.log("start");

setTimeout(function macro() {
  console.log("macro callback");
}, 0);

Promise.resolve().then(function micro() {
  console.log("micro callback");
});

console.log("end");

该代码将会输出:

"start"
"end"
"micro callback"
"macro callback"

好,来看看这段代码的执行过程:

promise

微任务就像是VIP客户,而宏任务则是普通客户。当主线程完成了当前的任务后,会优先接待VIP客户,在完成了所有VIP客户的任务后,再回过头来继续下一个宏任务。

如果在执行微任务的过程中不断产生新的微任务会怎样呢?

function foo() {
  Promise.resolve().then(foo);
}
foo();

上面的代码会让页面完全失去响应,因为在执行foo方法后,会产生一个微任务,再继续执行微任务时,又会继续产生微任务。而页面的交互操作都会以宏任务的形式存储在消息队列中,最终没有机会执行。

而如果将上面代码改为:

function foo() {
  setTimeout(foo, 0);
}
foo();

页面则能正常响应用户交互操作,虽然我们设置的延时时间是0,但系统并不是真正立即(而是在极短时间内)就将回调放入消息队列中,在回调之间,依旧会被插入其他的交互任务或者渲染任务。这也能看出宏任务精度确实不高。

Async/Await

在ES6成为主流的今天,我们会更倾向使用Async/Await编写异步代码。Async/Await本质上是Generator和Promise的语法糖,只要我们理解Promise在事件循环中的工作方式,就很容易理解Async/Await

以下面代码为例:

console.log("start");

async function getNumber() {
  console.log("async start");
  const a = await 1;
  console.log("get a", a);
  const b = await Promise.resolve(2);
  console.log("get b", b);
  return a + b;
}

getNumber().then((number) => {
  console.log("result", number);
});

setTimeout(() => {
  console.log("macro callback");
}, 0);

console.log("end");

该代码将会输出:

"start"
"async start"
"end"
"get a 1"
"get b 2"
"result 3"
"macro callback"

getNumber函数中第一个await函数之前的代码是同步执行的,而await后跟着的对象都会被转换为Promise,当函数执行遇到await时会先返回,然后等到该对象被resolved后再回来继续执行。

为了便于理解,我们可以将getNumber代码转换为:

function getNumber() {
  return new Promise((resolve) => {
    console.log("async start");
    Promise.resolve(1).then((a) => {
      console.log("get a", a);
      Promise.resolve(2).then((b) => {
        console.log("get b", b);
        resolve(a + b);
      });
    });
  });
}

是否每次Event Loop都会导致页面渲染?

主线程除了执行js代码外,还会执行渲染流水线的任务,如样式计算,布局计算,分层,绘制等等工作,那么是否每次Event Loop都会导致页面重新渲染,重新执行渲染流水线?

答案是渲染流水线不会在每次Event Loop都执行。浏览器非常聪明,它会判断我们所执行的代码以及和页面的交互行为是否会导致页面内容的改变,只有在我们在当前循环中确实改变了页面内容或者requestAnimationFrame回调不为空,它才会执行必要的渲染流水线步骤更新页面。

此时我们可以将事件循环进一步完善为:

while (true) {
  const task = getNextTask(); //如果没有任务,则在此处一直等待
  processTask(task);

  while (microQueue.hasMicroTask()) {
    const microTask = microQueue.pop();
    processTask(microTask);
  }
  
  if(needRepaint()) {
    repaint();
  }
}

当我们加入渲染流水线后,事件渲染流程将会是这样:

render

requestAnimationFrame

在进行动画操作时,官方推荐使用requestAnimationFrame(下文简称rAF),在每一帧实际执行到渲染步骤前,它给了我们一个额外的改变页面结构的机会,并且在接下来的绘制中很快将其呈现出来。

当加上了rAF后,事件渲染流程将会是这样:

rAF 如果rAF有多个回调任务,则都会在此时渲染中全部执行。如果在rAF回调中又调用了rAF,新的rAF在下次渲染才会被执行,而不是本次渲染。

// cb1和cb2会在同一次渲染执行
requestAnimationFrame(function cb1() {
  console.log("task 1");
});

requestAnimationFrame(function cb2() {
  console.log("task 2 ");
});


// cb4会在cb3的后一次渲染中执行
requestAnimationFrame(function cb3() {
  console.log("task 3");
  requestAnimationFrame(function cb4() {
    console.log("task 4 ");
  });
});

加入rAF之后的事件循环:

while (true) {
  // ......
  if (needRepaint()) {
    const rAFTasks = animationQueue.copyTasks(); // 获取当前所有的rAF任务,所以在执行rAF所产生的rAF任务不会在本轮执行
    for (const task in rAFTasks) {
      processTask(task);
    }
    repaint();
  }
}

定时器合并

定时器宏任务可能会直接跳过渲染,这也是浏览器的优化策略之一。以下面代码为例:

setTimeout(() => {
  console.log("callback1");
  requestAnimationFrame(() => console.log("rAF1"));
});
setTimeout(() => {
  console.log("callback2");
  requestAnimationFrame(() => console.log("rAF2"));
});

实际的输出是:

“callback1"
"callback2"
"rAF1"
"rAF2"

实际上在两个到期任务都执行完毕后,才开始渲染步骤。

更真实的世界

到目前为止,我们主要提到了一个消息队列,主线程会按照顺序执行其中的任务。

但实际情况更为复杂,一个渲染进程中通常会有多个消息队列,如负责存放键鼠输入事件的队列,存放定时器回调的定时器队列,存放如垃圾回收事件等实时性不高的空闲队列等等。并且为不同的队列,在不同场景给与不同的优先级。

在每一轮事件循环中,浏览器在保证任务顺序的前提下,会从多个消息队列中,选择优先级高的队列中的任务执行,并且在连续执行多个高优先级任务后,中间一定会执行一次低优先级任务,保证低优先级的任务不会被饿死。

到此为止,基本就是浏览器事件循环的全貌,整个过程的伪代码如下:

while (true) {
  const queue = getNextQueue(); // 多个任务队列中选择需要合适的队列
  const task = queue.pop(); // 依旧按照先进先出的原则删选最老的任务
  processTask(task); // 执行宏任务

  while (microQueue.hasMicroTask()) { // 在宏任务完成前,执行所有的微任务
    const microTask = microQueue.pop();
    processTask(microTask);
  }

  if (needRepaint()) {  // 判断当前任务所做的操作是否需要重新渲染
    const rAFTasks = animationQueue.copyTasks();  // 获取当前所有的rAF任务,所以在执行rAF所产生的rAF任务不会在本轮执行
    for (const task in rAFTasks) {
      processTask(task);
    }
    repaint();
  }
}

如果对本文有什么意见和建议,欢迎讨论和指正!