avatar

veedrin

Async 专题 01 —— 事件循环

也许我们都听说过JavaScript是事件驱动的这种说法。各种异步任务通过事件的形式和主线程通信,保证网页流畅的用户体验。而异步可以说是JavaScript最伟大的特性之一(也许没有之一)。

现在我们就从Chrome浏览器的主要进程入手,深入的理解这个机制是如何运行的。

Chrome浏览器的主要进程

我们看一下Chrome浏览器都有哪些主要进程。

  • Browser进程。这是浏览器的主进程。

  • 第三方插件进程。

  • GPU进程。

  • Renderer进程。

大家都说Chrome浏览器是内存怪兽,因为它的每一个页面都是一个Renderer进程,其实这种说法是不对的。实际上,Chrome支持好几种进程模型。

  • Process-per-site-instance。每打开一个网站,然后从这个网站链开的一系列网站都属于一个进程。这也是Chrome的默认进程模型。

  • Process-per-site。同域名范畴的网站属于一个进程。

  • Process-per-tab。每一个页面都是一个独立的进程。这就是外界盛传的进程模型。

  • Single Process。传统浏览器的单进程模型。

浏览器内核

现在我们知道,除了相关联的页面可能会合并为一个进程外,我们可以简单的认为每个页面都会开启一个新的Renderer进程。那么这个进程里跑的程序又是什么呢?就是我们常常说的浏览器内核,或者说渲染引擎。确切的说,是浏览器内核的一个实例。Chrome浏览器的渲染引擎叫Blink

由于浏览器主要是用来浏览网页的,所以虽然Browser进程是浏览器的主进程,但它充当的只是一个管家的角色,真正的一线业务大拿还得看Renderer进程。这也是跑在Renderer进程里的程序被称为浏览器内核(实例)的原因。

介绍Chrome浏览器的进程系统只是为了引出Renderer进程,接下来我们只需要关注浏览器内核与Renderer进程就可以了。

Renderer进程的主要线程

Renderer进程手下又有好多线程,它们各司其职。

  • GUI渲染线程。

  • JavaScript引擎线程。对于Chrome浏览器而言,这个线程上跑的就是威震海内的V8引擎。

  • 事件触发线程。

  • 定时器线程。

  • 异步HTTP请求线程。

调用栈

进入主题之前,我们先引入调用栈(call stack)的概念,调用栈是JavaScript引擎执行程序的一种机制。为什么要有调用栈呢?我们举个例子。

const str = 'biu';

console.log('1');

function a() {
    console.log('2');
    b();
    console.log('3');
}

function b() {
    console.log('4');
}

a();

我们都知道打印的顺序是1 2 4 3

问题在于,当执行到b函数的时候,我需要记住b函数的调用位置信息,也就是执行上下文。否则执行完b函数之后,引擎可能就忘了执行console.log('3')了。调用栈就是用来干这个的,每调用一层函数,引擎就会生成它的栈帧,栈帧里保存了执行上下文,然后将它压入调用栈中。栈是一个后进先出的结构,直到最里层的函数调用完,引擎才开始将最后进入的栈帧从栈中弹出。

12345678
----console.log('4')---
--console.log('2')b()b()b()console.log('3')-
console.log('1')a()a()a()a()a()a()a()

可以看到,当有嵌套函数调用的时候,栈帧会经历逐渐叠加又逐渐消失的过程,这就是所谓的后进先出。

同时也要注意,诸如const str = 'biu'的变量声明是不会入栈的。

调用栈也要占用内存,所以如果调用栈过深,浏览器会报Uncaught RangeError: Maximum call stack size exceeded错误。

webAPI

现在我们进入主题。

JavaScript引擎将代码从头执行到尾,不断的进行压栈和出栈操作。除了ECMAScript语法组成的代码之外,我们还会写哪些代码呢?不错,还有JavaScript运行时给我们提供的各种webAPI。运行时(runtime)简单讲就是JavaScript运行所在的环境。

