浅谈浏览器运行机制及单线程js的执行

事件循环与任务队列

Posted by Li Yucang on May 27, 2018

浅谈浏览器运行机制及单线程 js 的执行

本文从浏览器进程,再到浏览器内核运行,再到 JS 引擎单线程,再到 JS 事件循环机制,从头到尾系统的梳理一遍,摆脱碎片化,形成一个知识体系。

线程与进程

首先这里来个经典的列子简单了解下进程与线程:

进程是一个工厂,工厂有它的独立资源

工厂之间相互独立

线程是工厂中的工人,多个工人协作完成任务

工厂内有一个或多个工人

工人之间共享空间

对应到概念:

工厂的资源 -> 系统分配的内存(独立的一块内存)

工厂之间的相互独立 -> 进程之间相互独立

多个工人协作完成任务 -> 多个线程在进程中协作完成任务

工厂内有一个或多个工人 -> 一个进程由一个或多个线程组成

工人之间共享空间 -> 同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)

而之所以会有进程和线程的概念,是因为 CPU 与其他 PC 资源之间速度的不协调,人们想提高资源利用率,所以人们提出了多任务系统。得益于 CPU 的计算速度,我们可以“同时”运行多个任务,实质上是多个任务之间轮流使用 CPU 资源,由于速度超快,给用户的感觉就是连续的。

在 CPU 看来所有的任务都是一个一个的轮流执行的,具体的轮流方法就是:先加载程序 A 的上下文,然后开始执行 A,保存程序 A 的上下文,调入下一个要执行的程序 B 的程序上下文,然后开始执行 B,保存程序 B 的上下文……

最后,用较为官方的术语简单总结一下:

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

浏览器是多进程的

我们在 chromed 浏览器的任务管理器中可以查看到当前浏览器所有的进程:

可以发现,浏览器一般会为每一个页面创建一个进程,以及浏览器自身的主进程和GPU进程。但在这里浏览器应该也有自己的优化机制,有时候打开多个 tab 页后,可以在 Chrome 任务管理器中看到,也会合并一些 tab 页面为一个进程,如:标签页等。

经翻阅相关资料,浏览器主要有这些进程:

  1. 浏览器进程(Browser 进程):浏览器进程只有一个,用于管理标签页、窗口和浏览器本身。这个进程同时负责处理所有跟磁盘、网络、用户输入和显示的交互,然而它不分析和渲染任何网页内容。

  2. 第三方插件进程:览器进程同样为处于使用状态的每种类型的插件创建一个进程,如:Flash、Quicktime 或 Adobe reader。这些进程仅仅包含插件本身以及和浏览器进程、渲染器进程交互的胶水代码。

  3. GPU 进程:最多一个,用于 3D 绘制等

  4. 浏览器渲染进程(浏览器内核)(Renderer 进程,内部是多线程的):渲染器进程会存在多个,每个都负责渲染网页。渲染器进程中包含用于操作 HTML,JavaScript,CSS,图片和其他内容的复杂的逻辑。我们使用了也同样被 Apple Safari 浏览器使用的开源的 WebKit 渲染引擎实现以上功能。每个渲染进程都运行在沙箱内,这意味着它对磁盘、网络和显示器没有直接的访问权限。所有跟网络应用的交互,包括用户输入事件和屏幕绘制都必须通过浏览器进程。这可以让浏览器进程监视渲染器的可疑行为,一旦发现其从事破坏活动就将其终止。

在浏览器刚被设计出来的时候,那时的网页非常的简单,几乎没有动态的代码。这对仅使用一个进程渲染所有要访问的网页却仍然保持非常低的资源占有率是行得通的。

然而在今天我们看到大量网页转而使用动态网页,从含有大量 javascript 和 flash 的网页到像完全成熟的网络应用如 GMail。这些应用的很大一部分是在浏览器中运行的,就像运行在操作系统之上的应用程序一样。跟操作系统一样,浏览器必须让这些应用互相分离。

