搞懂EventLoop
前言
Event Loop
即事件循环,是指浏览器或Node
的一种解决javaScript
单线程运行时不会阻塞的一种机制,也就是我们经常使用异步的原理。
为啥要弄懂 Event Loop
Event Loop 是 JavaScript 异步编程的核心思想,也是前端进阶必须跨越的一关。同时,它又是面试的必考点,特别是在 Promise 出现之后,各种各样的面试题层出不穷,花样百出。这篇文章从现实生活中的例子入手,让你彻底理解 Event Loop 的原理和机制,并能游刃有余的解决此类面试题。
- 是要增加自己技术的深度,也就是懂得
JavaScript
的运行机制。 - 现在在前端领域各种技术层出不穷,掌握底层原理,可以让自己以不变,应万变。
- 应对各大互联网公司的面试,懂其原理,题目任其发挥。
常见面试题
async function async1() {
console.log("async1 start");
await async2();
console.log("async1 end");
}
async function async2() {
console.log("async2");
}
console.log("script start");
setTimeout(function () {
console.log("setTimeout");
}, 0);
async1();
new Promise(function (resolve) {
console.log("promise1");
resolve();
}).then(function () {
console.log("promise2");
});
console.log("script end");
如果上面的面试题,还没有搞明白的话,那么接下来的内容,你需要仔细阅读喽。
为什么 JavaScript 是单线程的?
我们都知道 JavaScript 是一门 单线程
语言,也就是说同一时间只能做一件事。这是因为 JavaScript 生来作为浏览器脚本语言,主要用来处理与用户的交互、网络以及操作 DOM。 这就决定了它只能是单线程的,否则会带来很复杂的同步问题。
所以就是说在一行代码执行的过程中,必然不会存在同时执行的另一行代码,就像使用alert()
以后进行疯狂console.log
,如果没有关闭弹框,控制台是不会显示出一条log
信息的。
亦或者有些代码执行了大量计算,比方说在前端暴力破解密码之类的鬼操作,这就会导致后续代码一直在等待,页面处于假死状态,因为前边的代码并没有执行完。
所以如果全部代码都是同步执行的,这会引发很严重的问题,比方说我们要从远端获取一些数据,难道要一直循环代码去判断是否拿到了返回结果么?就像去饭店点餐,肯定不能说点完了以后就去后厨催着人炒菜的,会被打出去的。
于是就有了异步事件的概念,注册一个回调函数,比如说发一个网络请求,我们告诉主程序等到接收到数据后通知我,然后我们就可以去做其他的事情了。
然后在异步完成后,会通知到我们,但是此时可能程序正在做其他的事情,所以即使异步完成了也需要在一旁等待,等到程序空闲下来才有时间去看哪些异步已经完成了,可以去执行。
比如说打了个车,如果司机先到了,但是你手头还有点儿事情要处理,这时司机是不可能自己先开着车走的,一定要等到你处理完事情上了车才能走。
既然 Javascript 是单线程的,它就像是只有一个窗口的银行,客户不得不排队一个一个的等待办理。同理 JavaScript 的任务也要一个接一个的执行,如果某个任务(比如加载高清图片)是个耗时任务,那浏览器岂不得一直卡着?为了防止主线程的阻塞,JavaScript 有了 同步
和 异步
的概念。
同步和异步
同步
如果在一个函数返回的时候,调用者就能够得到预期结果,那么这个函数就是同步的。也就是说同步方法调用一旦开始,调用者必须等到该函数调用返回后,才能继续后续的行为。下面这段代码首先会弹出 alert 框,如果你不点击 确定
按钮,所有的页面交互都被锁死,并且后续的 console
语句不会被打印出来。
alert("furmen");
console.log("is");
console.log("the");
console.log("best");
异步
如果在函数返回的时候,调用者还不能够得到预期结果,而是需要在将来通过一定的手段得到,那么这个函数就是异步的。比如说发一个网络请求,我们告诉主程序等到接收到数据后再通知我,然后我们就可以去做其他的事情了。当异步完成后,会通知到我们,但是此时可能程序正在做其他的事情,所以即使异步完成了也需要在一旁等待,等到程序空闲下来才有时间去看哪些异步已经完成了,再去执行。
这也就是定时器并不能精确在指定时间后输出回调函数结果的原因。
setTimeout(() => {
console.log('furmen');
}, 1000);
for (let i = 0; i < 100000000; i += 1) {
// todo
}
堆、栈、任务队列
堆(heap)
堆是基于树抽象数据类型的一种特殊的数据结构。是利用完全二叉树维护的一组数据,堆分为两种,一种为最大堆,一种为最小堆,将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。 堆是线性数据结构,相当于一维数组,有唯一后继。
栈 (stack)
栈是遵循后进先出 (LIFO) 原则的有序集合,新添加或待删除的元素都保存在同一端,称为栈顶,另一端叫做栈底。在栈里,新元素都靠近栈顶,旧元素都接近栈底。栈在编程语言的编译器和内存中存储基本数据类型和对象的指针、方法调用等。
栈被称为是一种后入先出(LIFO last-in-first-out)的数据结构。由于栈具有后入先出的特点,所以任何不在栈顶的元素都无法访问,为了得到栈顶的元素,必须先拿掉上面的元素。 在这里,为方便理解,通过类比乒乓球盒子来分析栈的存放方式。
这种乒乓球的存放方式与栈中存取数据的方式如出一辙。 处于盒子中最顶层的乒乓球 5,它一定是最后被放进去,但可以最先被使用。 而我们想要使用底层的乒乓球 1,就必须将上面的 4 个乒乓球取出来,让乒乓球 1 处于盒子顶层。 这就是栈空间先进后出,后进先出的特点。
队列 (queue)
队列是遵循先进先出 (FIFO) 原则的有序集合,队列在尾部添加新元素,并在顶部移除元素,最新添加的元素必须排在队列的末尾。
特殊之处在于它只允许在表的前端(front
)进行删除操作,而在表的后端(rear
)进行插入操作,和栈一样,队列是一种操作受限制的线性表。 进行插入操作的端称为队尾,进行删除操作的端称为队头。 队列中没有元素时,称为空队列。
队列的数据元素又称为队列元素。在队列中插入一个队列元素称为入队,从队列中删除一个队列元素称为出队。因为队列只允许在一端插入,在另一端删除,所以只有最早进入队列的元素才能最先从队列中删除,故队列又称为先进先出(FIFO—first in first out
)。
如上图所示,JavaScript 中的内存分为 堆内存
和 栈内存
,
JavaScript 中引用类型值的大小是不固定的,因此它们会被存储到 堆内存
中,由系统自动分配存储空间。JavaScript 不允许直接访问堆内存中的位置,因此我们不能直接操作对象的堆内存空间,而是操作 对象的引用
。
而 JavaScript 中的基础数据类型都有固定的大小,因此它们被存储到 栈内存
中。我们可以直接操作保存在栈内存空间的值,因此基础数据类型都是 按值访问
。此外,栈内存还会存储 对象的引用 (指针)
以及 函数执行时的运行空间
。
执行栈
当我们调用一个方法的时候,JavaScript 会生成一个与这个方法对应的执行环境,又叫执行上下文(context)。这个执行环境中保存着该方法的私有作用域、上层作用域(作用域链)、方法的参数,以及这个作用域中定义的变量和 this 的指向,而当一系列方法被依次调用的时候。由于 JavaScript 是单线程的,这些方法就会按顺序被排列在一个单独的地方,这个地方就是所谓执行栈。
任务队列
事件队列是一个存储着 异步任务
的队列,其中的任务严格按照时间先后顺序执行,排在队头的任务将会率先执行,而排在队尾的任务会最后执行。事件队列每次仅执行一个任务,在该任务执行完毕之后,再执行下一个任务。执行栈则是一个类似于函数调用栈的运行容器,当执行栈为空时,JS 引擎便检查事件队列,如果事件队列不为空的话,事件队列便将第一个任务压入执行栈中运行。
事件循环(Event Loop)
我们注意到,在异步代码完成后仍有可能要在一旁等待,因为此时程序可能在做其他的事情,等到程序空闲下来才有时间去看哪些异步已经完成了。所以 JavaScript 有一套机制去处理同步和异步操作,那就是事件循环 (Event Loop)。
下面就是事件循环的示意图
用文字描述的话,大致是这样的:
- 所有同步任务都在主线程上执行,形成一个执行栈 (Execution Context Stack)。
- 而异步任务会被放置到 Task Table,也就是上图中的异步处理模块,当异步任务有了运行结果,就将该函数移入任务队列。
- 一旦执行栈中的所有同步任务执行完毕,引擎就会读取任务队列,然后将任务队列中的第一个任务压入执行栈中运行。
主线程不断重复第三步,也就是 只要主线程空了,就会去读取任务队列
,该过程不断重复,这就是所谓的 事件循环
。
宏任务和微任务
在JavaScript
中,任务被分为两种,一种宏任务(MacroTask
)也叫Task
,一种叫微任务(MicroTask
)。
宏任务
script(整体代码)、setTimeout、setInterval、I/O、事件、postMessage、 MessageChannel、setImmediate (Node.js)
微任务
Promise.then、 MutaionObserver、process.nextTick (Node.js)
宏任务和微任务的区别
这个就像去银行办业务一样,先要取号进行排号。 一般上边都会印着类似:“您的号码为 XX,前边还有 XX 人。”之类的字样。
因为柜员同时职能处理一个来办理业务的客户,这时每一个来办理业务的人就可以认为是银行柜员的一个宏任务来存在的,当柜员处理完当前客户的问题以后,选择接待下一位,广播报号,也就是下一个宏任务的开始。 所以多个宏任务合在一起就可以认为说有一个任务队列在这,里边是当前银行中所有排号的客户。 任务队列中的都是已经完成的异步操作,而不是说注册一个异步任务就会被放在这个任务队列中,就像在银行中排号,如果叫到你的时候你不在,那么你当前的号牌就作废了,柜员会选择直接跳过进行下一个客户的业务处理,等你回来以后还需要重新取号
而且一个宏任务在执行的过程中,是可以添加一些微任务的,就像在柜台办理业务,你前边的一位老大爷可能在存款,在存款这个业务办理完以后,柜员会问老大爷还有没有其他需要办理的业务,这时老大爷想了一下:“最近 P2P 爆雷有点儿多,是不是要选择稳一些的理财呢”,然后告诉柜员说,要办一些理财的业务,这时候柜员肯定不能告诉老大爷说:“您再上后边取个号去,重新排队”。 所以本来快轮到你来办理业务,会因为老大爷临时添加的“理财业务”而往后推。 也许老大爷在办完理财以后还想 再办一个信用卡?或者 再买点儿纪念币? 无论是什么需求,只要是柜员能够帮她办理的,都会在处理你的业务之前来做这些事情,这些都可以认为是微任务。
这就说明:~你大爷永远是你大爷~ 在当前的微任务没有执行完成时,是不会执行下一个宏任务的。
所以就有了那个经常在面试题、各种博客中的代码片段:
setTimeout((_) => console.log(4));
new Promise((resolve) => {
resolve();
console.log(1);
}).then((_) => {
console.log(3);
});
console.log(2);
setTimeout
就是作为宏任务来存在的,而Promise.then
则是具有代表性的微任务,上述代码的执行顺序就是按照序号来输出的。
所有会进入的异步都是指的事件回调中的那部分代码 也就是说new Promise
在实例化的过程中所执行的代码都是同步进行的,而then
中注册的回调才是异步执行的。 在同步代码执行完成后才回去检查是否有异步任务完成,并执行对应的回调,而微任务又会在宏任务之前执行。 所以就得到了上述的输出结论1、2、3、4
。
+部分表示同步执行的代码
+setTimeout(_ => {
- console.log(4)
+})
+new Promise(resolve => {
+ resolve()
+ console.log(1)
+}).then(_ => {
- console.log(3)
+})
+console.log(2)
本来setTimeout
已经先设置了定时器(相当于取号),然后在当前进程中又添加了一些Promise
的处理(临时添加业务)。
所以进阶的,即便我们继续在Promise
中实例化Promise
,其输出依然会早于setTimeout
的宏任务。
setTimeout((_) => console.log(4));
new Promise((resolve) => {
resolve();
console.log(1);
}).then((_) => {
console.log(3);
Promise.resolve()
.then((_) => {
console.log("before timeout");
})
.then((_) => {
Promise.resolve().then((_) => {
console.log("also before timeout");
});
});
});
console.log(2);
当然了,实际情况下很少会有简单的这么调用Promise
的,一般都会在里边有其他的异步操作,比如fetch
、fs.readFile
之类的操作。 而这些其实就相当于注册了一个宏任务,而非是微任务。
来做几道题
看看下面这道题你能不能做出来。
setTimeout(() => {
console.log("A");
}, 0);
var obj = {
func: function () {
setTimeout(function () {
console.log("B");
}, 0);
return new Promise(function (resolve) {
console.log("C");
resolve();
});
},
};
obj.func().then(function () {
console.log("D");
});
console.log("E");
第一个 setTimeout
放到宏任务队列,此时宏任务队列为 [‘A’]
接着执行 obj 的 func 方法,将 setTimeout
放到宏任务队列,此时宏任务队列为 [‘A’, ‘B’]
函数返回一个 Promise,因为这是一个同步操作,所以先打印出 'C'
接着将 then
放到微任务队列,此时微任务队列为 [‘D’]
接着执行同步任务 console.log('E');
,打印出 'E'
因为微任务优先执行,所以先输出 'D'
最后依次输出 'A'
和 'B'
再来看一道阮一峰老师出的题目,其实也不难。
let p = new Promise((resolve) => {
resolve(1);
Promise.resolve().then(() => console.log(2));
console.log(4);
}).then((t) => console.log(t));
console.log(3);
首先将 Promise.resolve()
的 then() 方法放到微任务队列,此时微任务队列为 [‘2’]
然后打印出同步任务 4
接着将 p
的 then() 方法放到微任务队列,此时微任务队列为 [‘2’, ‘1’]
打印出同步任务 3
最后依次打印微任务 2
和 1
当 Event Loop 遇到 async/await
我们知道,async/await 仅仅是生成器的语法糖,所以不要怕,只要把它转换成 Promise 的形式即可。下面这段代码是 async/await 函数的经典形式。
async function foo() {
// await 前面的代码
await bar(); // await 后面的代码
}
async function bar() {
// do something...
}
foo();
其中 await 前面的代码
是同步的,调用此函数时会直接执行;而 await bar();
这句可以被转换成 Promise.resolve(bar())
;await 后面的代码
则会被放到 Promise 的 then() 方法里。因此上面的代码可以被转换成如下形式:
function foo() {
// await 前面的代码
Promise.resolve(bar()).then(() => {
// await 后面的代码
});
}
function bar() {
// do something...
}
foo();
这样是不是就很清晰了?
回到开篇的那道面试题,我们"重构"一下代码,再做解析,是不是很轻松了?
function async1() {
console.log("async1 start"); // 2
Promise.resolve(async2()).then(() => {
console.log("async1 end"); // 6
});
}
function async2() {
console.log("async2"); // 3
}
console.log("script start"); // 1
setTimeout(function () {
console.log("settimeout"); // 8
}, 0);
async1();
new Promise(function (resolve) {
console.log("promise1"); // 4
resolve();
}).then(function () {
console.log("promise2"); // 7
});
console.log("script end"); // 5
首先打印出 script start
接着将 settimeout
添加到宏任务队列,此时宏任务队列为 ['settimeout']
然后执行函数 async1
,先打印出 async1 start
,又因为 Promise.resolve(async2())
是同步任务,所以打印出 async2
,接着将 async1 end
添加到微任务队列,此时微任务队列为 [‘async1 end’]
接着打印出 promise1
,将 promise2
添加到微任务队列,此时微任务队列为 ['async1 end', promise2]
打印出 script end
因为微任务优先级高于宏任务,所以先依次打印出 async1 end
和 promise2
最后打印出宏任务 settimeout
在 Node 中的表现
Node 也是单线程,但是在处理Event Loop
上与浏览器稍微有些不同。
就单从 API 层面上来理解,Node 新增了两个方法可以用来使用:微任务的process.nextTick
以及宏任务的setImmediate
。
setImmediate 与 setTimeout 的区别
在官方文档中的定义,setImmediate
为一次Event Loop
执行完毕后调用。 setTimeout
则是通过计算一个延迟时间后进行执行。
但是同时还提到了如果在主进程中直接执行这两个操作,很难保证哪个会先触发。 因为如果主进程中先注册了两个任务,然后执行的代码耗时超过XXs
,而这时定时器已经处于可执行回调的状态了。 所以会先执行定时器,而执行完定时器以后才是结束了一次Event Loop
,这时才会执行setImmediate
。
setTimeout((_) => console.log("setTimeout"));
setImmediate((_) => console.log("setImmediate"));
有兴趣的可以自己试验一下,执行多次真的会得到不同的结果。
但是如果后续添加一些代码以后,就可以保证setTimeout
一定会在setImmediate
之前触发了:
setTimeout((_) => console.log("setTimeout"));
setImmediate((_) => console.log("setImmediate"));
let countdown = 1e9;
while (countdown--) {} // 我们确保这个循环的执行速度会超过定时器的倒计时,导致这轮循环没有结束时,setTimeout已经可以执行回调了,所以会先执行`setTimeout`再结束这一轮循环,也就是说开始执行`setImmediate`
如果在另一个宏任务中,必然是setImmediate
先执行:
require("fs").readFile(__dirname, (_) => {
setTimeout((_) => console.log("timeout"));
setImmediate((_) => console.log("immediate"));
});
// 如果使用一个设置了延迟的setTimeout也可以实现相同的效果
process.nextTick
就像上边说的,这个可以认为是一个类似于Promise
和MutationObserver
的微任务实现,在代码执行的过程中可以随时插入nextTick
,并且会保证在下一个宏任务开始之前所执行。
在使用方面的一个最常见的例子就是一些事件绑定类的操作:
class Lib extends require("events").EventEmitter {
constructor() {
super();
this.emit("init");
}
}
const lib = new Lib();
lib.on("init", (_) => {
// 这里将永远不会执行
console.log("init!");
});
因为上述的代码在实例化Lib
对象时是同步执行的,在实例化完成以后就立马发送了init
事件。 而这时在外层的主程序还没有开始执行到lib.on('init')
监听事件的这一步。 所以会导致发送事件时没有回调,回调注册后事件不会再次发送。
我们可以很轻松的使用process.nextTick
来解决这个问题:
class Lib extends require("events").EventEmitter {
constructor() {
super();
process.nextTick((_) => {
this.emit("init");
}); // 同理使用其他的微任务 // 比如Promise.resolve().then(_ => this.emit('init')) // 也可以实现相同的效果
}
}
这样会在主进程的代码执行完毕后,程序空闲时触发Event Loop
流程查找有没有微任务,然后再发送init
事件。