由浅入深理解JS事件循环

430 阅读7分钟

涉及知识点:同步任务、异步任务、事件循环(Event Loop)、并发模型、事件队列(任务队列、消息队列、Event Queue)、执行栈(调用栈)、宏任务(macrotask)、微任务(microtask)、浏览器的进程、浏览器内核的线程

前言

JS 是单线程语言。JS 在设计之初用作用户互动,以及操作 DOM。这决定了它只能是单线程(例如多线程操作同一 DOM,一个删除一个修改,这样会产生冲突)。

这样所导致的问题是:如果 JS 执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞

Snipaste_2023-04-09_15-14-51.png

为了解决这个问题,模仿 "多线程" ,JS 中出现了同步任务和异步任务。在讲 JS 任务执行机制前,先要了解一下什么是同步任务与异步任务。

同步任务:即主线程上的任务,按照顺序由上⾄下依次执⾏,当前⼀个任务执⾏完毕后,才能执⾏下⼀个任务。

异步任务:不进⼊主线程,⽽是进⼊任务队列的任务,执行完毕之后会产生一个回调函数,并且通知主线程。当主线程上的任务执行完后,就会调取最早通知自己的回调函数,使其进入主线程中执行。

Snipaste_2023-04-09_15-15-05.png

事件循环(Event Loop)

对于 JS 运行中的任务,JS 有一套处理收集排队执行的特殊机制,我们把这套处理机制称为事件循环(Event Loop)。事件循环又叫做消息循环,是浏览器渲染主线程的工作方式。

在 Chrome 的源码中,它开启一个不会结束的 for 循环,每次循环从消息队列中取出第一个任务执行,而其他线程只需要在合适的时候将任务加入到队列末尾即可。

并发模型

JavaScript 有一个基于事件循环的并发模型,事件循环负责执行代码、收集和处理事件以及执行队列中的子任务。这个模型与其他语言中的模型截然不同,比如 C 和 Java。

可视化描述(from MDN):

Stack, heap, queue

  • Stack => 执行栈(调用栈)

    • 函数调用形成一个由若干帧组成的栈
  • Queue => 任务队列(消息队列)(事件队列)

    • 一个 JavaScript 运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用以处理这个消息的回调函数。
  • Heap => 对象堆

    • 对象被分配在堆中

执行过程

image-20230409125546160.png

主线程执行过程:

  1. 进入到script标签,执行主函数
  2. 遇到函数调用,从对象堆中找到对应的函数对象形成函数帧放入执行栈
  3. 遇到同步代码直接执行,遇到异步代码则交给对应异步处理线程,当处理完成形成回调时则会将回调任务放入事件队列队尾
  4. 继续执行完所有同步代码,该事件出栈
  5. 判断事件队列是否为空,不为空则将队头的事件放入执行栈执行
  6. 循环直至事件队列为空

宏任务和微任务的两种理解

一般理解

除了广义的同步任务和异步任务,JS 单线程中的任务又可以细分为宏任务和微任务。

  • 宏任务(macro-task):一般JS 引擎和宿主环境发生通信产生的回调任务,比如 setTimeout,setInterval 是浏览器进行计时的,其中回调函数的执行时间需要浏览器通知到 JS 引擎,网络模块, I/O处理的通信回调也是。包含有 setTimeout,setInterval,DOM事件回调,ajax请求结束后的回调,整体 script 代码,setImmediate。

  • 微任务(micro-task):一般是宏任务在线程中执行时产生的回调,如 Promise,process.nextTick,Object.observe(已废弃), MutationObserver(DOM监听),这些都是 JS 引擎自身可以监听到回调。

    • 使用微任务的最主要原因归纳为:确保任务顺序的一致性,即便当结果或数据是同步可用的,也要同时减少操作中用户可感知到的延迟而带来的风险。
  • 宏任务优先级,主代码块 > setImmediate > MessageChannel > setTimeout / setInterval

  • 微任务优先级,process.nextTick > Promise > MutationObserver(DOM监听)