除此之外,浏览器中负责渲染 HTML,JavaScript 和 CSS 的部分日益的复杂。在这些渲染引擎在演化的过程中会频繁的出现 bug,有些 bug 会导致渲染引擎崩溃。不仅如此,渲染引擎会经常性的在网络上遇到不可信、甚至是恶意的代码,它们会利用这些漏洞在你的电脑上安装恶意的软件。

在当今世界,把所有东西都放进一个进程的浏览器面临在健壮性,响应速度,安全性方面的挑战。如果浏览器中的一个网络应用崩溃的话,这会波及括所有被打开的应用在内的任何其他应用。单线程的网络应用不得不经常相互竞争以获得的 cpu 时间,这有时会导致整个浏览器无法响应。安全性也同样不容小觑,因为仅仅一个页面就可以利用渲染引擎的某个漏洞获得对整台计算机的控制权。

然而,并不是非这样做不可。网络应用在设计的时候就是在浏览器中相互独立且并行的运行。它们不需要对磁盘和设备的访问权。这些被应用在网络上的安全策略保证了这些,使让你在访问大部分的页面时并不需要担心数据和计算机的安全性。这意味着可以让浏览器中的应用在不破坏彼此的情况下完全相互隔离。对于浏览器中的插件如 flash 也是一样的,它们与浏览器松散的耦合在一起且相互隔离,这没有任何问题。

Google Chrome 充分利用了这种特性,它将插件或是网络应用放在与浏览器本身不同的进程中。在一个渲染引擎中的崩溃并不会影响浏览器本身或是其他网络应用。这意味着操作系统可以并发的运行网络应用来提高响应速度,如果一个特定的网络应用程序或是插件停止响应时浏览器本身并不会被死锁。这也意味着我们可以在一个严格意义上的沙箱内运行渲染引擎进程,帮助减少发生错误时造成的损失。

总结一下,多进程浏览器有这些优点:

  • 避免单个 page crash 影响整个浏览器
  • 避免第三方插件 crash 影响整个浏览器
  • 多进程充分利用多核优势
  • 方便使用沙盒模型隔离插件等进程,提高浏览器稳定性

浏览器渲染原理及流程

在浏览器的几个进程中,我们最关心的肯定是渲染进程啦。那浏览器渲染进程到底干了啥呢?可以这么说,咱比较关心的:页面的渲染、JS 的执行、事件的循环,都是在这个进程内进行。

先看看它包含那些常驻线程:

  1. GUI 渲染线程负责渲染浏览器界面,解析 HTML,CSS,构建 DOM 树和 RenderObject 树,布局和绘制等。当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行注意,GUI 渲染线程与 JS 引擎线程是互斥的,当 JS 引擎执行时 GUI 线程会被挂起(相当于被冻结了),GUI 更新会被保存在一个队列中等到 JS 引擎空闲时立即被执行。

  2. JS 引擎线程也称为 JS 内核,负责处理 Javascript 脚本程序。(例如 V8 引擎) JS 引擎线程负责解析 Javascript 脚本,运行代码。 JS 引擎一直等待着任务队列中任务的到来,然后加以处理,一个 Tab 页(renderer 进程)中无论什么时候都只有一个 JS 线程在运行 JS 程序。同样注意,GUI 渲染线程与 JS 引擎线程是互斥的,所以如果 JS 执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。

  3. 事件触发线程归属于浏览器而不是 JS 引擎,用来控制事件循环(可以理解,JS 引擎自己都忙不过来,需要浏览器另开线程协助) 当 JS 引擎执行代码块如 setTimeOut 时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX 异步请求等),会将对应任务添加到事件线程中。当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理。注意,由于 JS 的单线程关系,所以这些待处理队列中的事件都得排队等待 JS 引擎处理(当 JS 引擎空闲时才会去执行)

  4. 定时触发器线程,传说中的 setInterval 与 setTimeout 所在线程浏览器定时计数器并不是由 JavaScript 引擎计数的,(因为 JavaScript 引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确) 因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待 JS 引擎空闲后执行) 。注意,W3C 在 HTML 标准中规定,规定要求 setTimeout 中低于 4ms 的时间间隔算为 4ms。

  5. 异步 http 请求线程,XMLHttpRequest 在连接后通过浏览器新开一个线程请求,检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由 JavaScript 引擎执行。

