js异步梳理:1.从浏览器的多进程到JS的单线程,理解JS运行机制

时间:2021-10-28 01:01:57

大家很早就知道JS是一门单线程的语言。但是也时不时的会看到进程这个词。首先简单区分下线程和进程的概念

1. 简单理解进程

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

- 工厂之间相互独立

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

- 工厂内有一个或多个工人

- 工人之间共享空间

2. 简单理解线程

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

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

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

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

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

3. 浏览器是多进程的

上面的1.1和1.2可能还是有些抽象。接下来用与前端息息相关的浏览器为例展开。

当你打开浏览器开了好几个网页的时候,打开浏览器的任务管理器(比如谷歌浏览器-> 更多工具 -> 任务管理器)

这里就是查看进程的地方,而且可以看到每个进程的cpu占用率和内存资源信息。

简单用比较官方的术语总结下:

  • 进程是cpu资源分配的最小单位(是能拥有资源和独立运行的最小单位)
  • 线程是cpu调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)
  • 不同进程之间也可以通信。(比如网页是一个进程,qq是一个进程,在网页上使用快捷方式qq登录。网页怎么会知道你当前有没有登录qq的?这之间就涉及到了不同进程之间的通信)
  • 一般讨论的单线程和多线程,都只是指在一个进程内的单和多。

4 浏览器是如何渲染进程的?与JS的单线程有什么联系?

在浏览器中打开一个网页相当于新起了一个进程,每个进程内又会有自己的多线程(当然,浏览器有自身的优化机制,当你开了很多空的标签页的时候,可能会发现多个空白标签页被合并成了一个进程)。比如页面的渲染,JS的执行,事件的循环,都会在这个进程内进行。(以下用比较官方的术语列举一些主要常驻线程)

扩散思考1:浏览器为什么要弄成多进程的?

优点:

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

缺点:

  • 会占用更多的内存

