探索前端线程魔法:从单线程到消息队列,解密浏览器异步执行的奇妙世界

使用单线程处理好安排的任务

js 复制代码
function add() {
    const a = 1 + 2; // 任务一
    const b = 1 + 3; // 任务二
    const c = 1 + 4; // 任务三
    console.log('a: ', a, ', b: ', b, ', c: ', c); // 任务四
}
add();

在执行这段代码的时候,会把所有的任务都按照顺序写进主线程中,等到线程执行时,这些任务会按照顺序在线程中依次被执行,等到所有任务执行完成之后,线程会自动推出。

在线程运行过程中处理新任务

但并不是所有的任务都是在执行代码的时候都统一安排好的,大部分情况下都会有新的任务在线程运行过程中产生。那当有新任务产生的时候上述方式就无法满足这种情况。

所以为了满足这种在线程运行过程中,能够接收并执行新的任务,就需要采用事件循环机制

可以通过for循环来监听是否有新的任务。

js 复制代码
// 获取用户输入的函数
function getInput() {
    let inputNumber = 0;
    // 通过 prompt 获取用户输入
    const userInput = prompt("请输入一个数字:");

    // 将用户输入的字符串转换为数字
    inputNumber = parseInt(userInput);

    // 检查是否成功转换为数字
    if (isNaN(inputNumber)) {
        console.log("输入无效,请输入一个有效的数字。");
        return getInput(); // 递归调用以重新获取有效输入
    }

    return inputNumber;
}

// 主线程函数
function mainThread() {
    for (; ;) {
        const firstNumber = getInput();
        const secondNumber = getInput();
        const resultNum = firstNumber + secondNumber;

        // 使用 console.log 输出结果
        console.log("和为:", resultNum);
    }
}

// 在浏览器中运行时,可以使用 alert 替代 prompt
// function getInput() {
//     let inputNumber = 0;
//     const userInput = prompt("请输入一个数字:");

//     inputNumber = parseInt(userInput);

//     if (isNaN(inputNumber)) {
//         alert("输入无效,请输入一个有效的数字。");
//         return getInput();
//     }

//     return inputNumber;
// }

// 调用主线程函数
mainThread();

相对于上面的,这一段代码做了如下改进:

第一:引入了循环机制: 具体实现就是在线程语句最后添加了一个for循环语句,线程会一直循环执行。

第二:引入了事件: 可以在线程运行的时候,等待用户输入的数字,等待过程中线程处于阻塞状态,一旦接收到用户输入的信息,那么线程就会被激活,然后执行相加运算,最后输出结果。

通过引入事件循环机制,就可以让线程"活"起来,我们每次输入两个数字都会打印两个数字相加的结果。

处理其他线程发送过来的任务

上面改进了线程的执行方式,引入了事件循环机制,可以让线程执行的过程中接收新的任务,但是这种模型中,所有的任务都是来自于线程内部,如果另外一个线程想让主线程执行一个任务,这种模型无法做到。

从图中可以看出渲染主进程会频繁收到IO线程的一些任务,接收这些任务之后,渲染主进程就会进行处理。

线程接收其他线程发送的消息的模型:消息队列

消息队列是一种数据结构,可以存放要执行的任务。符合队列"先进先出"的特点,也就是需要添加任务的时候,添加到队列尾部,要取出任务的时候,从队列的头部取出。

从图中可以得知,改造分为三个步骤

  1. 添加一个消息队列
  2. IO线程中产生的新任务添加进消息队列尾部
  3. 渲染主进程会循环的从消息队列头部读取任务,执行任务。
js 复制代码
// 创建消息队列类
class MessageQueue {
    constructor() {
        this.queue = [];
    }

    // 向消息队列尾部添加任务
    enqueue(task) {
        this.queue.push(task);
    }

    // 从消息队列头部取出任务
    dequeue() {
        return this.queue.shift();
    }

    // 判断消息队列是否为空
    isEmpty() {
        return this.queue.length === 0;
    }
}

// 模拟IO线程产生的任务
function produceTask(messageQueue) {
    setInterval(() => {
        // 模拟IO线程产生一个任务
        const task = () => {
            console.log("执行IO线程产生的任务");
        };
        // 将任务加入消息队列
        messageQueue.enqueue(task);
    }, 2000); // 假设每两秒产生一个任务
}

// 模拟渲染主进程的事件循环
function eventLoop(messageQueue) {
    setInterval(() => {
        if (!messageQueue.isEmpty()) {
            // 从消息队列取出一个任务
            const task = messageQueue.dequeue();
            // 执行任务
            task();
        }
    }, 1000); // 假设每秒执行一次事件循环
}

// 创建消息队列实例
const messageQueue = new MessageQueue();

// 启动IO线程模拟任务的产生
produceTask(messageQueue);

// 启动渲染主进程的事件循环
eventLoop(messageQueue);

消息队列中的任务类型

