进程与线程
涉及面试题:进程与线程区别?JS 单线程带来的好处?
相信大家经常会听到 JS 是单线程执行的,但是你是否疑惑过什么是线程?
讲到线程,那么肯定得说一下进程。本质上来说,两个名词都是 CPU 工作时间片 的一个描述。
进程描述了 CPU 在运行指令及加载和保存上下文所需的时间放在应用上来说就代表了一个程序。线程是进程中的更小单位,描述了执行一段指令所需要的时间。
把这些概念拿到浏览器中来说,当你打开一个 tab 页时,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程等待。当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。
上文说到了 JS 引擎线程和渲染线程,大家应该都知道,在 JS 运行的时候可能会阻止 UI 渲染,这说明了两个线程是互斥的。这其中的原因是因为 JS 可以修改 DOM,如果在 JS 执行的时候 UI 线程还在工作,就可能导致不能安全的渲染 UI。这其实也是一个单线程的好处,得益于 JS 是单线程运行的,可以达到节省内存,节约上下文切换时间,没有锁的问题的好处。当然前面两点在服务端中更容易体现,对于锁的问题,形象的来说就是当我读取一个数字 24 的时候,同时有两个操作对数字进行了加减,这时候结果就出现了错误。解决这个问题也不难,只需要在读取的时候加锁,知道读取完毕之前都不能进行写入操作。
执行栈
涉及面试题:什么是执行栈?
可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。
当开始执行 JS 代码时,首先会执行一个main
函数,然后执行我们的代码。根据先进后出的原则,后执行的函数会先弹出栈,在图中我们也可以发现,foo
函数后执行,当执行完毕后就从栈中弹出了。
平时在开发中,大家也可以在报错中找到栈的痕迹
1 | function foo() { |
大家可以在上图清晰的看到报错在 foo
函数,foo
函数又是在 bar
函数中调用的。
当我们使用递归的时候,因为栈可存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话,就会出现爆栈的问题
1 | function bar() { |
浏览器中的 Event Loop
涉及面试题:异步代码执行顺序?解释一下什么是 Event Loop ?
上一小节我们讲到了什么是执行栈,大家也知道了当我们执行 JS 代码的时候其实就是往执行栈中放入函数,那么遇到异步代码的时候该怎么办?其实当遇到异步的代码时,会被挂起并在需要执行的时候加入到 Task (有多种Task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为。
不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs
,macrotask 称为 task
。下面来看以下代码的执行顺序:
1 | console.log('script start') |
首先先来解释下上述代码的 async
和 await
的执行顺序。当我们调用 async1
函数时,会马上输出 async2 end
,并且函数会返回一个 Promise
,接下来在遇到 await
的时候就会让出线程开始执行 async1
外的代码,所以我们完全可以把 await
看成让出线程的标志。
然后当同步代码全部执行完毕以后,就会去执行所有的异步代码,那么又回到 await
的位置执行返回的 Promise
的 resolve
函数,这又会把 resolve
丢到 微任务队列中,接下来去执行 then
中的回调,当两个 then
中的回调全部执行完毕以后,又会回到 await
的位置处理返回值,这时候你可以看成是 Promise.resolve(返回值).then()
,然后 await
后的代码全部被包裹进了 then
的回调中,所以 console.log('async1 end')
会优先执行于 setTimeout
。
如果你觉得上面这段解释还是有点绕,那么我把 async
的这两个函数改造成你一定能理解的代码
1 | new Promise((resolve, reject) => { |
也就是说,如果 await
后面跟着 Promise
的话,async1 end
需要等待三个 tick 才能执行到。那么其实这个性能相对来说还是略慢的,所以 V8 团队借鉴了 Node 8 中的一个 Bug,在引擎底层将三次 tick 减少到了二次 tick。但是这种做法其实是违法了规范的,当然规范也是可以更改的,这是 V8 团队的一个 PR,目前已被同意这种做法。
所以 Event Loop 执行顺序如下所示:
- 首先执行同步代码,这属于宏任务
- 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行
- 执行所有微任务
- 当执行完所有微任务后,如有必要会渲染页面
- 然后开始下一轮 Event Loop,执行宏任务中的异步代码,也就是
setTimeout
中的回调函数
所以以上代码虽然 setTimeout
写在 Promise
之前,但是因为 Promise
属于微任务而 setTimeout
属于宏任务,所以会有以上的打印。
微任务包括 process.nextTick
,Promise
,MutationObserver
。
宏任务包括 script
, setTimeout
,setInterval
,setImmediate
,I/O
,UI rendering
。
这里很多人会有个误区,认为微任务快于宏任务,其实是错误的。因为宏任务中包括了 script
,浏览器会先执行一个宏任务,接下来有异步代码的话才会先执行微任务。