Event Loop
在前两章节中我们了解了 JS 异步相关的知识。在实践的过程中,你是否遇到过以下场景,为什么 setTimeout
会比 Promise
后执行,明明代码写在 Promise
之前。这其实涉及到了 Event Loop 相关的知识,这一章节我们会来详细地了解 Event Loop 相关知识,学习 JS 异步运行代码的原理,并且这一章节也是面试常考知识点。
更新日志
- 各小节内容优化
进程与线程
相信大家经常会听到 JS 是单线程执行的,但是你是否疑惑过什么是线程?
讲到线程,那么肯定也得说一下进程。本质上来说,这两个名词都是 CPU 工作时间片的一个描述。
进程描述了 CPU 在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序。线程是进程中的更小单位,描述了执行一段指令所需的时间。
把这些概念拿到浏览器中来说,当你打开一个 Tab 页时,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程等等。当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。
常见考点
- 进程线程区别
- JS 单线程执行带来的优缺点
执行栈
可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。
当开始执行 JS 代码时,首先会执行一个 main
函数,然后执行我们的代码。根据先进后出的原则,后执行的函数会先弹出栈,在图中我们也可以发现,foo
函数后执行,当执行完毕后就先从栈中弹出了。
另外在平时开发过程中,大家也可以在报错中找到执行栈的痕迹:
function foo() {
throw new Error('error')
}
function bar() {
foo()
}
bar()
大家可以在上图清晰的看到报错在 foo
函数,foo
函数又是在 bar
函数中调用的。
当我们使用递归的时候,因为执行栈的内存空间是有限的,一旦存放了过多的函数且没有得到释放的话,就会出现爆栈的问题。
function bar() {
bar()
}
bar()
常见考点
- 什么是执行栈
浏览器中的 Event Loop
上一小节中我们讲到了什么是执行栈,大家也知道了当我们执行 JS 代码的时候其实就是往执行栈中放入函数,那么当遇到异步代码的时候该怎么办?
其实当遇到异步代码时,会被挂起并在需要执行的时候加入到 Task(有多种 Task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行。
不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs
,macrotask 称为 task
。
微任务包括 process.nextTick
、queueMicrotask
、promise.then
、MutationObserver
,其中 process.nextTick
为 Node 独有。
宏任务包括 script
、 setTimeout
、setInterval
、setImmediate
、I/O
及 UI rendering
。
讲了这么多,想必大家肯定对于这个 Loop 是怎么样执行的很感兴趣吧。
- 首先执行同步代码,这属于宏任务
- 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行
- 执行所有微任务
- 当执行完所有微任务后,如有必要会渲染页面
- 然后开始下一轮 Event Loop,执行宏任务中的异步代码,也就是
setTimeout
中的回调函数
这里很多人会有个误区,认为微任务快于宏任务,其实是错误的。因为宏任务中包括了 script
,浏览器一定会先执行一个 script
,接下来才会执行微任务。
了解完以上内容,我们通过一段代码来看看掌握情况:
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()
setTimeout(function() {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function() {
console.log('promise1')
})
console.log('script end')
// script start => async2 end => Promise => script end
// => async1 end => promise1 => setTimeout
以上代码能很好地帮助我们理解宏任务及微任务的执行顺序,务必搞懂。
如果你对执行顺序有迷惑的话,可以再结合 Event Loop 的执行顺利再思考下。我们首先根据代码执行顺序分离出同步代码,也就是注释第一行的 log
顺序。另外在这些同步代码执行过程中,微任务及宏任务也被推入了相应的队列中等待执行。
接下来我们再根据微任务进入队列的顺序得出打印结果为 async1 end
及 promise1
。
最后当微任务执行完毕后需要执行宏任务,此时打印 setTimeout
。
常见考点
- 笔试题,类似于上面的代码,让你写出
log
顺序 - 如何理解 Event loop
Node 中的 Event Loop
Node 中的 Event Loop 和浏览器中的区别很大。
Node 的 Event Loop 分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。
timer
timers 阶段会执行 setTimeout
和 setInterval
回调,并且是由 poll 阶段控制的。
同样,在 Node 中定时器指定的时间也不是准确时间,只能是尽快执行。
pending callbacks
这个阶段会执行上一轮循环中的poll 阶段被延迟执行的 I/O 回调。
idle, prepare
这个阶段内部使用,略过。
poll
这个阶段略复杂,做的事情会比较多。
首先如果队列不为空,会遍历回调队列并执行,直到队列为空或者达到系统限制。
接下来检查是否有 setImmediate
回调需要执行。有的话就直接结束 poll 阶段并且进入到 check 阶段执行回调。
如果没有 setImmediate
回调需要执行。会 block 线程并等待一会,看是否有新的事件进入队列,有的话就去执行。
在此过程中如果有 timer 需要执行,会回到 timer 阶段执行回调。
check
这个阶段执行 setImmediate
回调。
close callbacks
这个阶段执行关闭操作,比如关闭 socket
等。
上述阶段都为宏任务的执行阶段,对于 microtask 来说,它会在以上每个阶段完成前清空 microtask 队列,下图中的 Tick 就代表了 microtask。
最后我们来讲讲 Node 中的 process.nextTick
,这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
})
})
})
})
对于以上代码,大家可以发现无论如何都是先把 nextTick
全部打印出来。
常见考点
- Node 中的 Event Loop 和浏览器中的有什么区别?
- process.nexttick 执行顺序?
小结
这一章节我们学习了 JS 实现异步的原理,并且了解了在浏览器和 Node 中 Event Loop 其实是不相同的。Event Loop 这个知识点对于我们理解 JS 是如何执行的至关重要,同时也是常考题。如果大家对于这个章节的内容存在疑问,欢迎在评论区与我互动。