单线程的 Javascript 为什么可以异步

前言

看下面一段代码:

console.log("start");

setTimeout(() => {
    console.log("children2");

    Promise.resolve().then(() => {
        console.log("children3");
    });
}, 0);

new Promise((resolve, reject) => {
    console.log("children4");

    setTimeout(() => {
        console.log("children5");

        resolve("children6");
    }, 0);
}).then(result => {
    console.log("children7");

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

打印结果:

start
chidlren4
chidlren2
chidlren3
chidlren5
chidlren7
chidlren6

疑问:既然 Javascript 是单线程的,为什么不是从上到下的打印结果呢?

浏览器内核是多线程的

虽然 Javascript 是单线程的,但是浏览器却不是,在内核控制下,各线程相互配合以保持同步,一个浏览器通常由以下常驻线程组成:

  • 浏览器
    • 浏览器进程
    • 渲染进程
      • Javascript 引擎线程
      • GUI 渲染线程
      • 定时触发器线程
      • 事件触发线程
      • 异步 HTTP 请求线程
    • GPU 进程
    • 网络进程
    • 插件进程

Javascript 引擎线程称为主线程,其他可以称为辅助线程,这些辅助线程便是 Javascript 实现异步的关键

Javascript 引擎线程

Javascript 引擎,也叫 Javascript 内核,主要负责处理 Javascript 脚本程序,解析 Javascript 脚本,运行代码,例如 V8 引擎

GUI 渲染线程

GUI 渲染线程,负责渲染浏览器界面,解析 HTML、CSS,构建 DOM 树和 RenderObject 树,布局和绘制等

当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行

Javascript 引擎线程运行脚本期间,GUI 渲染线程都是处于挂起状态的,也就是说被“冻结”了

GUI 渲染线程Javascript 引擎线程互斥

由于 Javascript 是可操纵 DOM 的,如果在修改这些元素属性同时渲染界面(即Javascript 引擎线程GUI 渲染线程同时运行),那么GUI 渲染前后获得的元素数据就可能不一致了;因此,为了防止渲染出现不可预期的结果,浏览器设置GUI 渲染线程Javascript 引擎线程为互斥的关系,当Javascript 引擎线程执行时,GUI 渲染线程会被挂起,GUI 更新会被保存在一个队列中,等到Javascript 引擎线程空闲时立即被执行。

Javascript 阻塞页面加载

由于GUI 渲染线程Javascript 引擎线程是互斥的关系,当浏览器在执行 Javascript 程序的时候,GUI 渲染线程会被保存在一个队列中,直到 Javascript 程序执行完成,才会接着执行;因此,如果 Javascript 执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。

定时触发器线程

setIntervalsetTimeout所在线程,避免Javascript 引擎线程处于阻塞线程状态影响计时的准确

事件触发线程

将满足触发条件的事件放入任务队列

当一个事件被触发时,事件触发线程会把事件添加到待处理队列的队尾,等待Javascript 引擎线程的处理;这些事件可以是当前执行的代码块,如定时任务,也可来自浏览器内核的其他线程,如鼠标点击、Ajax 异步请求等,但由于 Javascript 的单线程关系,所有这些事件都得排队,等待Javascript 引擎线程处理。

异步 HTTP 请求线程

XMLHttpRequest 在连接后,通过浏览器新开一个线程请求,将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件放到Javascript 引擎线程的处理队列中等待处理。

任务队列

任务队列,即事件循环(Event Loop),Javascript 管理事件执行的一个流程

当任务加入到任务队列后并不会立即执行,而是处于等候状态,等主线程处理完了自己的事情后,才来执行任务队列中任务

任务又分为两种:

  • 宏任务(MacroTask 或者 Task)
    • script
    • setInterval/setTimeout
    • setImmediate(NodeJS)
    • requestAnimationFrame
    • I/O
    • ajax
    • 事件绑定
    • MessageChannel
    • postMessage
  • 微任务(MicroTask)
    • UI rendering
    • Promise
    • process.nextTick(NodeJS)
    • Object.observe
    • MutationObserve

微任务拥有更高的优先级,可以插队,当事件循环遍历队列时,先检查微任务队列,如果里面有任务,就全部拿来执行,执行完之后再执行一个宏任务

需要注意的是:

  1. 一个事件循环可以有一个或多个事件队列,但是只有一个微任务队列。
  2. 微任务队列全部执行完会重新渲染一次
  3. 每个宏任务执行完都会重新渲染一次
  4. requestAnimationFrame 处于渲染阶段,不在微任务队列,也不在宏任务队列

Web Worker

Web Worker 能够同时执行两段 Javascript,不代表 Javascript 实现了多线程,Web Worker 是向浏览器申请一个子线程,该子线程服务于主线程,完全受主线程控制

  • 同源限制
  • DOM 限制
  • 通信限制
  • 脚本限制
  • 文件限制

同源限制

分配给 Worker 线程运行的脚本文件,必须和主线程的脚本文件同源

DOM 限制

Worker 线程所在的全局对象与主线程不一样,无法读取主线程所在网页的 DOM 对象,也无法使用 document、window、parent 这些对象,但是,Worker 线程可以使用 navigator 对象和 location 对象

通信限制

Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成

脚本限制

Worker 线程不能执行 alter 和 confirm 方法,但可以使用 XMLHttpRequest 对象发出 Ajax 请求

文件限制

Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://),它所加载的脚本必须来自网络

最后

回到前言,分析执行流程:

  1. 先执行宏任务 script 脚本
  2. 执行 console.log("start")
  3. 遇到定时器,交由定时触发器线程,等待时间到了加入队列
  4. 遇到 Promise 直接执行 executor,打印 console.log("children4");遇到第二定时器,又交由定时触发器线程管理,等待加入队列;Promise.then等 resolve 之后加入微队列;此时,第一轮任务执行完毕
  5. 第一定时器先进入队列,取出任务执行 console.log("children2"),此时遇到 Promise 执行,并将 Promise.then 放入当前宏任务队列中的微任务队列,当前任务执行完毕;执行 then,打印 console.log("children3")
  6. 取出第二定时器,打印 console.log("children5"),并将 then 放入微任务中,当前宏任务执行完毕,取出 then 执行打印 console.log("children7")
  7. 又遇到定时器,由定时触发器线程等待时间到了添加到宏任务中
  8. 取出定时器任务,打印 console.log("children6")
本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!