4.1. GUI渲染线程

  • 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。
  • 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行(扩展阅读:页面重绘和回流以及优化
  • 注意:GUI渲染线程和JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起(想当与被冻结了),GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<style>
.a {
width: 100px;
height: 100px;
background: #f60;
}
</style>
<script>
console.time('js执行')
for(var i = 0; i < 1000000000; i++) { }
console.timeEnd('js执行') </script>
</head>
<body>
<div class="a">a</div>
</body>
</html>

从这个例子中可以看到JS页面明显有一段空白期,也就证明了上面所说的当JS引擎执行时GUI线程会被挂起。

扩展思考:你可能以前听说并且一直是这么做的,JS调用不放在中,要放到网页底部前面来优化你的网站。但是修改这个例子可能会发现无论你是将这段script包含的代码放到head里还是body里,或者是另外新建一个文件引入,都要等到js加载并且执行才会在页面里渲染出a。尤其是jquery时代大家统一会将代码写在$(document).ready中,那样的话JS不管在顶部引入还是在底部引入,这样看起来它们的执行时机对页面的影响是一样的,那JS调用放在顶部和底部真的会有差别吗?

推荐阅读:网站为什么 JS 调用尽量放到网页底部?

4.2. JS引擎线程

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

4.3. 事件触发线程

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

4.4 定时触发器线程

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

4.5 异步http请求线程

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

看了上面的描述后,思考两个问题。

a. 平时前端写的事件,定时器,异步我们都会把它称为JS。那为什么这里把JS引擎线程单独拿出来讲?我们平时说的JS和这里的JS引擎有什么区别?

JS引擎包含两个部分

内存堆(Memory Heap): 和内存分配有关。(比如基本类型值存栈内存里,引用类型值存堆内存里)

调用栈(Call Stack): 代码执行时候的栈帧 (你可能看到过一些执行栈,执行上下文堆栈,函数调用栈这样的词,其实没必要太过咬文嚼字。简单理解就是每当一个函数被调用的时候,都会为这个函数创建一个新的上下文。而在一个javascript程序中,必定会有多个执行上下文。javascript以栈(先进后出,后进先出)的方式来处理它们。而调用栈就像一个高速摄影机,会把当前运行的代码的每一帧都给记录下来。)

推荐阅读:js基础梳理-究竟什么是执行上下文栈(执行栈),执行上下文(可执行代码)?

而日常开发中真实的JS运行环境可能包含更多的内容,比如DOM操作(onload, onclick...), Ajax, setTimeout等等。这些是宿主环境(浏览器)提供的Web API。而WebAPI本身是不能把执行代码放到调用栈中执行的,每个Web Api在执行完成以后会把回调放到事件队列中。而Event Loop(事件轮询机制)就是检查执行栈和任务队列,如果执行栈已经为空了,就会将事件队列中的第一个回调函数放到栈中执行。

事件轮询机制代码演示

b. 单从前端开发来讲,除了上面说的dom操作,定时器,ajax。还有哪些你觉得是异步操作的?

promise, Generator, async

Vue.nextTick的原理和用途

为什么setState是异步的

看一段代码:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<script>
setTimeout(function() {
console.log(1);
}) new Promise(function(resolve) {
console.log(2);
for(var i = 0; i < 1000; i++) {
i == 99 && resolve();
}
console.log(3);
}).then(function() {
console.log(4);
}) console.log(5);
</script>
</body>
</html>

打印顺序是 2 3 5 4 1。这里的promise它的执行顺序又是怎么定的

这里扯出来另一个概念,宏任务和微任务。前面事件轮询机制(Event-Loop)中说到任务队列,一些Web Api 产生的回调函数在条件达到的时候会被加到任务队列中。而任务队列又分为宏任务(macro-task)和微任务(micro-task)。最新的标准中,它们分别被称为task 和jobs。

  • 常见的macro-task大概包括:script(整体代码), setTimeout,setInterval,setImmediate, I/O, UI rendering

  • 常见的micro-task大概包括:process.nextTick, Promise, MutationObserver(html5新特性)

  • setTimeout/promise这些我们都称之为任务源,而进入任务队列的是它们指定的具体执行任务。比如setTimeout的第一个参数回调函数才是进入任务队列的任务。

  • 不同任务源的任务会进入到不同的任务队列,其中,setTimeout和setInterval是同源的

  • 事件循环的顺序,决定了Javascript代码的执行顺序。它从script(整体代码)开始第一次循环。然后全局上下文进入函数调用栈。直到调用栈清空(只剩全局),然后执行所有的微任务。当所有可执行的微任务执行完毕之后。循环再去从宏任务去找看看还有没有其它的宏任务队列,如果有的话就开始第二轮。

上面这段代码的执行顺序就是:

  1. 事件循环从宏任务队列开始,宏任务队列中只有一个script(整体代码)任务。全局上下文入栈
  2. script宏任务执行时,首先遇到了 setTimeout, 就会在宏任务中添加一个setTimout队列。
  3. script执行时遇到Promise实例。Promise构造函数的第一个参数,是在new的时候执行,不会进入到任何其它的队列。而是直接在当前任务直接执行了。所以先打印2
  4. 再往下for循环也不会进入其它队列,所以继续打印2
  5. 接下来到then了。promise的 .then 会被分发到 微任务的 Promise队列中去
  6. script继续往下执行。打印5。到此,全局任务就执行完了。
  7. 第一个宏任务script执行完了之后,就开始执行所有的可执行的微任务。这时候,微任务中,只有一个promise队列的任务console.log(4)。就打印了4
  8. 当所有的微任务执行完了之后,表示第一轮的循环就结束了。这时候继续第二轮的循环。第二轮的循环依然从宏任务开始,它就找到了 setTimout队列中还要一个 console.log(1) 的任务要执行。所以就打印了1。这时候发现宏任务队列和微任务队列中都没有任务了,所以代码就不会再输出其它东西了。