笔者最近忙着做项目之类的,文章输出遗落下了一段时间,这次我们就来聊一个面试中一个比较重要的知识点 —— Event Loop
可能有人会奇怪一个 EventLoop 还能写出什么,且听我慢慢来逼叨,看完这篇文章带你搞定 Event Loop 以及它相关的一些知识点。
在开始说 Event Loop 之前,我们先来认识一下它到底是个什么东西。
In computer science, the event loop is a programming construct or design pattern that waits for and dispatches events or messages in a program. The event loop works by making a request to some internal or external "event provider" (that generally blocks the request until an event has arrived), then calls the relevant event handler ("dispatches the event"). The event loop is also sometimes referred to as the message dispatcher, message loop, message pump, or run loop.
上面这段是Wikipedia[2]对 Event Loop 的解释,简单的来说就是Event Loop是一个程序结构,用于等待和分派消息和事件
我个人的理解是 JS 中的 Event Loop 是浏览器或 Node 的一种协调 JavaScript 单线程运行时不会阻塞的一种机制。
可能有人会比较疑惑前端为什么要学看起来比较底层的 Event Loop,不仅仅是因为这是一道面试的常考题。
上文我说了 Event Loop 是单线程阻塞问题的一种解决机制,所以在正式开始前还是要先从进程和线程的角度来聊一聊。众所周知的一件事是,JavaScript 是一个单线程机制的语言,那我们先来看看进程和线程的定义:
说实话,光从定义来看你根本感受不到进程和线程到底是什么样的一个东西。简单来说,进程简单理解就是我们平常使用的程序,如 QQ,浏览器,网盘等。进程拥有自己独立的内存空间地址,拥有一个或多个线程,而线程就是对进程粒度的进一步划分。
更通俗的来说,进程就像是一家工厂,多个工厂之间是独立存在的。而线程就像是工厂中的那些工人,共享资源,完成同一个大目标。
很多人都知道的是,JavaScript 是一门动态的解释型的语言,具有跨平台性。在被问到 JavaScript 为什么是一门单线程的语言,有的人可能会这么回答:“语言特性决定了 JavaScript 是一个单线程语言,JavaScript 天生是一个单线程语言”,这只不过是一层糖衣罢了。
JavaScript 从诞生起就是单线程,原因大概是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说,这就太复杂了。
准确的来说,我认为 JavaScript 的单线程是指 JavaScript 引擎是单线程的,JavaScript 的引擎并不是独立运行的,跨平台意味着 JavaScript 依赖其运行的宿主环境 --- 浏览器(大部分情况下是浏览器)。
浏览器需要渲染 DOM,JavaScript 可以修改 DOM 结构,JavaScript 执行时,浏览器 DOM 渲染停止。如果 JavaScript 引擎线程不是单线程的,那么可以同时执行多段 JavaScript,如果这多段 JavaScript 都操作 DOM,那么就会出现 DOM 冲突。
举个例子来说,在同一时刻执行两个 script 对同一个 DOM 元素进行操作,一个修改 DOM,一个删除 DOM,那这样话浏览器就会懵逼了,它就不知道到底该听谁的,会有资源竞争,这也是 JavaScript 单线程的原因之一。
之前说过,JavaScript 运行的宿主环境浏览器是多线程的。
以 Chrome 来说,我们可以通过 Chrome 的任务管理器来看看。
Chrome任务管理器
当你打开一个 Tab 页面的时候,就创建了一个进程。如果从一个页面打开了另一个页面,打开的页面和当前的页面属于同一站点的话,那么这个页面会复用父页面的渲染进程。
这里没看懂没关系,后面我会再说。
看到这里,总算是进入正题了,先讲讲浏览器端的 Event Loop 是什么样的。
JS运行机制图
上图是一张 JS 的运行机制图,Js 运行时大致会分为几个部分:
说到这里,Event Loop 也可以理解为:不断地从任务队列中取出任务执行的一个过程。
上文已经说过了 JavaScript 是一门单线程的语言,一次只能执行一个任务,如果所有的任务都是同步任务,那么程序可能因为等待会出现假死状态,这对于一个用户体验很强的语言来说是非常不友好的。
比如说向服务端请求资源,你不可能一直不停的循环判断有没有拿到数据,就好像你点了个外卖,点完之后就开始一直打电话问外卖有没有送到,外卖小哥都会抄着锅铲来打你(狗头)。因此,在 JavaScript 中任务有了同步任务和异步任务,异步任务通过注册回调函数,等到数据来了就通知主程序。
简单的介绍一下同步任务和异步任务的概念。
从概念就可以看出来,异步任务从一定程度上来看比同步任务更高效一些,核心是提高了用户体验。
Event Loop 很好的调度了任务的运行,宏任务和微任务也知道了,现在我们就来看看它的调度运行机制。
JavaScript 的代码执行时,主线程会从上到下一步步的执行代码,同步任务会被依次加入执行栈中先执行,异步任务会在拿到结果的时候将注册的回调函数放入任务队列,当执行栈中的没有任务在执行的时候,引擎会从任务队列中读取任务压入执行栈(Call Stack)中处理执行。
现在就有一个问题了,任务队列是一个消息队列,先进先出,那就是说,后来的事件都是被加在队尾等到前面的事件执行完了才会被执行。如果在执行的过程中突然有重要的数据需要获取,或是说有事件突然需要处理一下,按照队列的先进先出顺序这些是无法得到及时处理的。这个时候就催生了宏任务和微任务,微任务使得一些异步任务得到及时的处理。
曾经看到的一个例子很好,宏任务和微任务形象的来说就是:你去营业厅办一个业务会有一个排队号码,当叫到你的号码的时候你去窗口办充值业务(宏任务执行),在你办理充值的时候你又想改个套餐(微任务),这个时候工作人员会直接帮你办,不可能让你重新排队。
所以上文说过的异步任务又分为宏任务和微任务,JS 运行时任务队列会分为宏任务队列和微任务队列,分别对应宏任务和微任务。
先介绍一下(浏览器环境的)宏任务和微任务大致有哪些:
总的来说就是:同步任务/宏任务 -> 执行产生的所有微任务(包括微任务产生的微任务) -> 同步任务/宏任务 -> 执行产生的所有微任务(包括微任务产生的微任务) -> 循环......
注意:微任务队列
光说不练假把式,现在就来看一个例子:
举个栗子
放图的原因是为了让大家在看解析之前可以先自己按照运行顺序走一遍,写好答案之后再来看解析。 解析: (用绿色的表示同步任务和宏任务,红色表示微任务)
+ console.log('script start')
+ setTimeout(function() {
+ console.log('setTimeout')
+ }, 0)
+ new Promise((resolve, reject)=>{
+ console.log("promise1")
+ resolve()
+ })
- .then(()=>{
- console.log("then11")
+ new Promise((resolve, reject)=>{
+ console.log("promise2")
+ resolve();
+ })
- .then(() => {
- console.log("then2-1")
- })
- .then(() => {
- console.log("then2-2")
- })
- })
- .then(()=>{
- console.log("then12")
- })
+ console.log('script end')
script start
promise1
,然后 resolvescript end
,当前执行栈清空then11
promise2
,然后 resolvethen2-1
,触发 promise2 的第二个 then,注册到微任务队列[then12, then2-2]then12
then2-2
setTimeout
经过以上一番缜(xia)密(gao)分析,希望没有绕晕你,最后的输出结果就是:
script start -> promise1 -> script end -> then11 -> promise2 -> then2-1 -> then12 -> then2-2 -> setTimeout
不知道大家看了宏任务和微任务之后会不会有一个疑惑,宏任务和微任务都是异步任务,微任务之前说过了是为了及时解决一些必要事件而产生的。
什么是宏任务?什么是微任务?怎么区分宏任务和微任务?
不能只是默许接受这个概念,在这里,我根据我的个人理解进行一番说(hu)明(che) 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')
})
.then(function() {
console.log('promise2')
})
console.log('script end')
按照之前的分析方法去分析之后就会得出一个结果:
script start => async2 end => Promise => script end => promise1 => promise2 => async1 end => setTimeout
可以看出 async1 函数获取执行权是作为微任务的队尾,但是,在 Chrome73(金丝雀) 版本之后,async 的执行优化了,它会在 promise1 和 promise2 的输出之前执行。笔者大概了解了一下应该是用 PromiseResolve 对 await 进行了优化,减少了 Promise 的再次创建,有兴趣的小伙伴可以看看 Chrome 的源码。
Node 中也有宏任务和微任务,与浏览器中的事件循环类似。Node 与浏览器事件循环不同,其中有多个宏任务队列,而浏览器是只有一个宏任务队列。
Node 的架构底层是有 libuv,它是 Node 自身的动力来源之一,通过它可以去调用一些底层操作,Node 中的 Event Loop 功能就是在 libuv 中封装实现的。
Node 中的宏任务和微任务在浏览器端的 JS 相比增加了一些,这里只列出浏览器端没有的:
Node 的事件循环分成了六个阶段,每个阶段对应一个宏任务队列,相当于是宏任务进行了一个分类。
执行的轮循顺序 --- 每个阶段都要等对应的宏任务队列执行完毕才会进入到下一个阶段的宏任务队列
每两个阶段之间执行微任务队列
这里要注意的是,nextTick 事件是一个单独的队列,它的优先级会高于微任务,所以在当前宏任务/同步任务执行完成之后,会先执行 nextTick 队列中的所有任务,再去执行微任务队列中的所有任务。
在这里要单独说一下 setTimeout 和 setImmediate,setTimeout 定时器很熟悉,那就说说 setImmediate
setImmediate() 方法用于把一些需要长时间运行的操作放在一个回调函数里,并在浏览器完成其他操作(如事件和显示更新)后立即运行回调函数。从定义来看就是为了防止一些耗时长的操作阻塞后面的操作,这也是为什么 check 阶段运行顺序排的比较后。
我们来看这样的一个例子:
setTimeout(() => {
console.log('setTimeout')
}, 0)
setImmediate(() => {
console.log('setImmediate')
})
这里涉及 timers 阶段和 check 阶段,按照上面的运行顺序来说,timers 阶段是在第一个执行的,会早于 check 阶段。运行这段程序可以看到如下的结果:
可是再多运行几次,你就会看到如下的结果:
setImmediate 的输出跑到 setTimeout 前面去了,这时候就是:小朋友你是否有很多的问号❓
我们来分析一下原因,timers 阶段确实是在 check 阶段之前,但是在 timers 阶段时候,这里的 setTimeout 真的到了执行的时间吗?
这里就要先看看 setTiemout(fn, 0)
,这个语句的意思不是指不延迟的执行,而是指在可以执行 setTimeout 的时候就立即执行它的回调,也就是处理完当前事件的时候立即执行回调。
在 Node 中 setTimeout 第二个时间参数的最小值是 1ms,小于 1ms 会被初始化为 1(浏览器中最小值是 4ms),所以在这里 setTimeout(fn, 0) === setTimeout(fn, 1)
setTimeout 的回调函数在 timers 阶段执行,setImmediate 的回调函数在 check 阶段执行,Event Loop 的开始会先检查 timers 阶段,但是在代码开始运行之前到 timers 阶段(代码的启动、运行)会消耗一定的时间,所以会出现两种情况:
最开始就说了,一个优秀的程序员要让自己的代码按照自己想要的顺序运行,下面我们就来控制一下 setTimeout 和 setImediate 的运行。
const start = Date.now()
while (Date.now() - start < 10)
setTimeout(() => {
console.log('setTimeout')
}, 0)
setImmediate(() => {
console.log('setImmediate')
})
const fs = require('fs')
fs.readFile(__dirname, () => {
setTimeout(() => {
console.log('setTimeout')
}, 0)
setImmediate(() => {
console.log('setImmediate')
})
})
timers 阶段的执行有所变化
setTimeout(() => console.log('timeout1'))
setTimeout(() => {
console.log('timeout2')
Promise.resolve().then(() => console.log('promise resolve'))
})
timer1 -> timer2 -> promise resolve
timer2 执行完之后 timer2 还没到任务队列中,顺序为 timer1 -> promise resolve -> timer2
timeout1 -> timeout2 -> promise resolve
一旦执行某个阶段里的一个宏任务之后就立刻执行微任务队列,这和浏览器端运行是一致的。Node 和端浏览器端有什么不同
看到这里,你应该对浏览器端和 Node 端的 Event Loop 有了一定的了解,那就留一个题目。
不直接放代码是想让大家先自己思考然后在敲代码运行一遍~
本文到这里算是结束了,还是那句话,做一个程序员要知其然更要知其所以然。我写些文章也是想把知识输出,检验自己是不是真的学懂了。文章中可能还存在一些没有说清楚的地方或者是有错的地方,欢迎直接指出~
我把我的学习记录都记录在了我的 github 并且会持续的更新下去,有兴趣的小伙伴可以看看~ github[4]
[1]
博客链接: https://tearill.github.io/
[2]
Wikipedia: https://en.wikipedia.org/wiki/Event_loop
[3]
JS 大会: https://www.bilibili.com/video/BV1bE411B7ez?t=478
[4]
github: https://github.com/tearill/Reading_Record
● 手写async await的最简实现(20行搞定)面试必考!● 1.5万字概括ES6全部特性● 最简实现Promise,支持异步链式调用(20行)
·END·