# 事件循环 ## 浏览器多进程架构 从安全性、高性能等原因出发,目前浏览器已经是多进程架构模式,至于演进历史,本文不再展开,感兴趣的可以查看这篇[“Electron” 一个可圈可点的 PC 多端融合方案]()文章。 现在的架构设计如下: ![最新 Chrome 进程架构图](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/2020-05-07-ChromeMordernArch.png) 一个页面最少包括:1个网络进程、1个浏览器进程、1个 GPU 进程、多个渲染进程、多个插件进程 - 浏览器进程:负责界面展示、用户交互(比如滚动条)、子进程管理协作、同时提供存储功能。同时内部会启动多个线程处理不同的任务 - 渲染进程:核心任务是将 HTML、CSS、Javascript 转换为用户可以与之的网页,排版引擎 Blink 和 Javascript 引擎 V8 都是运行在该进程中。默认情况下,Chrome 为每个 tab 标签页创建一个新的渲染进程。出于安全考虑,渲染进程都是运行在沙箱机制下的。渲染进程启动后,会开启一个渲染主线程,主线程负责执行 HTML、CSS、JS 代码。 - GPU 进程:最早 Chrome 刚发布的时候是没有 GPU 进程的,而 GPU 的使用初衷是实现 css 3D 效果。随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍需求。最后 Chrome 多进程架构中也引入了 GPU 进程。 - 网络进程:主要负责页面的网络资源请求加载。早期是作为一个模块运行在浏览器进程里面的,最近才独立出来作为一个单独的进程。网络进程内部会启动多个线程来处理不同的网路任务 - 插件进程:主要负责插件的运行。因插件代码由普通开发者书写,所以在 QA 方面可能不是那么完善,代码质量参差不齐,插件容易奔溃,所以需要通过插件进程来隔离,以保证插件进程的奔溃不会对浏览器和页面造成影响。 所以,你会发现打开一个页面,查看进程发现有4个进程。凡事具有两面性,上面说了多进程架构带来浏览器稳定性、安全性、流畅性,但是也带来一些问题: - 更高资源占用:每个进程都会包含公共基础结构的副本(如 Javascript 运行环境),这意味着浏览器将会消耗更多的资源 - 更复杂的体系结构:浏览器各模块之间耦合度高、拓展性差,会导致现在的架构很难适应新需求。 ## 渲染主线程是如何工作的 渲染主线程是浏览器中最繁忙的线程,需要它处理的任务包括但不限于: - HTML 解析和构建 DOM 树:渲染主线程会解析 HTML 代码,并构建 DOM(文档对象模型)树。DOM 树是网页的结构化表示,它描述了网页中的元素、标签和它们之间的关系。 - CSS 解析和构建样式树:渲染主线程会解析 CSS 样式表,并构建样式树。样式树表示了网页中的元素和它们的样式信息,包括颜色、字体、布局等属性。 - 布局(Layout):渲染主线程会根据 DOM 树和样式树计算元素在页面中的位置和大小,确定它们的布局。布局过程也被称为回流(reflow)或重排(relayout)。 - 绘制(Painting):渲染主线程会将布局后的元素绘制到屏幕上,形成可见的网页内容。这个过程包括生成绘制命令、将命令发送给图形系统,并最终在屏幕上绘制出来。 - JavaScript 执行:渲染主线程会执行网页中的 JavaScript 代码,处理交互、动画和事件等。JavaScript 的执行可能会引起 DOM 树和样式树的变化,进而触发布局和绘制。 - 处理用户输入:渲染主线程会监听用户的输入事件,如鼠标点击、键盘输入等,并根据用户的操作进行相应的处理。这包括响应用户的点击、滚动、拖拽等操作,以及处理表单提交和页面跳转等事件。 渲染主线程是渲染进程中的核心部分,它负责将网页的结构、样式和交互转化为可视化的网页内容。通过优化渲染主线程的性能和效率,可以提高网页的渲染速度和用户体验。同时,渲染主线程也需要与其他线程(如网络线程、合成线程等)进行协作,以实现流畅的页面渲染和交互效果。 ## 主线程如何有条不紊工作 浏览器主线程很繁忙,遇到以下情况该怎么处理: - 正在执行某个方法,此时用户点击了按钮触发了事件,该立即去执行点击事件的处理函数吗? - 正在执行某个方法,此时某个计时器到达了时间,该立即去执行它的回调吗? - Input 输入框内容改变了,触发了 `oninput` 事件 ,此时刚好某个计时器也到达了时间,回调函数和 oninput 事件如何调度? 熟悉其他系统设计的同学可能会立马想到用**队列**来解决问题。浏览器对这个 case 也采用队列,叫做事件队列。 解释下这个事件队列: 1. 一开始,渲染主线程开启一个事件循环(也可以认为是死循环一直在跑) 2. 每一次循环会检查消息队列中是否有任务存在。如果有,就取出第一个任务执行,执行完一个后进入下一次循环;如果没有,则进入休眠状态 3. 其他所有线程(包括浏览器其他进程中的线程)都可以向这个事件队列中添加任务,新任务会被添加到队列尾部。添加新任务时,如果主线程是休眠的,则会唤醒主线程,继续从事件队列头部读取任务并不断执行 4. 持续这个流程 当然,这个解释是相对宏观宽泛的,具包括哪些队列、不同任务里的任务优先级是怎么样的下面会逐步展开。 1. 执行当前的同步任务:JS 引擎每次执行当前的同步任务,将其推入调用栈中执行。同步任务是按照代码顺序执行的,知道遇到异步任务 2. 执行微任务队列:在执行同步任务的过程中,如果遇到微任务(Microtask)如 Promise 的回调函数、MutationObserver 的回调函数则被添加到微任务队列(Microtask Queue)中。当前的同步任务执行完毕后,在进入下一轮事件循环之前,JS 引擎会按照顺序执行微任务队列中的事件。 3. 1. 每次循环都会检查消息队列中是否存在任务,如果存在,就取出第一个任务执行,执行完一个后进入另一个 ## 异步任务 ### 何为异步 代码在执行的过程中会遇到一些无法立即处理的逻辑,比如: - 计时器完成之后需要执行的任务:setTimeout、setInterval - 网络通信完成后需要执行的任务:XHR、Fetch - 用户交互后需要执行的任务:addEventListener 如果渲染主线程遇到这些任务时等待的话,就会让主线程处于等待的状态,对于用户而言,就是浏览器的「卡死」。 应对这种情况,浏览器采用异步的设计来解决,如下图 使用异步的方案,可以使得主线程不等待、不阻塞,高效有序的执行逻辑。 何为异步?异步(Asynchronous)是指一种非阻塞的执行方式,允许同时执行多个任务而不会阻塞主线程。异步任务是指那些不会立即执行或不会阻塞代码执行的任务。 以下是常见的异步操作和写法: 1. 回调函数:使用回调函数是一种常见的处理异步操作的方式。在执行异步操作时,可以提供一个回调函数,在操作完成后调用回调函数来处理结果。 2. Promise:ES6 引入的一种处理异步操作的机制。它可以用于处理异步操作的成功和失败,并支持链式调用。 3. async/await:ES8 引入的一种更直观处理异步操作的语法。通过在函数前面加上 `async` 关键字,可以在函数内部使用 `await` 关键字来等待异步操作的结果。 4. 定时器函数:JavaScript 提供了一些定时器函数,如 `setTimeout` 和 `setInterval`,它们可以用来延迟执行代码或定期执行代码 5. XHR 和 Fetch:通过使用 AJAX 或 Fetch API,可以发送异步请求并获取服务器返回的数据。 6. 交互事件:当事件发生时,注册好的回调函数通过异步来处理事件,而不会阻塞主线程的执行 ### 异步的意义 浏览器是一个多进程架构,包含诸多进程,不同进程核心处理的问题不一样。浏览器进程负责界面展示、用户交互(比如滚动条)等。JS 是一门单线程语言,因为运行在浏览器渲染主线程中。然而这个浏览器主线程工作特别繁忙,渲染页面、执行 JS、负责用户交互等,如果采用同步的方式,很有可能会导致主线程阻塞,从而导致消息队列 中的很多其他任务无法得到执行。这样一来,一方面会导致繁忙的主线程白 白的消耗时间,另一方面导致⻚面无法及时更新,给用户造成卡死现象。 为了解决同步带来的问题,浏览器采用异步的设计。做法是渲染主线程存在一个事件队列,当某些任务(上述描述的异步任务)发生时,主线程会将该任务派发给其他线程处理(比如定时任务交给计时器线程处理),自身立即结束该任务的执行,转而执行后续逻辑。当派发给其他线程的异步任务执行完毕后,该异步事件的回调函数将被包装成任务,添加到消息队列的尾部,待主线程调度执行。 这种设计使得即使是单线程的 JS,运行也不会卡顿,渲染主线程有条不紊、高效的执行,保证了浏览器的流畅性。 **通过使用异步,可以更好地处理耗时的操作、网络请求、文件读写等任务,提高程序的性能和用户体验。** ## JS 为何会阻塞渲染? 由于 JS 可以操作 DOM,如果在修改 DOM 元素属性的同时渲染界面(JS 线程与 UI 线程同时运行),那么可能会发生一些不符合预期的结果了,渲染线程前后获得的元素数据不一致。 因此为了防止产生渲染产生不符合预期的结果,**浏览器设置 GUI 渲染线程和 JS 引擎为互斥关系**。当 JS 引擎执行时 GUI 线程会被挂起。GUI 的更新会被保存在一个队列中,等到 JS 引擎线程空闲时立即被执行。 因为互斥,所以可以推导出,JS 如果执行时间过长就会阻塞页面。 举个例子: ```

