#线程

为什么 JavaScript 是单线程

众所周知 JavaScript 最开始是被设计运行在浏览器中的脚本语言,从浏览器的功能和特性角度来讲,单线程是最合适的。多个线程操作 DOM 的时候容易出现此类问题:这个 DOM 应该被哪个线程控制的问题? 因此为了避免问题的复杂性,从诞生开始 JavaScript 就是单线程,这已经成为这门语言的核心特征。

随着硬件资源(具体指的是 CPU 核数)不断提升和业务场景的不断的扩大(密集型计算、游戏),需要充分利用多核能力。 HTML 5 提出 Web Worker 标准, 允许 JavaScript 脚本创建多个线程,但主线程完全控制子线程且子线程没有操作 DOM 的权限, 所以说并没有改变 JavaScript 单线程的本质。

执行栈与任务队列

单线程意味着排队,必须按照书写代码的顺序执行下去 A -> B -> C -> D,然而一旦哪一步执行的时间非常长就不得不进行长时间的等待,那么这个时候任务就不得不等着前面的执行完。
如果是计算量过大 CPU 忙不过来了倒也好说, 然而这时候 CPU 是闲着的,因为许多任务是比较缓慢的,例如: IO、网络,比如等到有结果才能继续执行。

那么可以把任务分为两种:同步任务(synchronous)、异步任务(asynchronous)。同步任务是指:一个任务执行完后再进行下一个任务,异步任务是说:不进入主线程而进入“任务队列(task queue)“的任务,只有“任务队列“通知主线程某个异步任务可以执行了,该任务才可以执行。

具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)

1
2
3
4
5
6
7
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。