我们重点讨论三种webAPI。

const url = 'https://api.github.com/users/veedrin/repos';
fetch(url).then(res => res.json()).then(console.log);
const url = 'https://api.github.com/users/veedrin/repos';
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onload = () => {
    if (xhr.status === 200) {
        console.log(xhr.response);
    }
}
xhr.send();

发起异步的HTTP请求,这几乎是一个网页必要的模块。我们知道HTTP请求的速度和结果取决于当前网络环境和服务器的状态,JavaScript引擎无法原地等待,所以浏览器得另开一个线程来处理HTTP请求,这就是之前提到的异步HTTP请求线程

const timeoutId = setTimeout(() => {
    console.log(Date.now());
    clearTimeout(timeoutId);
}, 5000);
const intervalId = setInterval(() => {
    console.log(Date.now());
}, 1000);
const immediateId = setImmediate(() => {
    console.log(Date.now());
    clearImmediate(immediateId);
});

定时器也是一个棘手的问题。首先,JavaScript引擎同样无法原地等待;其次,即便不等待,JavaScript引擎也得执行后面的代码,根本无暇给定时器定时。所以于情于理,都得为定时器单独开一个线程,这就是之前提到的定时器线程

const $btn = document.getElementById('btn');
$btn.addEventListener('click', console.log);

按道理来讲,DOM事件没什么异步动作,直接绑定就行了,不会影响后面代码的执行。

别急,我们来看一个例子。

const $btn = document.getElementById('btn');
$btn.addEventListener('click', console.log);
const timeoutId = setTimeout(() => {
    for (let i = 0; i < 10000; i++) {
        console.log('biu');
    }
    clearTimeout(timeoutId);
}, 5000);

运行代码,先绑定DOM事件,大约5秒钟后开启一个循环。注意,如果在循环结束之前点击按钮,浏览器控制台会打印什么呢?

结果是先打印10000个biu,接着会打印Event对象。

试想一下,你点击按钮的时候,JavaScript引擎还在处理该死的循环,根本没空理你。那为什么点击事件能够被响应呢(虽然有延时)?肯定是有另外一个线程在监听DOM事件。这就是之前提到的事件触发线程

任务队列

好的,现在我们知道有几类webAPI是单独的线程在处理。但是,处理完之后的回调总归是要由JavaScript引擎线程来执行的吧?这些线程是如何与JavaScript引擎线程通信的呢?

这就要提到大名鼎鼎的任务队列(Task Queue)。

其实无论是HTTP请求还是定时器还是DOM事件,我们都可以统称它们为事件。很好,各自的线程把各自的webAPI处理完,完成之后怎么办呢?它要把相应的回调函数放入一个叫做任务队列的数据结构里。队列和栈不一样,队列是先进先出的,讲究一个先来后到的顺序。

有很多文章认为任务队列是由JavaScript引擎线程维护的,也有很多文章认为任务队列是由事件触发线程维护的。

根据上文的描述,事件触发线程是专门用来处理DOM事件的。

然后我们来论证,为什么任务队列不是由JavaScript引擎线程维护的。假如JavaScript引擎线程在执行代码的同时,其他线程要给任务队列添加事件,这时候它哪忙得过来呢?

所以根据我的理解,任务队列应该是由一个专门的线程维护的。我们就叫它任务队列线程吧。

事件循环

JavaScript引擎线程把所有的代码执行完了一遍,现在它可以歇着了吗?也许吧,接下来它还有一个任务,就是不停的去轮询任务队列,如果任务队列是空的,它就可以歇一会,如果任务队列中有回调,它就要立即执行这些回调。

这个过程会一直进行,它就是事件循环(Event Loop)。