最新解释

过去把消息队列简单分为宏队列微队列,这种说法目前已无法满足复杂的浏览器环境,取而代之的是一种更加灵活多变的处理方式。

根据 W3C 官方的解释,每个任务有不同的类型,同类型的任务必须在同一个队列,不同的任务可以属于不同的队列。不同任务队列有不同的优先级,在一次事件循环中,由浏览器自行决定取哪一个队列的任务。但浏览器必须有一个微队列。微队列的任务一定具有最高的优先级,必须优先调度执行。

在目前 chrome 的实现中,至少包含了下面的队列:

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

底层原理

浏览器进程和线程

进程和线程

  • 进程是cpu资源分配的最小单位(是能拥有资源和独立运行的最小单位)
  • 线程是cpu调度的最小单位(线程是建立在进程的基础上的一次程序运行单位)

浏览器进程

  • 浏览器,是一种多进程的架构设计,在浏览器中打开一个网页相当于新起了一个进程,当然,浏览器也有它自己的优化机制,比方说有五个空白页,这五个空白页会合并成同一个进程。

主要包含一下四种进程:

1. Browser进程(主进程)

控制浏览器的地址栏,书签栏,返回和前进按钮,同时还有浏览器的不可见部分,例如网络请求和文件访问

2. 渲染进程(浏览器内核)

负责界面渲染,脚本执行,事件处理等

3. GPU进程

仅此一个 ,用于3D绘制等

4. 插件进程

每种插件一个进程,插件运行时才会创建

渲染进程(浏览器内核)的线程

img

1. GUI线程
  • 负责渲染页面,解析 HTML,CSS 构成 DOM 树等,当页面重绘或者由于某种操作引起回流都会调起该线程。
  • 和 JS 引擎线程是互斥的,当 JS 引擎线程在工作的时候,GUI 渲染线程会被挂起,GUI 更新被放入在 JS 任务队列中,等待 JS 引擎线程空闲的时候继续执行。
  • script 加 defer:不会阻碍GUI渲染,GUI渲染完后等defer加载完后才会去渲染js。GUI渲染完成后会等待所有的加defer文件请求完成后,按照编写顺序执行这个defer文件。
  • script 加 async:单独开启请求,此时GUI继续渲染,等async请求回来后,GUI渲染会暂停,会去渲染js。
2. JS引擎线程
  • 单线程工作,负责解析运行 JavaScript 脚本。
  • 和 GUI 渲染线程互斥,JS 运行耗时过长就会导致页面阻塞。
3. 事件触发线程
  • 当事件符合触发条件被触发时,该线程会把对应的事件回调函数添加到任务队列的队尾,等待 JS 引擎处理。
4. 定时触发器线程
  • 浏览器定时计数器并不是由 JS 引擎计数的,阻塞会导致计时不准确。
  • 开启定时器触发线程来计时并触发计时,计时完成后会被添加到任务队列中,等待 JS 引擎处理。
5. 异步http请求线程
  • http 请求的时候会开启一条请求线程。
  • 请求完成有结果了之后,将请求的回调函数添加到任务队列中,等待 JS 引擎处理。

题目示例

(答案在最下面)

index.html

indexhtml.png

题目1:

index1js.png

题目2:

index2js.png

题目3:

index3js.png

题目4:

index4js.png

题目5:

index5js.png

题目6:

index6js.png

题目答案:

  1. 2 1
  2. 2 5 3 4 1
  3. 5 4 3 1 2
  4. (自测)
  5. 卡死......
  6. 3 4 7 1 2 5 6

ps: 关于题目的解析可在评论区提问讨论

参考资料

[前端进阶] - 搞懂浏览器进程和线程

浏览器渲染,进程与线程

js事件循环机制(await-async-事件循环)

并发模型与事件循环

你知道JS的执行原理吗?一文详解Event Loop事件循环、微任务、宏任务