Browser 进程和浏览器内核(Renderer 进程)的通信过程

这里再说下浏览器的 Browser 进程(控制进程)是如何和内核通信的:

  • Browser 进程收到用户请求,首先需要获取页面内容(譬如通过网络下载资源),随后将该任务通过 RendererHost 接口传递给 Render 进程。

  • Renderer 进程的 Renderer 接口收到消息,简单解释后,交给渲染线程,然后开始渲染
    • 渲染线程接收请求,加载网页并渲染网页,这其中可能需要 Browser 进程获取资源和需要 GPU 进程来帮助渲染
    • 当然可能会有 JS 线程操作 DOM(这样可能会造成回流并重绘)
    • 最后 Render 进程将结果传递给 Browser 进程
  • Browser 进程接收到结果并将结果绘制出来

梳理浏览器内核中线程之间的关系

GUI 渲染线程与 JS 引擎线程互斥

由于 JavaScript 是可操纵 DOM 的,如果在修改这些元素属性同时渲染界面(即 JS 线程和 UI 线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。

因此为了防止渲染出现不可预期的结果,浏览器设置 GUI 渲染线程与 JS 引擎为互斥的关系,当 JS 引擎执行时 GUI 线程会被挂起, GUI 更新则会被保存在一个队列中等到 JS 引擎线程空闲时立即被执行。

JS 阻塞页面加载

从上述的互斥关系,可以推导出,JS 如果执行时间过长就会阻塞页面。

譬如,假设 JS 引擎正在进行巨量的计算,此时就算 GUI 有更新,也会被保存到队列中,等待 JS 引擎空闲后执行。然后,由于巨量计算,所以 JS 引擎很可能很久很久后才能空闲,自然会感觉到巨卡无比。

所以,要尽量避免 JS 执行时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。

WebWorker

一个 worker 是使用一个构造函数创建的一个对象(e.g. Worker()) 运行一个命名的 JavaScript 文件 - 这个文件包含将在工作线程中运行的代码; workers 运行在另一个全局上下文中,不同于当前的 window. 因此,使用 window 快捷方式获取当前全局的范围 (而不是 self) 在一个 Worker 内将返回错误。

让我们上段代码瞅瞅这玩意是怎么用的:

// demo_workers.js
var i=0;

function timedCount() {
  i=i+1;
  postMessage(i);
  setTimeout("timedCount()",500);
}

timedCount();
// 以上代码中重要的部分是 postMessage() 方法 - 它用于向 HTML 页面传回一段消息。

// index.js
if(typeof(w)=="undefined") {
  w=new Worker("demo_workers.js");
}
w.onmessage=function(event) {
  document.getElementById("result").innerHTML=event.data;
};

w.terminate(); // 终止监听消息

我们可以这样理解,创建 Worker 时,JS 引擎向浏览器申请开一个子线程(子线程是浏览器开的,完全受主线程控制,而且不能操作 DOM)。JS 引擎线程与 worker 线程间通过特定的方式通信(postMessage API,需要通过序列化对象来与线程交互特定的数据)。Worker 主要是浏览器给 JS 引擎开的外挂,专门用来解决那些大量计算问题。

SharedWorker

既然都到了这里,就再提一下 SharedWorker。

  • WebWorker 只属于某个页面,不会和其他页面的 Render 进程(浏览器内核进程)共享

    • 所以 Chrome 在 Render 进程中(每一个 Tab 页就是一个 render 进程)创建一个新的线程来运行 Worker 中的 JavaScript 程序。
  • SharedWorker 是浏览器所有页面共享的,不能采用与 Worker 同样的方式实现,因为它不隶属于某个 Render 进程,可以为多个 Render 进程共享使用

    • 所以 Chrome 浏览器为 SharedWorker 单独创建一个进程来运行 JavaScript 程序,在浏览器中每个相同的 JavaScript 只存在一个 SharedWorker 进程,不管它被创建多少次。

看到这里,应该就很容易明白了,本质上就是进程和线程的区别。SharedWorker 由独立的进程管理,WebWorker 只是属于 render 进程下的一个线程。

浏览器渲染流程

从你输入 url 到页面生成,这已经是最常见的面试题之一了。我们现在来简单复习一下:

输入 url -> 浏览器主进程接管 -> dns 查询(先查是否缓存) -> 发送 http 请求 -> 三次握手确立链接 -> 四次挥手断开链接 -> 将返回内容通过 RendererHost 接口转交给 Renderer 进程 -> 开始渲染 -> 解析 html 以构建 dom 树 -> 构建 render 树 -> 布局 render 树 -> 绘制 render 树

这里我们主要讲一下渲染过程,先解释一下几个概念,方便大家理解:

  • DOM Tree:浏览器将 HTML 解析成树形的数据结构。

  • CSS Rule Tree:浏览器将 CSS 解析成树形的数据结构。

  • Render Tree: DOM 和 CSSOM 合并后生成 Render Tree。

  • layout: 有了 Render Tree,浏览器已经能知道网页中有哪些节点、各个节点的 CSS 定义以及他们的从属关系,从而去计算出每个节点在屏幕中的位置。

  • painting: 按照算出来的规则,通过显卡,把内容画到屏幕上。

  • reflow(回流):当浏览器发现某个部分发生了点变化影响了布局,需要倒回去重新渲染,内行称这个回退的过程叫 reflow。reflow 会从 <html> 这个 root frame 开始递归往下,依次计算所有的结点几何尺寸和位置。reflow 几乎是无法避免的。现在界面上流行的一些效果,比如树状目录的折叠、展开(实质上是元素的显 示与隐藏)等,都将引起浏览器的 reflow。鼠标滑过、点击……只要这些行为引起了页面上某些元素的占位面积、定位方式、边距等属性的变化,都会引起它内部、周围甚至整个页面的重新渲 染。通常我们都无法预估浏览器到底会 reflow 哪一部分的代码,它们都彼此相互影响着。

  • repaint(重绘):改变某个元素的背景色、文字颜色、边框颜色等等不影响它周围或内部布局的属性时,屏幕的一部分要重画,但是元素的几何尺寸没有变。

这里有几点需要大家注意的:

  1. display:none 的节点不会被加入 Render Tree,而 visibility: hidden 则会,所以,如果某个节点最开始是不显示的,设为 display:none 是更优的。

  2. display:none 会触发 reflow,而 visibility:hidden 只会触发 repaint,因为没有发现位置变化。

  3. 有些情况下,比如修改了元素的样式,浏览器并不会立刻 reflow 或 repaint 一次,而是会把这样的操作积攒一批,然后做一次 reflow,这又叫异步 reflow 或增量异步 reflow。但是在有些情况下,比如 resize 窗口,改变了页面默认的字体等。对于这些操作,浏览器会马上进行 reflow。

下面是 webkit 引擎渲染时的主要流程:

渲染的主要流程如下:

  1. 浏览器会将 HTML 解析成一个 DOM 树,DOM 树的构建过程是一个深度遍历过程:当前节点的所有子节点都构建好后才会去构建当前节点的下一个兄弟节点。

  2. 将 CSS 解析成 CSS Rule Tree 。

  3. 根据 DOM 树和 CSS Rule Tree 来构造 Rendering Tree。注意:Rendering Tree 渲染树并不等同于 DOM 树,因为一些像 Header 或 display:none 的东西就没必要放在渲染树中了。

  4. 有了 Render Tree,浏览器已经能知道网页中有哪些节点、各个节点的 CSS 定义以及他们的从属关系。下一步操作称之为 layout,顾名思义就是计算出每个节点在屏幕中的位置。

  5. 再下一步就是绘制,即遍历 render 树,并使用 UI 后端层绘制每个节点。

  6. 浏览器会将各层的信息发送给 GPU,GPU 会将各层合成(composite),显示在屏幕上。

注意:上述这个过程是逐步完成的,为了更好的用户体验,渲染引擎将会尽可能早的将内容呈现到屏幕上,并不会等到所有的 html 都解析完成之后再去构建和布局 render 树。它是解析完一部分内容就显示一部分内容,同时,可能还在通过网络下载其余内容。

load 事件与 DOMContentLoaded 事件的先后

渲染完毕后会触发 load 事件,那么你能分清楚 load 事件与 DOMContentLoaded 事件的先后么?

很简单,知道它们的定义就可以了:

  • 当 DOMContentLoaded 事件触发时,仅当 DOM 加载完成,不包括样式表,图片。 (譬如如果有 async 加载的脚本就不一定完成)

  • 当 onload 事件触发时,页面上所有的 DOM,样式表,脚本,图片都已经加载完成了。 (渲染完毕了)

所以,顺序是:DOMContentLoaded -> load

css 加载是否会阻塞 dom 树渲染?

这里说的是头部引入 css 的情况

首先,我们都知道:css 是由单独的下载线程异步下载的。

然后再说下几个现象:

css 加载不会阻塞 DOM 树解析(异步加载时 DOM 照常构建) 但会阻塞 render 树渲染(渲染时需等 css 加载完毕,因为 render 树需要 css 信息) 这可能也是浏览器的一种优化机制。

因为你加载 css 的时候,可能会修改下面 DOM 节点的样式,如果 css 加载不阻塞 render 树渲染的话,那么当 css 加载完之后, render 树可能又得重新重绘或者回流了,这就造成了一些没有必要的损耗。所以干脆就先把 DOM 树的结构先解析完,把可以做的工作做完,然后等你 css 加载完之后,在根据最终的样式来渲染 render 树,这种做法性能方面确实会比较好一点。

普通图层和复合图层

可以简单的这样理解,浏览器渲染的图层一般包含两大类:普通图层以及复合图层。

首先,普通文档流内可以理解为一个复合图层(这里称为默认复合层,里面不管添加多少元素,其实都是在同一个复合图层中)。

其次,absolute 布局(fixed 也一样),虽然可以脱离普通文档流,但它仍然属于默认复合层。

然后,可以通过硬件加速的方式,声明一个新的复合图层,它会单独分配资源(当然也会脱离普通文档流,这样一来,不管这个复合图层中怎么变化,也不会影响默认复合层里的回流重绘)。

可以简单理解下:GPU 中,各个复合图层是单独绘制的,所以互不影响,这也是为什么某些场景硬件加速效果一级棒。

可以 Chrome 源码调试 -> More Tools -> Rendering -> Layer borders 中看到,黄色的就是复合图层信息。

如何变成复合图层(硬件加速)

将该元素变成一个复合图层,就是传说中的硬件加速技术

  • 最常用的方式:translate3d、translateZ

  • opacity 属性/过渡动画(需要动画执行的过程中才会创建合成层,动画没有开始或结束后元素还会回到之前的状态)

  • <video><iframe><canvas><webgl>等元素

  • 其它,譬如以前的 flash 插件

复合图层的作用?

一般一个元素开启硬件加速后会变成复合图层,可以独立于普通文档流中,改动后可以避免整个页面重绘,提升性能。但是尽量不要大量使用复合图层,否则由于资源消耗过度,页面反而会变的更卡

硬件加速的坑

chrome 在具体什么时候会创建复合图层呢?经翻阅资料:

什么情况下能使元素获得自己的层?虽然 Chrome 的启发式方法(heuristic)随着时间在不断发展进步,但是从目前来说,满足以下任意情况便会创建层:

  • 3D 或透视变换(perspective transform) CSS 属性
  • 使用加速视频解码的 元素
  • 拥有 3D (WebGL) 上下文或加速的 2D 上下文的 元素
  • 混合插件(如 Flash)
  • 对自己的 opacity 做 CSS 动画或使用一个动画 webkit 变换的元素
  • 拥有加速 CSS 过滤器的元素
  • 元素有一个包含复合层的后代节点(换句话说,就是一个元素拥有一个子元素,该子元素在自己的层里)
  • 元素有一个 z-index 较低且包含一个复合层的兄弟元素(换句话说就是该元素在复合层上面渲染)

最后一条这样理解:如果一个元素添加了硬件加速,并且 index 层级比较低,那么在这个元素的后面其它元素(层级比这个元素高的,或者相同的,并且 releative 或 absolute 属性相同的),会默认变为复合层渲染。

所以我们在使用 3D 硬件加速提升动画性能时,最好给元素增加一个 z-index 属性,提高图层排序,减少 chrome 创建不必要的复合层,提升渲染性能。

js Event Loop

最后终于到了重头戏,谈谈 js 在浏览器中的事件循环机制。首先上个图:

我们经常会听到引擎和 runtime,它们的区别是什么呢?

  • 引擎:解释并编译代码,让它变成能交给机器运行的代码(runnable commands)。
  • runtime:就是运行环境,它提供一些对外接口供 Js 调用,以跟外界打交道,比如,浏览器环境、Node.js 环境。不同的 runtime,会提供不同的接口,比如,在 Node.js 环境中,我们可以通过 require 来引入模块;而在浏览器中,我们有 window、 DOM。

Js 引擎是单线程的,如上图中,它负责维护任务队列,并通过 Event Loop 的机制,按顺序把任务放入栈中执行。而图中的异步处理模块,就是 runtime 提供的,拥有和 Js 引擎互不干扰的线程。接下来,我们会细说图中的:栈和任务队列。

现在,我们要运行下面这段代码:

function bar() {
    console.log(1);
}

function foo() {
    console.log(2);
    bar();
}

setTimeout(() => {
    console.log(3)
});

foo();

任务队列

出现原因

首先来了解下任务队列出现的原因:

JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

所以,为了避免复杂性,从一诞生,JavaScript 就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。

如果排队是因为计算量大,CPU 忙不过来,倒也算了,但是很多时候 CPU 是闲着的,因为 IO 设备(输入输出设备)很慢(比如 Ajax 操作从网络读取数据),不得不等着结果出来,再往下执行。

JavaScript 语言的设计者意识到,这时主线程完全可以不管 IO 设备,挂起处于等待中的任务,先运行排在后面的任务。等到 IO 设备返回了结果,再回过头,把挂起的任务继续执行下去。

于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入”任务队列”(task queue)的任务,只有”任务队列”通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

执行步骤

Js 中,有两类任务队列:宏任务队列(macro tasks)和微任务队列(micro tasks)。宏任务队列可以有多个,微任务队列只有一个。那么什么任务,会分到哪个队列呢?

  • 宏任务:script(全局任务), setTimeout, setInterval, setImmediate, I/O, UI rendering.

  • 微任务:process.nextTick, Promise, Object.observer, MutationObserver.

我们上面讲到,当 stack 空的时候,就会从任务队列中,取任务来执行。共分 3 步:

  1. 取一个宏任务来执行。执行完毕后,下一步。
  2. 取一个微任务来执行,执行完毕后,再取一个微任务来执行。直到微任务队列为空,执行下一步。
  3. 更新 UI 渲染。

Event Loop 会无限循环执行上面 3 步,这就是 Event Loop 的主要控制逻辑。其中,第 3 步(更新 UI 渲染)会根据浏览器的逻辑,决定要不要马上执行更新。毕竟更新 UI 成本大,所以,一般都会比较长的时间间隔,执行一次更新。

从执行步骤来看,我们发现微任务,受到了特殊待遇!我们代码开始执行都是从 script(全局任务)开始,所以,一旦我们的全局任务(属于宏任务)执行完,就马上执行完整个微任务队列。看个例子:

console.log('script start');

Promise.resolve().then(() => {
    console.log('p 1');
});

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

var s = new Date();
while(new Date() - s < 50); // 阻塞50ms

Promise.resolve().then(() => {
    console.log('p 2');
});

console.log('script ent');


/*** output ***/

// one macro task
script start
script ent

// all micro tasks
p 1
p 2

// one macro task again
setTimeout

上面之所以加 50ms 的阻塞,是因为 setTimeout 的 delayTime 最少是 4ms. 为了避免认为 setTimeout 是因为 4ms 的延迟而后面才被执行的,我们加了 50ms 阻塞。在微任务中,process.nextTick 是一个特殊的任务,它会被直接插入到微任务的队首(当然了,多个 process.nextTick 之间也是先入先出的),优先级最高。

setTimeout、setInterval

这里要注意的是,setTimeout()、setInterval()只是将事件插入了”任务队列”,必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证,回调函数一定会在 setTimeout()指定的时间执行。

MutationObserver

MutationObserver 可以用来实现 microtask(它属于 microtask,优先级小于 Promise,一般是 Promise 不支持时才会这样做)。

它是 HTML5 中的新特性,作用是:监听一个 DOM 变动,当 DOM 对象树发生任何变动时,Mutation Observer 会得到通知。

像以前的 Vue 源码中就是利用它来模拟 nextTick 的,具体原理是,创建一个 TextNode 并监听内容变化,然后要 nextTick 的时候去改一下这个节点的文本内容,如下:(Vue 的源码,未修改)

var counter = 1
var observer = new MutationObserver(nextTickHandler)
var textNode = document.createTextNode(String(counter))

observer.observe(textNode, {
    characterData: true
})
timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
}