你好

``` 效果就是:点击按钮修改 h1 标签内的文本。但是在点击后停了3秒后才开始执行。那这个现象如何解释呢? T0 时刻:最开始的时候,主线程没有任务任务需要执行。但是主线程告诉交互线程,你需要监听用户的点击事件,点击后需要执行 callback T1时刻:当用户点击后,交互线程会把 callback 封装成一个任务,添加到事件循环队列的尾部。此时主线程依旧没有任务,所以主线程事件循环将被唤醒,从事件队列的头部取出一个任务去执行。大的一个任务里包含2个子事件:修改 DOM 和 延迟3秒。修改 DOM 这句指令执行后,浏览器要想看的见,需要内部会产生一个绘制任务(硬件设备显示图形的画家算法)。绘制任务被添加到事件队列尾部后,立马执行延迟3秒的事件。 T2时刻:到达T2时刻后,主线程又空了,此时从事件队列中读取绘制任务。进而去显示出 DOM 文本修改后的结果(但此时前面已经等待了3秒钟,所以体感上会有一种卡顿的现象) ## 任务有优先级吗 任务没有优先级,因为在消息队列中,无差别从对头选出任务,给队尾添加任务。 **但消息队列是有优先级的。**怎么理解?因为存在多个队列 根据 W3C 定义: - 每个任务都有一个任务类型,同一种任务类型的任务归属于同一个队列。不同任务类型的任务被派发到不同的队列中(在一次事件循环中,浏览器可以根据实际情况从不同的任务队列中取出任务并执行) - 浏览器必须事先准备好1个微任务队列。微队列中的任务优先所有其他任务执行,优先级最高 浏览器的复杂度急剧提升,W3C 不再使用宏队列的说法。 目前 chromium 中,至少包含以下队列: - 延时队列:用于存放计时器到达后的回调任务,优先级「中」 - 交互队列:用于存放用户操作后产生的事件处理任务,优先级「高」 - 微队列:用户存放需要最快执行的任务,优先级「最高」 添加任务到微队列的主要方式主要是使用 Promise、MutationObserver。此外新加入的 [`queueMicrotask()`](https://developer.mozilla.org/zh-CN/docs/Web/API/queueMicrotask) 方法增加了一种标准的方式,可以安全的引入微任务。 ## JS 事件循环 事件循环又叫消息循环,是指在单线程中处理任务队列的机制。 1. JS 引擎会创建一个全局作用域,并将其添加到调用栈中 2. 如果有同步任务,则直接进入主线程执行该任务 3. 若存在微任务或者用户交互事件、定时器事件等异步任务,则分门别类,添加到对应的任务队列中 4. 当所有的同步任务执行完毕后,JS 引擎开始按照队列优先级检查任务队列是否存在未执行的任务 - 如果微任务队列中存在任务,则优先从微任务队列对头取出1个任务执行,然后再次返回到主线程检查是否有新的同步任务。重复此过程,直到微任务队列为空 - 如果微任务队列已经没有任务了,则从交互队列队列中判断,如果用户触发了某个事件,则把交互队列中对应的回调取出来放到主线程执行 - 如果交互队列为空了,而定时器队列不为空,则判断定时器任务有没有到时见,如果已经达到可执行的条件,则把对应的回调事件放到主线程进行执行 - 如果微任务队列和用户交互队列、定时器队列都为空,那么 JS 引擎会抛出一个“There are no more tasks to run.“ 的错误信息,表示事件循环结束 ## JS 计时器准吗? 不准。存在以下几个原因: - 计算机硬件没有原子钟,无法做到精确计时。依靠网络来和拥有原子钟的服务器进行同步 - 操作系统的计时函数本身就有少量偏差,由于 JS 的计时器最终调用的是操作系统的函数,也就携带了这些偏差 - 按照 W3C 的标准,浏览器实现计时器时,如果嵌套层级超过 5 层,则会带有 4 毫秒的最少时间 - 受事件循环的影响,计时器的回调函数只能在主线程空闲时运行,因此又带来了偏差 ## 计算机的时间 没有时间,我们的生活将无序,无法度量。计算机更是如此,比如纳秒级的运算,那么计算机到底是如何感知时间的呢? 计算机依靠**晶振**(全称是石英晶体振荡器),是一种高精度、高稳定度的振荡器。位置一般在主板上,晶振为系统提供很稳定的脉冲信号,一秒内产生的脉冲次数也被称为系统时钟频率。一个标准的脉冲信号如下: ![](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/time-pulse.png) 几个概念: - 时钟周期:这里的时钟就是晶振,也就是晶振周期/振荡周期。是计算机的最小时间单元,其他周期只能是它的倍数 - 机器周期性:既生瑜何生亮?时钟周期太小了,小到表述很多东西不够方便。到了 CPU 操作层面来说时钟周期不好用,类似人民币,虽然早期有1分钱,但某个商品价格比较大,需要1238902分,咋感觉这个单位不那么好用了,所以设计了100百元这个度量单位。一个机器周期等于多个时钟周期,它是 CPU 的一个基本操作时间单元,比如:取指、译码、存储器读/写、运算等等操作 - 指令周期:是 CPU 完成一条指令的时间,又称为提取-执行周期(fetch-and-execute cycle),是指 CPU 要执行一条指令经过的步骤,由若干机器周期组成。不同的机器分解指令周期的方式不同。 - 总线周期:它是 CPU 操作指令设备的时间,由于存储器和 I/O 端口是挂接在总线上的,对它们的访问是通过总线实现的。通常将进行一次访问所需时间称为一个总线周期。这种访问速度较慢,因硬件设备发展的原因,各个设备的工作频率无法同步(周期不一致),甚至相差好几个数量级,CPU 很快,硬盘很慢,大家又需要协同工作,所以出现了**分频**的概念。将高频信号变成低频信号。