常见的任务类型

  1. 异步操作任务: 包括从网络获取数据、读取文件、定时器回调等。
  2. 事件处理任务: 处理用户输入、鼠标事件、键盘事件等。
  3. UI 更新任务: 更新用户界面的任务,例如在数据变化时重新渲染视图。
  4. 定时器任务: 通过 setTimeoutsetInterval 等设置的定时任务。
  5. Promise 回调任务: 处理异步操作完成后的回调。
  6. Web API 回调任务: 处理诸如 fetchXMLHttpRequest 等 Web API 的异步回调。
  7. 队列操作任务: 对消息队列进行操作的任务,例如添加或删除任务。
  8. 用户自定义任务: 应用程序中定义的任务,根据具体需求而定。

如何安全退出

当页面主线程执行完成之后,如果确定需要退出当前页面,页面主进程会设置一个退出标志,在每次执行完一个任务的时候都会判断退出标志。

页面使用单线程的缺点

如何处理优先级高的任务

我们把任务又划分成宏任务微任务,我们把消息队列中的任务称为宏任务,每个宏任务中包含了一个微任务队列。

宏任务(Macro Task):

  • 定义: 宏任务代表的是一个离散的、完整的工作单元。在浏览器环境中,宏任务可以包括整体的 script 执行、setTimeout、setInterval、I/O 操作等。
  • 执行时机: 宏任务会被放入宏任务队列中,在每轮事件循环中执行一个宏任务。当前宏任务执行完成后,才会去执行下一个宏任务。

微任务(Micro Task):

  • 定义: 微任务通常来说是一个需要异步执行的较小的任务单元。在浏览器环境中,微任务包括Promise的回调、MutationObserver 回调等。
  • 执行时机: 微任务会被放入微任务队列中,在当前宏任务执行完毕、下一个宏任务开始执行之前,将微任务队列中的所有微任务执行完毕。

执行流程:

  1. 执行宏任务: 事件循环开始时,执行当前宏任务队列中的第一个宏任务。
  2. 执行微任务: 在执行完当前宏任务后,检查微任务队列,将微任务队列中的所有微任务依次执行完毕。
  3. 更新渲染: 浏览器进行渲染,更新页面显示。
  4. 重复: 重复上述过程,形成一个事件循环。

这样的设计有助于更细致地控制任务的执行顺序,确保某些任务在其他任务之前或之后执行。例如,Promise 的回调就是微任务,会在当前任务结束后、下一个任务开始前执行,这有助于处理异步代码,而不影响页面的渲染和响应。

单个任务执行时长过久

因为所有任务都是在单线程中执行的,所以每次都只能执行一个任务,其他任务就都处于等待状态,如果一个任务执行过久,那么下一个任务就需要等待很长事件,就会造成页面卡顿现象。

Chrome 采用的优化策略:

  1. 任务分片: Chrome 使用任务分片(Task Scheduling)的方式,将长时间运行的任务划分为多个小片段。这样可以让其他任务在分片任务之间插入执行,从而提高页面的响应性。
  2. requestAnimationFrame: 长时间运行的任务可以通过 requestAnimationFrame 进行分片,将任务拆分成多个帧中执行,确保每一帧都有时间执行渲染和用户交互。
  3. Web Workers: Chrome 支持 Web Workers,允许在后台线程中执行计算密集型任务,避免阻塞主线程。这有助于保持页面的响应性,特别是对于一些需要大量计算的任务。
  4. Idle Callback: Chrome 支持 requestIdleCallback,该 API 允许在浏览器空闲时执行任务。这可以用于执行一些不紧急但耗时的任务,而不影响页面的实时交互。
  5. 事件循环的微任务: Chrome 使用事件循环机制,通过微任务队列来执行异步任务。Promise 的回调、async/await 中的任务都属于微任务,会在宏任务执行结束后立即执行,确保异步任务及时执行。
  6. Timeouts 和 Intervals 优化: ChromesetTimeoutsetInterval 进行了优化,确保它们的执行不会过早或过晚。Chrome 会将定时器的回调任务放入合适的宏任务队列中,以保证执行的准确性。
  7. DevTools 的性能分析: Chrome 提供强大的开发者工具,包括性能分析工具,可以帮助开发者检测性能问题,分析任务执行时间,并定位潜在的性能瓶颈。
相关推荐
-seventy-3 分钟前
对 JavaScript 原型的理解
javascript·原型
&白帝&21 分钟前
uniapp中使用picker-view选择时间
前端·uni-app
谢尔登28 分钟前
Babel
前端·react.js·node.js
ling1s28 分钟前
C#基础(13)结构体
前端·c#
卸任35 分钟前
使用高阶组件封装路由拦截逻辑
前端·react.js
lxcw1 小时前
npm ERR! code CERT_HAS_EXPIRED npm ERR! errno CERT_HAS_EXPIRED
前端·npm·node.js
秋沐1 小时前
vue中的slot插槽,彻底搞懂及使用
前端·javascript·vue.js
这个需求建议不做1 小时前
vue3打包配置 vite、router、nginx配置
前端·nginx·vue
QGC二次开发1 小时前
Vue3 : Pinia的性质与作用
前端·javascript·vue.js·typescript·前端框架·vue
云草桑1 小时前
逆向工程 反编译 C# net core
前端·c#·反编译·逆向工程