不过,现在的 Vue(2.5+)的 nextTick 实现移除了 MutationObserver 的方式(据说是兼容性原因),取而代之的是使用 MessageChannel(当然,默认情况仍然是 Promise,不支持才兼容的)。

MessageChannel 属于宏任务,优先级是:MessageChannel->setTimeout,所以 Vue(2.5+)内部的 nextTick 与 2.4 及之前的实现是不一样的,需要注意下。

Node.js 的 Event Loop

上面我们讨论的都是浏览器中的 Event Loop,随着 nodejs 的出现,js 代码首次能运行在其他的环境中。Node.js 也是单线程的 Event Loop,但是它的运行机制不同于浏览器环境。

nodejs 中 Event loop 有以下特点:

  1. 初始化: Node.js 启动后,会进行一些初始化

    • 初始化 Event loop
    • 处理目标脚本
    • 然后进入事件循环
  2. 每个阶段,都有其 FIFO 队列,用来执行回调函数。

    1. 每个阶段都是特殊的。
    2. 当进行到该阶段时,会执行该阶段特有的操作,然后执行该阶段队列中的回调。
    3. 当队列空,或者达到执行次数限制,事件循环进行下阶段。循环往复。

阶段总览

根据上图,Node.js 的运行机制如下。

(1)V8 引擎解析 JavaScript 脚本。

