之前一段时间得了急性肠炎,休养停更了一段时间。天气变热,大家也要多多注意,隔夜食物如果没进冰箱就别吃了 😢。
本篇递进解析下进程、线程、浏览器渲染、任务队列、事件循环,最后用一个 ES2016 的 async/await 例子进行了分析。部分内容借鉴一些书籍和博客。
进程和线程
通俗解释
关于进程和线程的通俗解释参考阮一峰老师的《进程与线程的一个简单解释》。
计算机的核心是 CPU,它承担了所有的计算任务。它就像一座工厂,时刻在运行。
假定工厂的电力有限,一次只能供给一个车间使用。也就是说,一个车间开工的时候,其他车间都必须停工。背后的含义就是,单个 CPU 一次只能运行一个任务。
进程就好比工厂的车间,它代表 CPU 所能处理的单个任务。任一时刻,CPU 总是运行一个进程,其他进程处于非运行状态。
一个车间里,可以有很多工人。他们协同完成一个任务。
线程就好比车间里的工人。一个进程可以包括多个线程。
车间的空间是工人们共享的,比如许多房间是每个工人都可以进出的。这象征一个进程的内存空间是共享的,每个线程都可以使用这些共享内存。
可是,每间房间的大小不同,有些房间最多只能容纳一个人,比如厕所。里面有人的时候,其他人就不能进去了。这代表一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存。
一个防止他人进入的简单方法,就是门口加一把锁。先到的人锁上门,后到的人看到上锁,就在门口排队,等锁打开再进去。这就叫“互斥锁”(Mutual exclusion,缩写 Mutex),防止多个线程同时读写某一块内存区域。
还有些房间,可以同时容纳 n 个人,比如厨房。也就是说,如果人数大于 n,多出来的人只能在外面等着。这好比某些内存区域,只能供给固定数目的线程使用。
这时的解决方法,就是在门口挂 n 把钥匙。进去的人就取一把钥匙,出来时再把钥匙挂回原处。后到的人发现钥匙架空了,就知道必须在门口排队等着了。这种做法叫做“信号量”(Semaphore),用来保证多个线程不会互相冲突。
不难看出,Mutex 是 Semaphore 的一种特殊情况(n=1 时)。也就是说,完全可以用后者替代前者。但是,因为 Mutex 较为简单,且效率高,所以在必须保证资源独占的情况下,还是采用这种设计。
操作系统的设计,因此可以归结为三点:
- 以多进程形式,允许多个任务同时运行;
- 以多线程形式,允许单个任务分成不同的部分运行;
- 提供协调机制,一方面防止进程之间和线程之间产生冲突,另一方面允许进程之间和线程之间共享资源。
学术解释
关于进程和线程的学术一点的解释参考这篇知乎回答《线程和进程的区别是什么?》
CPU 背景
- CPU+RAM+各种资源(比如显卡,光驱,键盘,GPS, 等等外设)构成我们的电脑,但是电脑的运行,实际就是 CPU 和相关寄存器以及 RAM 之间的事情。
- 一个最最基础的事实:CPU 太快,太快,太快了,寄存器仅仅能够追的上他的脚步,RAM 和别的挂在各总线上的设备完全是望其项背。那当多个任务要执行的时候怎么办呢?轮流着来?或者谁优先级高谁来?不管怎么样的策略,一句话就是在 CPU 看来就是轮流着来。
- 一个必须知道的事实:执行一段程序代码,实现一个功能的过程介绍 ,当得到 CPU 的时候,相关的资源必须也已经就位,就是显卡啊,GPS 啊什么的必须就位,然后 CPU 开始执行。这里除了 CPU 以外所有的就构成了这个程序的执行环境,也就是我们所定义的程序上下文。当这个程序执行完了,或者分配给他的 CPU 执行时间用完了,那它就要被切换出去,等待下一次 CPU 的临幸。在被切换出去的最后一步工作就是保存程序上下文,因为这个是下次他被 CPU 临幸的运行环境,必须保存。
- 串联起来的事实:前面讲过在 CPU 看来所有的任务都是一个一个的轮流执行的,具体的轮流方法就是:先加载程序 A 的上下文,然后开始执行 A,保存程序 A 的上下文,调入下一个要执行的程序 B 的程序上下文,然后开始执行 B,保存程序 B 的上下文。
进程线程区别
进程和线程就是这样的背景出来的,两个名词不过是对应的CPU 时间段的描述,名词就是这样的功能。
- 进程就是包括切换上下文切换的程序执行时间总和 = CPU 加载上下文 + CPU 执行 + CPU 保存上下文
线程是什么呢?
进程的颗粒度太大,每次都要有上下的调入,保存,调出。如果我们把进程比喻为一个运行在电脑上的软件,那么一个软件的执行不可能是一条逻辑执行的,必定有多个分支和多个程序段,就好比要实现程序 A,实际分成 a,b,c 等多个块组合而成。那么这里具体的执行就可能变成:
程序 A 得到 CPU –> CPU 加载上下文–>开始执行程序 A 的 a 小段–>然后执行 A 的 b 小段–>然后再执行 A 的 c 小段–>最后 CPU 保存 A 的上下文。
这里 a,b,c 的执行的是共享了 A 的上下文,CPU 在执行的时候没有进行上下文切换的。这里的a,b,c 就是线程,也就是说线程是共享了进程的上下文环境的更为细小的 CPU 时间段。
做一个总结:
进程和线程都是一个时间段的描述,是 CPU 工作时间段的描述,不过是颗粒大小不同。
小结
看完通俗和学术的理解以后,官方的解释就应该很明白了:
- 进程是 CPU 资源分配的最小单位(是能拥有资源和独立运行的最小单位)
- 线程是 CPU 调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)
现在,一般通用的单线程与多线程的说法,都是指在一个进程内的单和多(核心是属于一个进程)。
浏览器的进程与线程
关于浏览器中的进程与线程借鉴该博客《从浏览器多进程到 JS 单线程,JS 运行机制最全面的一次梳理》。
浏览器多进程
在 Chromium 系浏览器中按 Shift+Esc 快捷键可以打开任务管理器,可以发现基本上每个 tab 页都有一个自己的进程。
相比于单进程浏览器,多进程有如下优点:
- 避免单个 page 崩溃影响整个浏览器
- 避免第三方插件崩溃影响整个浏览器
- 多进程充分利用多核优势
- 方便使用沙盒模型隔离插件等进程,提高浏览器稳定性
渲染(Renderer)进程
对于前端开发来说,渲染(Renderer)进程是最重要的进程。可以这样理解,页面的渲染,JS 的执行,事件的循环,都在这个进程内进行。接下来重点分析这个进程的多个线程:
渲染进程中的线程
GUI 渲染线程
负责渲染浏览器界面,解析 HTML,CSS,构建 DOM 树和 RenderObject 树,布局和绘制等。
当界面需要重绘(Repaint)或由于某种操作引发回流(Reflow)时,该线程就会执行。
注意,GUI 渲染线程与 JS 引擎线程是互斥的,当 JS 引擎线程执行时 GUI 线程会被挂起(相当于被冻结了),GUI 更新会被保存在一个队列中等到 JS 引擎空闲时立即被执行。
JS 引擎线程
也称为 JS 内核,负责处理 Javascript 脚本程序。(例如V8 引擎)
JS 引擎线程负责解析 Javascript 脚本,运行代码。
JS 引擎一直等待着任务队列中任务的到来,然后加以处理,一个 Tab 页(Renderer 进程)中无论什么时候都只有一个 JS 线程在运行 JS 程序
同样注意,GUI 渲染线程与 JS 引擎线程是互斥的,所以如果 JS 执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。
事件触发线程
- 归属于浏览器而不是 JS 引擎,用来控制事件循环(可以理解,JS 引擎自己都忙不过来,需要浏览器另开线程协助)。
- 当 JS 引擎执行代码块如 setTimeOut 时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX 异步请求等),会将对应任务添加到事件线程中。
- 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理。
- 注意,由于 JS 的单线程关系,所以这些待处理队列中的事件都得排队等待 JS 引擎处理(当 JS 引擎空闲时才会去执行)。
定时触发器线程
setInterval
与setTimeout
所在线程。浏览器定时计数器并不是由 JavaScript 引擎计数的,(因为 JavaScript 引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确)。
因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待 JS 引擎空闲后执行)。
注意,W3C 在 HTML 标准中规定,规定要求 setTimeout 中低于 4ms 的时间间隔算为 4ms。
异步 http 请求线程
- 在 XMLHttpRequest 在连接后是通过浏览器新开一个线程请求。
- 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由 JavaScript 引擎执行。
渲染进程中的线程之间的关系
到了这里,已经对浏览器的运行有了一个整体的概念,接下来,简单梳理一些概念。
GUI 渲染线程与 JS 引擎线程互斥
由于 JavaScript 是可操纵 DOM 的,如果在修改这些元素属性同时渲染界面(即 JS 线程和 UI 线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。
因此为了防止渲染出现不可预期的结果,浏览器设置 GUI 渲染线程与 JS 引擎为互斥的关系,当 JS 引擎执行时 GUI 线程会被挂起,GUI 更新则会被保存在一个队列中等到 JS 引擎线程空闲时立即被执行。
JS 阻塞页面加载
从上述的互斥关系,可以推导出,JS 如果执行时间过长就会阻塞页面。
譬如,假设 JS 引擎正在进行巨量的计算,此时就算 GUI 有更新,也会被保存到队列中,等待 JS 引擎空闲后执行。
然后,由于巨量计算,所以 JS 引擎很可能很久很久后才能空闲,自然会感觉到巨卡无比。
所以,要尽量避免 JS 执行时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。
渲染流程
浏览器输入 URL,浏览器主进程接管,开一个下载线程,然后进行 http 请求(略去 DNS 查询,IP 寻址等等操作),然后等待响应,获取内容,随后将内容通过 RendererHost 接口转交给 Renderer 进程,浏览器渲染流程开始:
浏览器器内核拿到内容后,渲染大概可以划分成以下几个步骤:
- 解析 HTML 建立 DOM 树
- 解析 CSS 构建 Render 树(将 CSS 代码解析成树形的数据结构,然后结合 DOM 合并成 render 树)
- 布局 Render 树(Layout/Reflow),负责各元素尺寸、位置的计算
- 绘制 Render 树(Paint),绘制页面像素信息
- 浏览器会将各层的信息发送给 GPU,GPU 会将各层合成(Composite),显示在屏幕上。
所有详细步骤都已经略去,渲染完毕后就是load
事件,之后就是自己的 JS 逻辑处理了。
任务队列
关于自己的 JS 逻辑处理,参考阮一峰老师的《JavaScript 运行机制详解:再谈 Event Loop》。
JS 引擎是单线程就意味着,所有 JS 任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
很多时候 CPU 是闲着的,因为 IO 设备(输入输出设备)很慢(比如 Ajax 操作从网络读取数据),不得不等着结果出来,再往下执行。
JavaScript 语言的设计者意识到,这时 JS 引擎线程完全可以不管 IO 设备,挂起处于等待中的任务,先运行排在后面的任务。等到 IO 设备返回了结果,再回过头,把挂起的任务继续执行下去。
于是,所有任务可以分成两种,一种是同步任务(Synchronous),另一种是异步任务(Synchronous)。同步任务指的是,在 JS 引擎线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入JS 引擎线程、而进入事件触发线程管理的”任务队列“(Task Queue),所有同步任务执行完毕(此时 JS 引擎线程空闲),就会读取任务队列,将可运行的异步任务添加到 JS 引擎线程的可执行栈中,开始执行。
具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)
(1)所有同步任务都在 JS 引擎线程上执行,形成一个执行栈(Execution Context Stack)。
(2)事件触发线程一个”任务队列”(Task Queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。
(3)一旦”执行栈”中的所有同步任务执行完毕,JS 引擎线程就会读取”任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
(4)JS 引擎线程不断重复上面的第三步。
下图就是 JS 引擎线程和任务队列的示意图。
只要 JS 引擎线程空了,就会去读取”任务队列”,这就是 JavaScript 的运行机制。这个过程会不断重复。
事件和回调函数
“任务队列”是一个事件的队列(也可以理解成消息的队列),IO 设备完成一项任务,就在”任务队列”中添加一个事件,表示相关的异步任务可以进入”执行栈”了。JS 引擎线程读取”任务队列”,就是读取里面有哪些事件。
“任务队列”中的事件,除了 IO 设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入”任务队列”,等待 JS 引擎线程读取。
所谓”回调函数”(Callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。
“任务队列”是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,”任务队列”上第一位的事件就自动进入 JS 引擎线程。
Event Loop
JS 引擎线程从”任务队列”中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为 Event Loop(事件循环)。
为了更好地理解 Event Loop,请看下图(转引自 Philip Roberts 的演讲《Help, I’m stuck in an event-loop》)。
上图中,JS 引擎线程运行的时候,产生堆(Heap)和栈(Stack),栈中的代码调用各种外部 API,它们在”任务队列”中加入各种事件(click,load,done)。只要栈中的代码执行完毕,JS 引擎线程就会去读取”任务队列”,依次执行那些事件所对应的回调函数。
以 async/await 举例
ES2016 中加入的 async/await 关键字,引入了对异步/等待的支持。允许开发者以同步的方式编写 Javascript 代码,但以异步的方式执行。
在运行中,await 关键字将暂停 async 函数的执行,然后释放 JS 引擎线程,让 JS 引擎线程跳出当前 async 函数继续执行后面栈中的代码。而事件触发线程管理 await 的异步运行,当异步完成后加入到任务队列中。当本轮事件循环完成后,JS 引擎线程检查任务队列中是否有 await 的运行结果。如果已在任务队列中,就读取结果后跳回 async 函数继续执行。
以《JS 高级程序设计第四版》中 11 章的代码举例,分析实际的运行流程,代码如下:
1 | async function foo() { |
结合线程的概念,运行流程分析如下:
console.log(1)
打印1
。- 调用
async
函数foo
。 foo
内部console.log(2)
打印2
。foo
内部,await
关键字暂停并释放 JS 引擎线程的执行,事件触发线程管理await
的异步运行,获得了Promise.resolve(8)
,将其添加到任务队列中。- JS 引擎线程跳出了
async
函数foo
,继续执行后面栈中的代码。 console.log(3)
打印3
。- 调用
async
函数bar
。 bar
内部console.log(4)
打印4
。bar
内部,await
关键字暂停并释放 JS 引擎线程的执行,事件触发线程管理await
的异步运行,马上就获得了结果6
后加入到任务队列中。- JS 引擎线程跳出了
async
函数 bar,继续执行后面栈中的代码。 console.log(5)
打印5
。- JS 引擎线程执行完成一次。
- JS 引擎线程从任务队列中取出了已完成的 promise 处理器,得到结果值
8
。 - 事件触发线程把结果
8
放入了事件队列中。 - JS 引擎线程从任务队列中取出
6
。 - JS 引擎线程跳回
bar
,await
提供值6
。 console.log(await 6)
打印6
。console.log(7)
打印7
。- 函数
bar
return
。 - JS 引擎线程从任务队列中取出
8
。 - JS 引擎线程跳回
foo
,await
提供值8
。 console.log(await Promise.resolve(8))
打印8
。console.log(9)
打印9
。- 函数
foo
return
。
这里比较关键的地方是
await Promise.resolve(8)
,其实是进了 2 次任务队列的,一次是await
,一次是Promise.resolve(8)
,而await 6
只进了 1 次任务队列,所以虽然代码中 8 在前面,但是6
要比8
先打印出来。
总结
关于 Event Loop 和 async/await 的执行流程这里,自己也拿不太准,按照自己的理解写出来的。抛砖引玉,希望大家多多指正。
参考
- 进程与线程的一个简单解释,阮一峰;
- 线程和进程的区别是什么?,zhongyong;
- JavaScript 运行机制详解:再谈 Event Loop,阮一峰;
- Professional JavaScript for Web Developes 4th Edition(JavaScript 高级程序设计第四版),Matt Frisbie,李松峰