我们总结一下这个过程:

  • 第一阶段,JavaScript引擎线程从头到尾把脚本代码执行一遍,碰到需要其他线程处理的代码则交给其他线程处理。
  • 第二阶段,JavaScript引擎线程专注于处理事件。它会不断的去轮询任务队列,执行任务队列中的事件。这个过程又可以分解为轮询任务队列-执行任务队列中的事件-更新页面视图的无限往复。对,别忘了更新页面视图(如果需要的话),虽然更新页面视图是GUI渲染线程 处理的。

这些事件,在任务队列里面也被称为任务。但是事情没这么简单,任务还分优先级,这就是我们常听说的宏任务和微任务。

宏任务

既然任务分为宏任务和微任务,那是不是得有两个任务队列呢?

此言差矣。

首先我们得知道,事件循环可不止一个。除了window event loop之外,还有worker event loop。并且同源的页面会共享一个window event loop。

A window event loop is the event loop used by similar-origin window agents. User agents may share an event loop across similar-origin window agents.

其次我们要区分任务和任务源。什么叫任务源呢?就是这个任务是从哪里来的。是从addEventListener来的呢,还是从setTimeout来的。为什么要这么区分呢?比如键盘和鼠标事件,就要把它的响应优先级提高,以便尽可能的提高网页浏览的用户体验。虽然都是任务,命可分贵贱呢!

所以不同任务源的任务会放入不同的任务队列里,浏览器根据自己的算法来决定先取哪个队列里的任务。

总结起来,宏任务有至少一个任务队列,微任务只有一个任务队列。

微任务

哪些异步事件是微任务?Promise的回调、MutationObserver的回调以及nodejs中process.nextTick的回调。

<div id="outer">
    <div id="inner">请点击</div>
</div>
const $outer = document.getElementById('outer');
const $inner = document.getElementById('inner');

new MutationObserver(() => {
    console.log('mutate');
}).observe($inner, {
    childList: true,
});

function onClick() {
    console.log('click');
    setTimeout(() => console.log('timeout'), 0);
    Promise.resolve().then(() => console.log('promise'));
    $inner.innerHTML = '已点击';
}

$inner.addEventListener('click', onClick);
$outer.addEventListener('click', onClick);

我们先来看执行顺序。

click
promise
mutate
click
promise
mutate
timeout
timeout

整个执行过程是怎样的呢?

  • 从头到尾初始执行脚本代码。给DOM元素添加事件监听。
  • 用户触发内元素的DOM事件,同时冒泡触发外元素的DOM事件。将内元素和外元素的DOM事件回调添加到宏任务队列中。
  • 因为此时调用栈中是空闲的,所以将内元素的DOM事件回调放入调用栈。
  • 执行回调,此时打印click。同时将setTimeout的回调放入宏任务队列,将Promise的回调放入微任务队列。因为修改了DOM元素,触发MutationObserver事件,将MutationObserver的回调放入微任务队列。回顾一下,现在宏任务队列里有两个回调,分别是外元素的DOM事件回调setTimeout的回调;微任务队列里也有两个回调,分别是Promise的回调MutationObserver的回调
  • 依次将微任务队列中的回调放入调用栈,此时打印promisemutate
  • 将外元素的DOM事件回调放入调用栈。执行回调,此时打印click。因为两个DOM事件回调是一样的,过程不再重复。再次回顾一下,现在宏任务队列里有两个回调,分别是两个setTimeout的回调;微任务队列里也有两个回调,分别是Promise的回调MutationObserver的回调
  • 依次将微任务队列中的回调放入调用栈,此时打印promisemutate
  • 最后依次将setTimeout的回调放入调用栈执行,此时打印两次timeout

规律是什么呢?宏任务与宏任务之间,积压的所有微任务会一次性执行完毕。这就好比超市排队结账,轮到你结账的时候,你突然想顺手买一盒冈本。难道超市会要求你先把之前的账结完,然后重新排队吗?不会,超市会顺便帮你把冈本的账也结了。这样效率更高不是么?虽然不知道内部的处理细节,但是我觉得标准区分两种任务类型也是出于性能的考虑吧。