(2)解析后的代码,调用 Node API。

(3)libuv 库负责 Node API 的执行。它将不同的任务分配给不同的线程,形成一个 Event Loop(事件循环),以异步的方式将任务的执行结果返回给 V8 引擎。

(4)V8 引擎再将结果返回给用户。

其中,event loop 包括:

  1. timers:执行 setTimeout()和 setInterval()安排的回调
  2. I/O callbacks: 执行除了 close 事件的 callbacks、被 timers(定时器,setTimeout、setInterval 等)设定的 callbacks、setImmediate()设定的 callbacks 之外的 callbacks;
  3. idle,prepare: 只用于内部
  4. poll : 获取新的 I/O 事件,node 在该阶段会适当的阻塞
  5. check : setImmediate 的回调被调用
  6. close callbacks: e.g socket.on(‘close’,…);
  7. 在每次运行事件循环之间,node.j 检查是否有正在等待的异步 i/o 调用、timers 等。如果没有,就清除并结束(退出程序),例如:执行一个程序,仅有一句话(var a= ‘hello’;),处理完目标代码后,不会进入 evetloop,而是直接结束程序。

Node.js 中的宏任务和微任务

宏任务:setTimeout 和 setImmediate

  • setTimeout 设计在 poll 阶段为空闲时,且设定时间到达后执行;但其在 timer 阶段执行
  • setImmediate 设计在 check 阶段执行;

