一文读懂Javascript事件循环

184 阅读8分钟

浏览器的进程和线程

何为进程

当启动一个程序时,操作系统就会给该程序创建一个内存空间(当程序被中止时,该内存空间就会被回收),该内存空间就是用来存放程序代码,运行中的数据和一个执行任务的主线程,这样的一个运行环境(内存空间)就被称为进程。

进程的特点: 进程与进程之间是完全隔离独立运行。一个进程崩溃不会影响其他进程,避免一个进程出错影响整个程序。进程与进程之间的通信,代价较大,需要借助进程通信管道 IPC 来传递。

何为线程

线程是依附于进程的,所以在进程开启后会自动创建一个线程来运行代码,该线程被称为主线程。如果程序要同时执行多块代码,主线程就会启动更多的线程来运行,所以一个进程中包含多个线程。

线程的特点: 一个进程包含多个线程,每个线程并行执行不同的任务。其中一个线程崩溃了,那么整个进程也就崩溃了。线程之间可以相互通信

浏览器有多个进程和多个线程

浏览器是一个多进程架构设计,每打开一个标签页就会创建一个进程。当然浏览器内部也有着自己的优化,比如:当浏览器同时打开多个空白标签页的时候,会合并成一个进程。

浏览器到关闭到启动,至少开启四个进程:1 个 browser进程,1 个 GPU进程,1 个 网络进程,1 个 渲染进程

默认情况下,每打开一个标签页,就会开启一个渲染进程。但是也会存在特殊的情况,就是如果打开的标签页在同一个站点下,会共享同一个渲染进程。

可以在浏览器的任务管理器中查看当前的所有进程。

其中最主要的进程:

  1. browser进程:主要负责界面显示、用户交互、子进程管理等。浏览器进程内部会启动多个线程处理不同的任务。
  2. 网络进程:负责加载网络资源。网络进程内部会启动多个线程来处理不同的网络任务。
  3. 渲染进程:渲染进程启动后,会开启一个渲染主线程,主线程负责执行 HTML、CSS、JS 代码。默认情况下,每打开一个标签页,就会开启一个渲染进程,以保障不同标签页之间互不影响。

渲染主线程

渲染主线程是浏览器中最繁忙的线程,它处理的任务包括且不限于:

  • 解析 HTML、CSS
  • 计算样式及布局
  • 处理及渲染图层
  • 执行 JS 代码及执行事件、处理函数等

那么,渲染进程为什么不使用多个线程来处理这些事情呢?

  1. ‌JavaScript的单线程性质:Javascript 最初设计为单线程语言就是为了避免并发问题和状态管理的复杂性。
  2. DOM的一致性:如果多个线程同时修改 DOM,可能会导致不可预测的结果。
  3. CSS的渲染规则:CSS 通常依赖于文档的顺序和嵌套关系。多线程可能会干扰这些规则,导致渲染不一致。
  4. 事件循环:Javascript 的事件循环机制确保了异步操作和回调的顺序执行。引入多线程可能会导致事件循环的管理和调度变得更加复杂。
  5. 浏览器安全模型:浏览器安全模型通常基于单线程模型设计。多线程可能会引入新的安全漏洞和跨站脚本攻击的风险。
  6. 性能和资源管理:引入多线程会增加资源管理的复杂性。线程之间的通信和同步会引起额外开销。
  7. 兼容性和标准:引入多线程会影响现有代码的兼容性,并需要新的 Web 标准和规范。
  8. 开发者体验:多线程编程比单线程要复杂的多,加大了开发者的能力要求。

主线程任务调度

既然主线程只能是单线程,那么要处理这么多任务,主线程会如何调度呢?方法就是:排队,这个过程就叫做事件循环

image-6.png

  1. 在最开始的时候,渲染主线程会进入一个无限循环
  2. 每一次循环会检查任务队列中是否有任务存在。如果有,就取出第一个任务执行,执行完之后进入下一次循环。如果没有,则进入休眠状态。
  3. 其他所有线程都可以随时向任务队列添加任务。新任务会加到任务队列末尾。在添加新任务时,如果主线程处于休眠状态,则会将其唤醒以继续循环拿取任务。

何为异步?事件循环与异步的关系

在代码执行过程中,往往会遇到一些无法立即处理的任务。例如:

  • 计时完成后需要执行的任务 --------- setTimeout、setInterval
  • 网络通信完成后需要执行的任务 --------- xhr、fetch、promise 等
  • 用户操作后需要执行的任务 --------- addEventListener 等