$inner.click();

如果DOM事件不是用户触发的,而是程序触发的,会有什么不一样吗?

click
click
promise
mutate
promise
timeout
timeout

严格的说,这时候并没有触发事件,而是直接执行onClick函数。翻译一下就是下面这样的效果。

onClick();
onClick();

这样就解释了为什么会先打印两次click。而MutationObserver会合并多个事件,所以只打印一次mutate。所有微任务依然会在下一个宏任务之前执行,所以最后才打印两次timeout

更新页面视图

我们再来看一个例子。

const $btn = document.getElementById('btn');

function onClick() {
    setTimeout(() => {
        new Promise(resolve => resolve('promise 1')).then(console.log);
        new Promise(resolve => resolve('promise 2')).then(console.log);
        console.log('timeout 1');
        $btn.style.color = '#f00';
    }, 1000);
    setTimeout(() => {
        new Promise(resolve => resolve('promise 1')).then(console.log);
        new Promise(resolve => resolve('promise 2')).then(console.log);
        console.log('timeout 2');
    }, 1000);
    setTimeout(() => {
        new Promise(resolve => resolve('promise 1')).then(console.log);
        new Promise(resolve => resolve('promise 2')).then(console.log);
        console.log('timeout 3');
    }, 1000);
    setTimeout(() => {
        new Promise(resolve => resolve('promise 1')).then(console.log);
        new Promise(resolve => resolve('promise 2')).then(console.log);
        console.log('timeout 4');
        // alert(1);
    }, 1000);
    setTimeout(() => {
        new Promise(resolve => resolve('promise 1')).then(console.log);
        new Promise(resolve => resolve('promise 2')).then(console.log);
        console.log('timeout 5');
        // alert(1);
    }, 1000);
    setTimeout(() => {
        new Promise(resolve => resolve('promise 1')).then(console.log);
        new Promise(resolve => resolve('promise 2')).then(console.log);
        console.log('timeout 6');
    }, 1000);
    new MutationObserver(() => {
        console.log('mutate');
    }).observe($btn, {
        attributes: true,
    });
}

$btn.addEventListener('click', onClick);

当我在第4个setTimeout添加alert,浏览器被阻断时,样式还没有生效。

有很多人说,每一个宏任务执行完并附带执行完累计的微任务(我们称它为一个宏任务周期),这时会有一个更新页面视图的窗口期,给更新页面视图预留一段时间。

但是我们的例子也看到了,每一个setTimeout都是一个宏任务,浏览器被阻断时事件循环都好几轮了,但样式依然没有生效。可见这种说法是不准确的。

而当我在第5个setTimeout添加alert,浏览器被阻断时,有很大的概率(并不是一定)样式会生效。这说明什么时候更新页面视图是由浏览器决定的,并没有一个准确的时机。

总结

JavaScript引擎首先从头到尾初始执行脚本代码,不必多言。

如果初始执行完毕后有微任务,则执行微任务(为什么这里不属于事件循环?后面会讲到)。

之后就是不断的事件循环。

首先到宏任务队列里找宏任务,宏任务队列又分好多种,浏览器自己决定优先级。

被放入调用栈的某个宏任务,如果它的代码中又包含微任务,则执行所有微任务。

更新页面视图没有一个准确的时机,是每个宏任务周期后更新还是几个宏任务周期后更新,由浏览器决定。

也有一种说法认为:从头到尾初始执行脚本代码也是一个任务。

如果我们认可这种说法,则整个代码执行过程都属于事件循环。

初始执行就是一个宏任务,这个宏任务里面如果有微任务,则执行所有微任务。

浏览器自己决定更新页面视图的时机。

不断的往复这个过程,只不过之后的宏任务是事件回调。

第二种解释好像更说得通。因为第一种解释会有一段微任务的执行不在事件循环里,这显然是不对的。