谁先输出,谁后输出?

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

setImmediate(function immediate () {
  console.log('immediate');
});

答案是不确定的。有两个前提我们是需要清楚的;

  • event loop 初始化是需要一定时间的
  • setTimeout 有最小毫秒数的,通常是 4ms。

当:event loop 准备时间 > setTimeout 最小毫秒数。从 timers 阶段检查,此时队列中已经有 setTimeout 的任务,所以 timeout 先输出;

当:event loop 准备时间 < setTimeout 最小毫秒数。从 timers 阶段检查,此时队列是空的就下检查接下来的阶段,到 check 阶段,已经有 setImmediate 的任务,所以 immediate 先输出;

微任务:process.nextTick()和 Promise.then()

微任务不在 event loop 的任何阶段执行,而是在各个阶段切换的中间执行,即从一个阶段切换到下个阶段前执行;nextTick 比 Promise.then()先执行。

下面代码是如何执行的。

setImmediate(() => {
  console.log('setImmediate1')
  setTimeout(() => {
    console.log('setTimeout1')
  }, 0);
})
setTimeout(()=>{
  process.nextTick(()=>console.log('nextTick'))
  console.log('setTimeout2')
  setImmediate(()=>{
    console.log('setImmediate2')
  })
},0);
  • 从前面的知识知道,此时 setTimeout 和 setImmediate 执行顺序是不确定的。
  • 假设 setImmediate 先执行,输出setImmediate1,setTimeout 的任务添加到 timer 阶段
  • 检查 timer 阶段,这时已经有两个任务。先执行之前的第一个任务,nextTick 添加到微任务队列,输出setTimeout2,setImmediate 的任务添加到 check 阶段。
  • timer 中还有一个任务,执行输出setTimeout1
  • 切换阶段,微任务执行,输出nextTick
  • 检查 check 阶段,输出setImmediate2

