🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
[TOC] # 线程与进程 ## 概念 我们经常说JS 是单线程执行的,指的是一个进程里只有一个主线程,那到底什么是线程?什么是进程? 官方的说法是:**进程是 CPU资源分配的最小单位;线程是 CPU调度的最小单位**。这两句话并不好理解,我们先来看张图: ![](https://user-gold-cdn.xitu.io/2019/1/9/168333c14c85d794?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) * 进程好比图中的工厂,有单独的专属自己的工厂资源。 * 线程好比图中的工人,多个工人在一个工厂中协作工作,工厂与工人是 1:n的关系。也就是说**一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线**; * 工厂的空间是工人们共享的,这象征**一个进程的内存空间是共享的,每个线程都可用这些共享内存**。 * 多个工厂之间独立存在。 <br> ## 多进程与多线程 * 多进程:在同一个时间里,同一个计算机系统中如果允许两个或两个以上的进程处于运行状态。多进程带来的好处是明显的,比如你可以听歌的同时,打开编辑器敲代码,编辑器和听歌软件的进程之间丝毫不会相互干扰。 * 多线程:程序中包含多个执行流,即在一个程序中可以同时运行多个不同的线程来执行不同的任务,也就是说允许单个程序创建多个并行执行的线程来完成各自的任务。 以Chrome浏览器中为例,当你打开一个 Tab 页时,其实就是创建了一个进程,一个进程中可以有多个线程(下文会详细介绍),比如渲染线程、JS 引擎线程、HTTP 请求线程等等。当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。 <br> <br> # 浏览器内核 简单来说浏览器内核是通过取得页面内容、整理信息(应用CSS)、计算和组合最终输出可视化的图像结果,通常也被称为渲染引擎。 浏览器内核是多线程,在内核控制下各线程相互配合以保持同步,一个浏览器通常由以下常驻线程组成: * GUI 渲染线程 * JavaScript引擎线程 * 定时触发器线程 * 事件触发线程 * 异步http请求线程 <br> ## GUI渲染线程 * 主要负责页面的渲染,解析HTML、CSS,构建DOM树,布局和绘制等。 * 当界面需要重绘或者由于某种操作引发回流时,将执行该线程。 * 该线程与JS引擎线程互斥,当执行JS引擎线程时,GUI渲染会被挂起,当任务队列空闲时,主线程才会去执行GUI渲染。 <br> ## JS引擎线程 * 该线程当然是主要负责处理 JavaScript脚本,执行代码。 * 也是主要负责执行准备好待执行的事件,即定时器计数结束,或者异步请求成功并正确返回时,将依次进入任务队列,等待 JS引擎线程的执行。 * 当然,该线程与 GUI渲染线程互斥,当 JS引擎线程执行 JavaScript脚本时间过长,将导致页面渲染的阻塞。 <br> ## 定时器触发线程 * 负责执行异步定时器一类的函数的线程,如: setTimeout,setInterval。 * 主线程依次执行代码时,遇到定时器,会将定时器交给该线程处理,当计数完毕后,事件触发线程会将计数完毕后的事件加入到任务队列的尾部,等待JS引擎线程执行。 <br> ## 事件触发线程 * 主要负责将准备好的事件交给 JS引擎线程执行。 比如 setTimeout定时器计数结束, ajax等异步请求成功并触发回调函数,或者用户触发点击事件时,该线程会将整装待发的事件依次加入到任务队列的队尾,等待 JS引擎线程的执行。 <br> ## 异步http请求线程 * 负责执行异步请求一类的函数的线程,如: Promise,axios,ajax等。 * 主线程依次执行代码时,遇到异步请求,会将函数交给该线程处理,当监听到状态码变更,如果有回调函数,事件触发线程会将回调函数加入到任务队列的尾部,等待JS引擎线程执行。 <br> <br> # 为什么要有event loop 因为Javascript设计之初就是一门单线程语言,因此为了实现主线程的不阻塞,Event Loop这样的方案应运而生。 当然,现如今人们也意识到,单线程在保证了执行顺序的同时也限制了javascript的效率,因此开发出了web worker技术。这项技术号称让javascript成为一门多线程语言。 然而,使用web worker技术开的多线程有着诸多限制,例如:所有新线程都受主线程的完全控制,不能独立执行。这意味着这些“线程” 实际上应属于主线程的子线程。另外,这些子线程并没有执行I/O操作的权限,只能为主线程分担一些诸如计算等任务。所以严格来讲这些线程并没有完整的功能,也因此这项技术并非改变了javascript语言的单线程本质。 <br /> <br /> # 浏览器的event loop ## 1.Micro-Task 与 Macro-Task 浏览器端事件循环中的异步队列有两种:macro(宏任务)队列和 micro(微任务)队列。**宏任务队列可以有多个,微任务队列只有一个**。 * 常见的 macro-task 比如:setTimeout、setInterval、script(整体代码)、 I/O 操作、UI 渲染等。 * 常见的 micro-task 比如: new Promise().then(回调)、MutationObserver、await(可以转化为Promise) 等。 <br> ## Event Loop 过程解析 一个完整的 Event Loop 过程,可以概括为以下阶段: ![](https://box.kancloud.cn/858d18555aedfb66761f12a500c2a9bf_394x449.png) * 一开始执行栈空,我们可以把**执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则**。micro 队列空,macro 队列里有且只有一个 script 脚本(整体代码)。 * 全局上下文(script 标签)被推入执行栈,同步代码执行。在执行的过程中,会判断是同步任务还是异步任务,通过对一些接口的调用,可以产生新的 macro-task 与 micro-task,它们会分别被推入各自的任务队列里。同步代码执行完了,script 脚本会被移出 macro 队列,这个过程本质上是队列的 macro-task 的执行和出队的过程。 * 上一步我们出队的是一个 macro-task,这一步我们处理的是 micro-task。但需要注意的是:当 macro-task 出队时,任务是**一个一个**执行的;而 micro-task 出队时,任务是**一队一队**执行的。因此,我们处理 micro 队列这一步,会逐个执行队列中的任务并把它出队,直到队列被清空。 * **执行渲染操作,更新界面** * 检查是否存在 Web worker 任务,如果有,则对其进行处理 * 上述过程循环往复,直到两个队列都清空 <br> ![](https://box.kancloud.cn/ada9ad8af2ab065579a4f7e408f939c6_628x132.png) <br> **当某个宏任务执行完后,会查看是否有微任务队列。如果有,先执行微任务队列中的所有任务,如果没有,会读取宏任务队列中排在最前的任务,执行宏任务的过程中,遇到微任务,依次加入微任务队列。栈空后,再次读取微任务队列里的任务,依次类推。** <br> ## 2个script中执行 setTimeout、Promise ~~~ setTimeout(()=>{ console.log('timer1') Promise.resolve().then(function() { console.log('promise1') }) }, 0) setTimeout(()=>{ console.log('timer2') Promise.resolve().then(function() { console.log('promise2') }) }, 0) ~~~ ![](https://box.kancloud.cn/ae6001a935d7bbef44e7eade2b78a86b_611x341.png) <br> <br> ~~~ <script> console.log('1'); setTimeout(function () { console.log('2'); new Promise(function (resolve) { console.log('4'); resolve(); }).then(function () { console.log('5') }) }) new Promise(function (resolve) { console.log('7'); resolve(); }).then(function () { console.log('8') }) setTimeout(function () { console.log('9'); new Promise(function (resolve) { console.log('11'); resolve(); }).then(function () { console.log('12') }) }) </script> <script> console.log('a'); setTimeout(function () { console.log('b'); new Promise(function (resolve) { console.log('c'); resolve(); }).then(function () { console.log('d') }) }) new Promise(function (resolve) { console.log('e'); resolve(); }).then(function () { console.log('f') }) setTimeout(function () { console.log('g'); new Promise(function (resolve) { console.log('h'); resolve(); }).then(function () { console.log('i') }) }) </script> ~~~ <br> 结果为 ~~~ 1 7 8 a e f 2 4 5 9 11 12 b c d g h i ~~~ ### 解析 可以将2个script标签替换为setTimeout来理解 1. 2个script作为第一、二个宏任务进入主线程,记为macro1、macro2 2. 执行macro1中的同步任务,遇到`console.log`,**输出1** 3. 遇到`setTimeout`,其回调函数被分发到宏任务Event Queue中。记为macro3 4. 遇到Promise,new Promise直接执行,**输出7**。then被分发到微任务Event Queue中。我们记为micro1。 5. 遇到了setTimeout,其回调函数被分发到宏任务Event Queue中,记为macro4 6. 下表是第一轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了1和7。 | macro | micro | | --- | --- | | macro2 | micro1 | | macro3 | | | macro4 | | 7. 执行micro1,**输出8** 8. 微任务队列清空,执行下一个宏任务 9. 执行macro2(即第二个script标签)的同步任务,遇到`console.log`,**输出a** 10. 遇到`setTimeout`,其回调函数被分发到宏任务Event Queue中。记为macro5 11. 遇到Promise,new Promise直接执行,**输出e**。then被分发到微任务Event Queue中。我们记为micro2。 12. 遇到了setTimeout,其回调函数被分发到宏任务Event Queue中,记为macro6 13. 下表是第二轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了1 7 8 a e。 | macro | micro | | --- | --- | | macro3 | micro2 | | macro4 | | | macro5 | | | macro6 | | 14. 执行micro2,**输出f** 15. 微任务队列清空,执行下一个宏任务 16. 执行macro3,遇到console,**输出2** 17. 遇到Promise,**输出4**,then被分发到微任务Event Queue中。我们记为micro3。 18. 执行微任务micro3,**输出5** 19. 执行macro4,遇到console,**输出9** 20. 遇到Promise,**输出11**,then被分发到微任务Event Queue中。我们记为micro4。 21. 执行微任务micro4,**输出12** 22. 剩下的依次执行macro5、macro6,流程与macro3、macro4相同 <br> ## async、await ~~~ async function async1() { console.log('async1 start') await async2() console.log('async1 end') } async function async2() { console.log('async2') } console.log('script start') setTimeout(() => { console.log('setTimeout') }, 0); async1() new Promise(function (resolve) { console.log('promise1') resolve() }).then(() => { console.log('promise2') }) console.log('script end') ~~~ 浏览器和Node返回结果相同,注意遇到 `await` 会立即执行,阻塞外部代码的同步代码 ~~~ script start async1 start async2 promise1 script end async1 end promise2 setTimeout ~~~ <br> <br> ~~~ async function async1(){ await async2() console.log('async1 then1 end') console.log('async1 then2 end') } async function async2 () {console.log('async2 function')} async1(); new Promise(function(resolve){ resolve(); }).then(function(){ console.log('promise2') }).then(function() { console.log('promise3') }).then(function() { console.log('promise4') }) .then(function () { console.log('promise5') }) ~~~ Node 8.12.0、Chrome 72返回 ~~~ async2 function async1 then1 end async1 then2 end promise2 promise3 promise4 promise5 ~~~ 而在 Node 10.11.0、Chrome 70 上返回 ~~~ async2 function promise2 promise3 async1 then1 end async1 then2 end promise4 promise5 ~~~ ### 关于73以下版本和73版本的区别 * 在老版本版本以下,先执行`promise1`和`promise2`,再执行`async1`。 * 在73版本,先执行`async1`再执行`promise1`和`promise2`。 **主要原因是因为在谷歌(金丝雀)73版本中更改了规范,如下图所示:** ![](https://box.kancloud.cn/cf7323783c7b7ecbd1cf7adbda33d311_668x243.png) * 区别在于`RESOLVE(thenable)`和`Promise.resolve(thenable)`之间的区别。 **在老版本中** * 首先,传递给 `await` 的值被包裹在一个 `Promise` 中。然后,处理程序附加到这个包装的 `Promise`,以便在 `Promise` 变为 `fulfilled` 后恢复该函数,并且暂停执行异步函数,一旦 `promise` 变为 `fulfilled`,恢复异步函数的执行。 * 每个 `await` 引擎必须创建两个额外的 Promise(即使右侧已经是一个 `Promise`)并且它需要至少三个 `microtask` 队列 `ticks`(`tick`为系统的相对时间单位,也被称为系统的时基,来源于定时器的周期性中断(输出脉冲),一次中断表示一个`tick`,也被称做一个“时钟滴答”、时标。)。 **引用贺老师知乎上的一个例子** ~~~ async function f() { await p console.log('ok') } ~~~ 简化理解为: ~~~ function f() { return RESOLVE(p).then(() => { console.log('ok') }) } ~~~ * 如果 `RESOLVE(p)` 对于 `p` 为 `promise` 直接返回 `p` 的话,那么 `p`的 `then` 方法就会被马上调用,其回调就立即进入 `job` 队列。 * 而如果 `RESOLVE(p)` 严格按照标准,应该是产生一个新的 `promise`,尽管该 `promise`确定会 `resolve` 为 `p`,但这个过程本身是异步的,也就是现在进入 `job` 队列的是新 `promise` 的 `resolve`过程,所以该 `promise` 的 `then` 不会被立即调用,而要等到当前 `job` 队列执行到前述 `resolve` 过程才会被调用,然后其回调(也就是继续 `await` 之后的语句)才加入 `job` 队列,所以时序上就晚了。 **谷歌(金丝雀)73版本中** * 使用对`PromiseResolve`的调用来更改`await`的语义,以减少在公共`awaitPromise`情况下的转换次数。 * 如果传递给 `await` 的值已经是一个 `Promise`,那么这种优化避免了再次创建 `Promise` 包装器,在这种情况下,我们从最少三个 `microtick` 到只有一个 `microtick`。 ### 详细过程 **73以下版本** * 首先,打印`script start`,调用`async1()`时,返回一个`Promise`,所以打印出来`async2 end`。 * 每个 `await`,会新产生一个`promise`,但这个过程本身是异步的,所以该`await`后面不会立即调用。 * 继续执行同步代码,打印`Promise`和`script end`,将`then`函数放入**微任务**队列中等待执行。 * 同步执行完成之后,检查**微任务**队列是否为`null`,然后按照先入先出规则,依次执行。 * 然后先执行打印`promise1`,此时`then`的回调函数返回`undefinde`,此时又有`then`的链式调用,又放入**微任务**队列中,再次打印`promise2`。 * 再回到`await`的位置执行返回的 `Promise` 的 `resolve` 函数,这又会把 `resolve` 丢到微任务队列中,打印`async1 end`。 * 当**微任务**队列为空时,执行宏任务,打印`setTimeout`。 **谷歌(金丝雀73版本)** * 如果传递给 `await` 的值已经是一个 `Promise`,那么这种优化避免了再次创建 `Promise` 包装器,在这种情况下,我们从最少三个 `microtick` 到只有一个 `microtick`。 * 引擎不再需要为 `await` 创造 `throwaway Promise` - 在绝大部分时间。 * 现在 `promise` 指向了同一个 `Promise`,所以这个步骤什么也不需要做。然后引擎继续像以前一样,创建 `throwaway Promise`,安排 `PromiseReactionJob` 在 `microtask` 队列的下一个 `tick` 上恢复异步函数,暂停执行该函数,然后返回给调用者。 ### Chrome 72以下 async 转换为 Promise 过程 `resolve(thenable)`和`Promise.resolve(thenable)`的转换关系是这样的 ~~~ new Promise(resolve=>{ resolve(thenable) }) ~~~ 会被转换成 ~~~ new Promise(resolve => { Promise.resolve().then(() => { thenable.then(resolve) }) }) ~~~ 所以`async1`就变成了这样: ~~~ async function async1() { return new Promise(resolve => { Promise.resolve().then(() => { async2().then(resolve) }) }).then(() => { console.log('async1 end') }) } ~~~ 同样,因为`resolve()`就等价于`Promise.resolve()`,所以 ~~~ new Promise(function(resolve){ resolve(); }) ~~~ 等价于 ~~~ Promise.resolve() ~~~ 所以题目等价于 ~~~ async function async1 () { return new Promise(resolve => { Promise.resolve().then(() => { async2().then(resolve) }) }).then(() => { console.log('async1 end') }) } async function async2 () {} async1() Promise.resolve() .then(function () { console.log('promise2') }) .then(function () { console.log('promise3') }) .then(function () { console.log('promise4') }) ~~~ ### 结论 在 chrome canary 73及未来可能被解析为 ~~~ async function async1 () { async2().then(() => { console.log('async1 end') }) } async function async2 () {} async1() new Promise(function (resolve) { resolve() }) .then(function () { console.log('promise2') }) .then(function () { console.log('promise3') }) .then(function () { console.log('promise4') }) //async1 end //promise2 //promise3 //promise4 ~~~ 在 chrome 70 被解析为, ~~~ async function async1 () { return new Promise(resolve => { Promise.resolve().then(() => { async2().then(resolve) }) }).then(() => { console.log('async1 end') }) } async function async2 () {} async1() Promise.resolve() .then(function () { console.log('promise2') }) .then(function () { console.log('promise3') }) .then(function () { console.log('promise4') }) //promise2 //promise3 //async1 end //promise4 ~~~ <br /> <br /> # timer [MDN的setTimeout文档](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/setTimeout)中提到HTML规范最低延时为4ms: > In fact, 4ms is specified by the HTML5 spec and is consistent across browsers released in 2010 and onward. Prior to (Firefox 5.0 / Thunderbird 5.0 / SeaMonkey 2.2), the minimum timeout value for nested timeouts was 10 ms. (补充说明:最低延时的设置是为了给CPU留下休息时间) ~~~ setTimeout(() => { console.log(2) }, 2) setTimeout(() => { console.log(1) }, 1) setTimeout(() => { console.log(0) }, 0) // 输出结果为 1、0、2 ~~~ <br /> ## Chrome中的timer ~~~ // https://chromium.googlesource.com/chromium/blink/+/master/Source/core/frame/DOMTimer.cpp#93 double intervalMilliseconds = std::max(oneMillisecond, interval * oneMillisecond); ~~~ 这里interval就是传入的数值,可以看出传入0和传入1结果都是oneMillisecond,即1ms。 这样解释了为何1ms和0ms行为是一致的,那4ms到底是怎么回事?我再次确认了HTML规范,发现虽然有4ms的限制,但是是存在条件的,详见规范第11点: > If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4. > 如果嵌套级别大于5,并且timeout 小于4,则将timeout设置为4。 MDN英文文档的说明也已经贴合了这个规范。 <br /> ## Node中的timer ~~~ // https://github.com/nodejs/node/blob/v8.9.4/lib/timers.js#L456 if (!(after >= 1 && after <= TIMEOUT_MAX)) after = 1; // schedule on next tick, follows browser behavior ~~~ <br /> <br /> ## 应用 如果从规范来看,microtask优先于task执行。那如果有需要优先执行的逻辑,放入microtask队列会比task更早的被执行,这个特性可以被用于在框架中设计任务调度机制。 如果从node的实现来看,如果时机合适,microtask的执行甚至可以阻塞I/O,是一把双刃剑。 综上,高优先级的代码可以用Promise/process.nextTick注册执行。 <br /> <br /> # 执行效率 从node的实现来看,setTimeout这种timer类型的API,需要创建定时器对象和迭代等操作,任务的处理需要操作小根堆,时间复杂度为O(log(n))。而相对的,process.nextTick和setImmediate时间复杂度为O(1),效率更高。 如果对执行效率有要求,优先使用process.nextTick和setImmediate。 <br /> <br /> # 参考资料 * [一次弄懂Event Loop(彻底解决此类面试问题)](https://juejin.im/post/5c3d8956e51d4511dc72c200) * [浏览器与Node的事件循环(Event Loop)有何区别?](https://juejin.im/post/5c337ae06fb9a049bc4cd218) * [这一次,彻底弄懂 JavaScript 执行机制](https://juejin.im/post/59e85eebf265da430d571f89) * [Event Loop的规范和实现](https://zhuanlan.zhihu.com/p/33087629) * [前端面试之道 - 掘金小册](https://juejin.im/book/5bdc715fe51d454e755f75ef/section/5be04a8e6fb9a04a072fd2cd) * [Tasks, microtasks, queues and schedules - JakeArchibald.com](https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/) * [更快的异步函数和 Promise](https://v8.js.cn/blog/fast-async/) * [async await 和 promise微任务执行顺序问题](https://segmentfault.com/q/1010000016147496)