Event Loop 也叫做“事件循环”,它其实与 JavaScript 的运行机制有关,乍一看云里雾里,不用着急,读完本文你便会知晓它的含义,这一切都要从 JavaScript 的初始设计说起。
单线程
进程和线程是操作系统中的概念,在操作系统中,一个任务就是一个进程,比如你在电脑上打开了一个浏览器来观看视频,便是打开了一个浏览器进程,此时又想记录视频中的重要信息,于是你打开了备忘录,这便是一个备忘录进程,系统会为每个进程分配它所需要的地址空间,数据,代码等系统资源。如果把一个进程看做一个小的车间,车间里有很多工人,有的负责操作机器,有的负责搬运材料,每个工人可以看做一个线程,线程可以共享进程的资源。可以说,线程是进程的最小单位,一个进程可以包含多个线程。
JavaScript 在设计之初便是单线程,程序运行时,只有一个线程存在,在特定的时候只能有特定的代码被执行。这和 JavaScript 的用途有关,它是一门浏览器脚本语言,通常是用来操作 DOM 的,如果是多线程,一个线程进行了删除 DOM 操作,另一个添加 DOM,此时该如何处理?所以 JavaScript 在设计之初便是单线程的。
虽然 HTML5 增加了 Web Work 可用来另开一个线程,但是该线程仍受主线程的控制,所以 JavaScript 的本质依然是单线程。
执行栈和任务队列
单线程的 JavaScript 一段一段地执行,前面的执行完了,再执行后面的,试想一个,如果前一个任务需要执行很久,比如接口请求、I/O 操作,此时后面的任务只能干巴巴地等待么?干等不仅浪费了资源,而且页面的交互程度也很差。JavaScript 意识到了这个问题,他们将任务分成了同步任务和异步任务,对于二者有不同的处理。
JavaScript 在运行时会将变量存放在堆(heap)和栈(stack)中,堆中通常存放着一些对象,而变量及对象的指针则存放在栈中。JavaScript 在执行时,同步任务会排好队,在主线程上按照顺序执行,前面的执行完了再执行后面的,排队的地方叫执行栈(execution context stack)。JavaScript 对异步任务不会停下来等待,而是将其挂起,继续执行执行栈中的同步任务,当异步任务有返回结果时,异步任务会加入与执行栈不一样的队列,即任务队列(task queue),所以任务队列中存放的是异步任务执行完成后的结果,通常是回调函数。
当执行栈的同步任务已经执行完成,此时主线程闲下来,它便会去查看任务队列是否有任务,如果有,主线程会将最先进入任务队列的任务加入到执行栈中执行,执行栈中的任务执行完了之后,主线程便又去任务队列中查看是否有任务可执行。主线程去任务队列读取任务到执行栈中去执行,这个过程是循环往复的,这便是 Event Loop,事件循环。
网上有张流传甚广的图对这一过程进行了总结,在图中我们可以看到,JavaScript 在运行时产生了堆和栈,ajax、setTimeout 等异步任务被挂起,异步任务的返回结果加入任务队列,主线程会循环往复地读取任务队列中的任务,加入执行栈中执行。
(JavaScript 运行机制,图片来源于网络)
为了更好的理解 JavaScript 的执行机制,我们来看个小例子。
console.log(1)
setTimeout(function() {
console.log(2)
}, 300)
console.log(3)
输出的结果是 1,3,2。setTimeout 是一个定时器,延迟 300 毫秒执行,所以 300 毫秒后,打印 2 的回调函数才会进入任务队列,等到执行栈中的代码执行完成后,也就是打印出 1 和 3 后,打印出 2 的回调函数才进入执行栈执行。
如果将 setTimeout 的第二个参数设置为 0,它表示主线程空闲之后尽早执行它的回调,HTML5 规定 setTimeout 的第二个参数不得小于 4 毫秒。
setTimeout(function() {
console.log(1)
}, 0)
console.log(2)
// 2,1
对于 setTimeout 还有一个需要注意的是,它的延迟时间并不是等待多少毫秒后就一定会执行,始终是要等待主线程已经空闲了才会去读取它,如果执行栈中的任务需要很长时间才能执行完,那任务队列中的任务只能等待。我们可以通过一个例子来体验一下。
var enterTime = Date.now()
function sleep(time) {
for(var temp = Date.now(); Date.now() - temp <= time;);
}
setTimeout(function() {
var exeTime = Date.now()
console.log(exeTime - enterTime)
}, 300)
sleep(1000) // 睡眠 1 秒
我们定义了一个 sleep 函数,设置了 1 秒的执行时间,所以 setTimeout 要等待的时间肯定大于 1 秒,而不是 300 毫秒后就执行了。上述代码的执行结果是 1000 左右,值不固定,可以复制代码到控制台执行看看。
宏任务与微任务
异步任务有更深一层的划分,它们是宏任务(macro task)和微任务(micro task),二者的执行顺序也有差别。在上面我们讲到异步任务的结果会进入任务队列中,对于不同的事件类型,宏任务会加入宏任务队列,微任务会加入微任务队列。
常见的宏任务有 script(整体代码),setTimeout,setInterval;常见的微任务有 new Promise、process.nextTick(node.js 环境)。
在执行栈空的时候,主线程会从任务队列中取任务来执行,其过程如下:
1.选择最先进入队列的宏任务执行(最开始是 script 整体代码)
2.检查是否存在微任务,如果存在,执行微任务队列中得所以任务,直至清空微任务队列
3.重复以上步骤
我们来通过代码体验一下宏任务与微任务的执行顺序。
console.log(1)
setTimeout(function() {
console.log(2)
new Promise(function(resolve) {
console.log(3)
resolve(4)
}).then(function(num) {
console.log(num)
})
}, 300)
new Promise(function(resolve) {
console.log(5)
resolve(6)
}).then(function(num) {
console.log(num)
})
setTimeout(function() {
console.log(7)
}, 400)
我们一步步来分析上面的执行顺序,这段代码作为宏任务进入主线程开始执行,首先打印出 1,然后遇到了 setTimeout,主程序将它挂起,300 毫秒后它的回调函数进入宏任务队列,我们记做 setTimeout1。随后遇到了 new Promise,resolve 部分是同步执行的,所以会打印出 5,then 中的回调函数进入微任务队列,我们暂时记做 promise1。最后是 setTimeout,同理在 400 毫秒后加入了宏任务队列,我们记做 setTimeout2。此时任务队列的情况如下:
宏任务 | 微任务 |
---|---|
setTimeout1 | promise1 |
setTimeout2 |
此时已经执行完一个宏任务(script 整体代码),接着主线程查看微任务队列,发现存在微任务,于是把 promise1 执行了,打印出 6。此时微任务队列已经空了,任务队列的情况如下:
宏任务 | 微任务 |
---|---|
setTimeout1 | |
setTimeout2 |
以上便是一次循环。
接着主线程又开始查看宏任务队列,将 setTimeout1 的回调函数加入任务栈开始执行,于是首先打印出 2,之后是 3,再将 then 中的回调函数加入微任务队列,我们记做 promise2。此时任务队列的情况如下:
宏任务 | 微任务 |
---|---|
setTimeout2 | promise2 |
此时执行栈也空了,于是将微任务 promise2 加入执行栈,打印出 4。此时微任务已经执行完,这便完成了第二次循环。然后再查看宏任务队列,于是执行 setTimeout2,打印出 7。所以代码中的输出顺序是 1,5,6,2,3,4,7。需要注意的是,主线程对微任务的读取是逐个读取,直到微任务队列为空。对宏任务队列的读取在一次循环中只读取一个。
小结
在本节中,我们了解了 JavaScript 的运行机制,它是单线程的。JavaScript 中的任务可分为同步任务和异步任务,同步任务总是先进入执行栈中执行,异步任务会被挂起,直到有结果返回时,异步任务会进入任务队列中等待主线程读取执行。当执行栈为空时,主线程便会循环往复地读取任务队列中的事件,进入执行栈执行,这个过程叫 Event Loop。主线程对任务队列的读取也有先后之分,首先会读取宏任务,最开始是 script 整体代码,执行完一个宏任务后,会去查找微任务,将微任务队列的事件都执行完,这个过程也是循环往复的。在本节中,你需要掌握:
JavaScript 是单线程的本质;
执行栈和任务队列是什么;
什么是 Event Loop;
宏任务和微任务的区别。