如果让渲染主线程等待这些任务的时机到达,那么会导致主线程长期处于阻塞状态,从而导致浏览器卡死

因此,浏览器通过 异步 来解决该问题。使用异步的目的就是让主线程永不阻塞,从而最大限度的保证了单线程的流畅运行。

任务是否有优先级

任务没有优先级,在消息队列中遵循先进先出原则。但是,消息队列是有优先级的。 根据 W3C 的最新解释:

  • 每个任务都有一个任务类型,同一个类型的任务必须在同一队列;不同类型的任务可以分属于不同队列。在一次事件循环中,浏览器可以根据实际情况从不同队列中取出任务执行。
  • 浏览器必须准备好一个微队列,微队列中的任务优先级要高于其他任务队列

目前,谷歌浏览器中至少包含下面几个队列:

  • 延时队列:用于存放计时器到达之后的回调任务,优先级:中
  • 交互队列:用于存放用户操作后产生的事件处理任务,优先级: 高
  • 微队列:用户存放需要最快执行的任务,优先级:最高

    添加任务到微队列的主要方式一般通过 promise、MutationObserver

经典面试题

解释一下 JS 的事件循环

事件循环又叫做消息循环,是浏览器渲染主线程的工作方式。

在谷歌浏览器中,主线程是一个不会结束的 for 循环,每次循环从消息队列中取出第一个任务执行,而其他线程只需要将任务在合适时间从消息队列末尾加入。

根据最新的 W3C 规范:每个任务都有不同的类型,同一类型的任务必须在同一个队列,不同的任务处于不同队列。不同队列有不同的优先级,在一次事件循环中,浏览器自行决定取哪一个队列的任务。但浏览器必须有一个微队列,并且微队列的优先级最高,必须优先调度执行。

JS 中的计时器能否做到精确计时,为什么

不能做到,因为:

  • 计算机硬件没有原子钟,无法进行精确计时。
  • 操作系统的计时函数本身就有少量偏差,由于 JS 的计时器最终调用的是操作系统的函数,也就携带了这种偏差。
  • 根据 W3C 标准,浏览器实现计时器是,如果嵌套层级超过 5 层,则会携带 4 毫秒的最少时间。
  • 受事件循环影响,计时器的回调函数只能在主线程空闲时间运行,因此又带来了偏差。

输出下面函数的结果

function a() {
    console.log(1);
    Promise.resolve().then(function() {
        console.log(2)
    })
}

setTimeout(() => {
    console.log(3)
    Promise.resolve().then(a)
}, 0)

Promise.resolve().then(function() {
    console.log(4)
})

console.log(5)

输出结果: 5、4、3、1、2

async function async1() {
    console.log("async1 start");
    await async2();
    console.log("async1 end");
}
async function async2() {
    console.log("async2");
}

console.log("script start");

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

async1();

new Promise(function (resolve) {
    console.log("promise1");
    resolve();
}).then(function () {
    console.log("promise2");
});
console.log("script end");

输出结果:script start、async1 start、async2、promise1、script end、async1 end、promise2、setTimeout

async function a1() {
    console.log("a1 start");
    await a2();
    console.log("a1 end");
}
async function a2() {
    console.log("a2");
}

console.log("script start");

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

Promise.resolve().then(() => {
    console.log("promise1");
});

a1();

let promise2 = new Promise((resolve) => {
    resolve("promise2.then");
    console.log("promise2");
});

promise2.then((res) => {
    console.log(res);
    Promise.resolve().then(() => {
        console.log("promise3");
    });
});
console.log("script end");

输出结果:script start、a1 start、a2、promise2、script end、promise1、a1 end、promise2.then、promise3、setTimeout

const first = () => (new Promise((resolve, reject) => {
    console.log(3);
    let p = new Promise((resolve, reject) => {
        console.log(7);
        setTimeout(() => {
            console.log(5);
            resolve(6);
        }, 0)
        resolve(1);
    });
    resolve(2);
    p.then((arg) => {
        console.log(arg);
    });
}));

first().then((arg) => {
    console.log(arg);
});
console.log(4);

输出结果:3、7、4、1、2、5

注意:resolve(6) 不会生效,因为 p 这个 Promise 的状态一旦改变就不会在改变了。