(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。

(4)主线程不断重复上面的第三步。

下图就是主线程和任务队列的示意图。

只要主线程空了,就会去读取”任务队列”,这就是JavaScript的运行机制。这个过程会不断重复。

事件和回调函数

“任务队列”是事件的队列,IO 设备每完成一项任务,就在“任务队列”中添加一个事件,表示相关的异步任务可以进入“执行栈”了。主线程读取“任务队列”,就是读取里面有哪些事件。

“任务队列”中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入”任务队列”,等待主线程读取。

所谓”回调函数”(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。

“任务队列”是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,”任务队列”上第一位的事件就自动进入主线程。但是,由于存在后文提到的”定时器”功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。

Event Loop

主线程从”任务队列”中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。

上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在”任务队列”中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取”任务队列”,依次执行那些事件所对应的回调函数。

定时器

除了放置异步任务的事件,”任务队列”还可以放置定时事件,即指定某些代码在多少时间之后执行。这叫做”定时器”(timer)功能,也就是定时执行的代码。

总之,setTimeout(fn,0)的含义是,指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行。它在”任务队列”的尾部添加一个事件,因此要等到同步任务和”任务队列”现有的事件都处理完,才会得到执行。

HTML5标准规定了setTimeout()的第二个参数的最小值(最短间隔),不得低于4毫秒,如果低于这个值,就会自动增加。在此之前,老版本的浏览器都将最短间隔设为10毫秒。另外,对于那些DOM的变动(尤其是涉及页面重新渲染的部分),通常不会立即执行,而是每16毫秒执行一次。这时使用requestAnimationFrame()的效果要好于setTimeout()。

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

定时器运行机制

setTimeout和setInterval的运行机制,是将指定的代码移出本轮事件循环,等到下一轮事件循环,再检查是否到了指定时间。如果到了,就执行对应的代码;如果不到,就继续等待。

这意味着,setTimeout和setInterval指定的回调函数,必须等到本轮事件循环的所有同步任务都执行完,才会开始执行。由于前面的任务到底需要多少时间执行完,是不确定的,所以没有办法保证,setTimeout和setInterval指定的任务,一定会按照预定时间执行。

特殊的定时器语句

我们经常会看到这么一些语句 setTimeout(fn, 0),那么他的意思真的是等待 0 秒后立即执行么? 当然不会,正如上文所说需要等到上文全部执行完, 总之,setTimeout(f, 0)这种写法的目的是,尽可能早地执行f,但是并不能保证立刻就执行f。

应用

Node.js 中的 Event Loop

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

1
2
3
4
5
6
7
(1)V8引擎解析JavaScript脚本。

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

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

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

问题遗留

process.nextTick process.setImmediate setTimeout 的区别

引用:

浏览器的组成结构

  1. 用户界面

包括地址栏、前进、后退、书签栏。

  1. 浏览器引擎

在用户界面和浏览器引擎之间传送指令。

  1. 呈现引擎

负责呈现浏览器内容,负责解析 HTML 和 CSS 内容,并将解析后的内容现实在屏幕上。

  1. 网络

用于网络调用。

  1. 用户界面后台

用于绘制基本的窗口小部件,比如组合框和窗口。其公开了与平台无关的通用接口,而在底层使用操作系统的用户界面方法。

  1. JavaScript 解释器

解析执行 JavaScript 代码。

  1. 数据存储

持久层,浏览器需要在硬盘上保存各种数据,例如 Cookie,新的 HTML 5 规范定义了网络数据库,这是一个完整轻便的网络数据库

和大多浏览器不同,Chrome 的每个标签页分别对应一个呈现引擎实例,每个标签页都是一个独立的进程。

呈现引擎

呈现引擎,又称为渲染引擎也成为浏览器内核,在这方面又称为 UI 线程,这是由各大浏览器厂商根据 W3C 标准自行研发的, 常见的内核有四种 (Webkit (chrome, safari) Gecko(firefox))。

呈现主流程

呈现引擎最大的作用是用于呈现,也就是在浏览器中显示请求的内容。一开始从网络层获取文档内容,内容大小限制一般在 8000 个块以内, 然后进行以下流程:

使用 HTML 构建 DOM 结构,并将各个标记转换为 “内容树” 上的 DOM 节点。同时也会解析外部 CSS 文件以及样式文件中的样式数据。 HTML 中这些带有视觉指令的样式信息将用于创建另一个树结构: 呈现树。

呈现树包含多个带有视觉属性(如颜色和尺寸)的矩形。这些矩形的排列顺序就是它们将在屏幕上显示的顺序。

呈现树构建完毕之后,进入“布局”处理阶段,也就是为每个节点分配一个应出现在屏幕上的确切坐标。下一个阶段是绘制 - 呈现引擎会遍历呈现树,由用户界面后端层将每个节点绘制出来。

示例:webkit 主流程示例图

示例:Mozila 的 Gecko 呈现主流程引擎

JavaScript 解释器

什么是 JavaScript 解释器?简单地说,JavaScript 解释器就是能够“读懂” JavaScript 代码,并准确地给出代码运行结果的一段程序。

所以 JavaScript 解释器,又称为 JavaScript 解析引擎,又称为 JavaScript 引擎,也可以成为 JavaScript 内核,在线程方面又称为 JavaScript 引擎线程。比较有名的有 Chrome 的 V8 引擎(用 C/C++ 编写),除此外还有 IE9 的 Chakra、Firefox 的 TraceMonkey。它是基于事件驱动单线程执行的,JavaScript 引擎一直等待着任务队列中任务的到来,然后加以处理,浏览器无论什么时候都只有一个 JavaScript 线程在运行 JavaScript 程序。

学过编译原理的人都知道,对于静态语言来说(如Java、C++、C),处理上述这些事情的叫编译器(Compiler),相应地对于 JavaScript 这样的动态语言则叫解释器(Interpreter)。这两者的区别用一句话来概括就是:编译器是将源代码编译为另外一种代码(比如机器码,或者字节码),而解释器是直接解析并将代码运行结果输出。 比方说,firebug 的 console 就是一个 JavaScript 解释器。但我们无需过多在这些点上纠结。因为比如像 V8,它其实是为了提高 JavaScript 的运行性能,会在运行之前将 JavaScript 编译为本地的机器码然后再去执行,这样速度就快很多,相信大家对 JIT(Just In Time Compilation)一定不陌生吧。

JavaScript 解释器和我们平时讨论的 ECMAScript 有很大关系,标准的 JavaScript 解释器会根据 ECMAScript 标准去实现文档中对语言规定的方方面面,但由于这不是一个强制措施,所以也有不按照标准来实现的解释器,比如 IE6,这也是一直困扰前端开发的一个来由——兼容问题。有关 JavaScript 解释器的部分不做过于深入的介绍,但是由于我们对它有了部分的了解,接下来可以介绍一个新的部分——线程。

JavaScript 与浏览器的线程机制

作为浏览器脚本语言,JavaScript 主要用于处理页面中用户交互,以及操作 DOM 树、CSS 样式树(当然也包括服务器逻辑的交互处理)。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?当然我们可以通过锁来解决上面的问题。但为了避免因为引入了锁而带来更大的复杂性,从一诞生,JavaScript 就是单线程,这已经成了这门语言的核心特征。

我们可以回顾一下最开始所提的一个问题:Web Worker 真的让 JavaScript 拥有了多线程的能力吗?

为了利用多核 CPU 的计算能力,在 HTML5 中引入的工作线程使得浏览器端的 JavaScript 引擎可以并发地执行 JavaScript 代码,从而实现了对浏览器端多线程编程的良好支持。Web Worker 允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM 。所以,这个新标准并没有改变 JavaScript 单线程的本质。

为什么页面会卡顿?

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

于是,我们便明白了:假设一个 JavaScript 代码执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染出现“加载阻塞”的现象。当然,针对 DOM 的大量操作也会造成页面出现卡顿现象,毕竟我们经常说:DOM 天生就很慢。

所以,当你需要考虑性能优化时就可以从如上的原因出发,大致有以下几个努力的方面:

  • 减少 JavaScript 加载对 DOM 渲染的影响(将 JavaScript 代码的加载逻辑放在 HTML 文件的尾部,减少对渲染引擎呈现工作的影响);
  • 避免重排,减少重绘(避免白屏,或者交互过程中的卡顿);
  • 减少 DOM 的层级(可以减少渲染引擎工作过程中的计算量);
  • 使用 requestAnimationFrame 来实现视觉变化(一般来说我们会使用 setTimeout 或 setInterval 来执行动画之类的视觉变化,但这种做法的问题是,回调将在帧中的某个时点运行,可能刚好在末尾,而这可能经常会使我们丢失帧,导致卡顿);

优化方面可以看 优化 JavaScript 的执行

浏览器中的那些线程

事实上我们在使用浏览器的时候都会涉及到网络工具、浏览器事件、定时器触发线程。事实上这些线程如果出现在主线程上的话工作效率会非常的低下(这里的工作效率指的是人能看到的渲染引擎渲染出的页面)所以浏览器为这些功能独立设计了其他的线程

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

  • 定时器触发线程:浏览器定时计数器并不是由 JavaScript 引擎计数的, 因为 JavaScript 引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确, 因此通过单独线程来计时并触发定时是更为合理的方案

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

总结

这篇大部分基本摘抄 聊聊 JavaScript 与浏览器的那些事 - 引擎与线程浏览器的工作原理:新式网络浏览器幕后揭秘主要是自己对浏览器结构的一个简单梳理、也是为下一篇 Event Loop 的铺垫。

引用:

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×