再来看看这种情况:

let fs = require('fs')

fs.readFile('./1.txt', 'utf8', function (err, data) {
    setTimeout(() => {
        console.log('setTimeout')
    }, 0);
    setImmediate(() => {
        console.log('setImmediate')
    })
})

readFile 的回调函数是在 poll 阶段执行 答案是 setImmediate 比 setTimeout 先执行。

process.nextTick 和 setImmediate

process.nextTick 方法可以在当前”执行栈”的尾部(即,微任务队列头部插入)—-下一次 Event Loop(主线程读取”任务队列”)之前—-触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。setImmediate 方法则是在 check 阶段的尾部添加事件。请看下面的例子:

process.nextTick(function A() {
  console.log(1);
  process.nextTick(function B(){console.log(2);});
});

setTimeout(function timeout() {
  console.log('TIMEOUT FIRED');
}, 0)
// 1
// 2
// TIMEOUT FIRED

上面代码中,由于 process.nextTick 方法指定的回调函数,总是在当前”执行栈”的尾部触发,所以不仅函数 A 比 setTimeout 指定的回调函数 timeout 先执行,而且函数 B 也比 timeout 先执行。这说明,如果有多个 process.nextTick 语句(不管它们是否嵌套),将全部在当前”执行栈”执行。

由于 process.nextTick 指定的回调函数是在本次”事件循环”触发,而 setImmediate 指定的是在下次”事件循环”触发,所以很显然,前者总是比后者发生得早,而且执行效率也高(因为不用检查”任务队列”)