抠一抠 事件循环机制 💗

542 阅读6分钟

前言

拆解实现 Promise 及其周边 | 8月更文挑战 文中大量聊到关于宏任务和微任务的知识点,其实这和事件循环机制息息相关。本文也将和大家一起来抠一抠事件循环机制的细节。

单线程语言

JavaScript是单线程语言,这点众所周知。那为啥JavaScript是单线程语言,从根本上改为多线程不好么?

阮一峰前辈文中提到原因,搬运一下:JavaScript从诞生起就是单线程。原因大概是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说,这就太复杂了。后来就约定俗成,JavaScript为一种单线程语言。简单来讲个场景:如果两个线程同时操作一个DOM,一个修改,一个删除,那以哪个为基准?为了避免这种场景,所以JS是单线程的。

H5提出的Web worker的标准,允许JavaScript创建多个线程,但子线程完全受主线程控制,所以,JavaScript本身依旧是单线程。

JavaScript单线程语言给我们造成了哪些问题呢?
举个单线程处理任务的🌰:

 let hello = 'hello'
 let world = 'world'
 console.log(hello + ',' + world)

上述代码,JS引擎编译完成之后,会把所有的任务代码放入主线程。等主线程开始执行,这些任务会按照顺序从上而下依次执行,至打印出hello,world后,主线程会自动退出。一切都很美好~

但现实是复杂且残酷的🤦‍♀️,不可能一直按部就班。如果单个任务执行时间过长导致后续任务阻塞,该怎么处理?

执行栈和任务队列

单线程就意味着,所有的任务需要排队。若前个任务执行时间过长,后一个任务就不得不一直等待,如IO线程(Ajax请求数据),不得不等待结果出来,再往下执行。但这个等待是没有必要的,我们可以挂起等待中的任务,继续执行后续的任务。因此,任务可分为两种:一种是同步任务;一种是异步任务
同步任务:均在主线程上执行,用执行栈管理同步任务的进行。
异步任务:异步操作完成,先进入任务队列,等主线程执行栈空了,就去读取任务队列中的异步任务。

function helloWorld() {
  console.log('inner function')
  setTimeout(function() {
    console.log('execute setTimeout')
  })
}
helloWorld()
console.log('outer function')

通过Loupe工具分析上述代码是否如我们所说的一样。

ezgif.com-gif-maker (2).gif

  1. helloWorld函数先进入执行栈,开始执行helloWorld函数内的代码。
  2. console.log('函数内')进入执行栈,打印函数内
  3. 执行setTimeout,属于定时任务,需要延迟等待,所以先挂起,后将匿名函数入队且继续执行主线程上的其余代码。
  4. console.log('函数外')进入执行栈,打印函数外
  5. 主线程代码执行完毕,读取任务队列里的里的匿名函数,执行打印execute setTimeout

代码执行顺序与先前结论完美的契合~

事件循环

之所以称事件循环,是因为主线程从任务队列读取事件是循环不断的。为了更好地理解Event Loop转引自Philip Roberts的演讲 《Help, I'm stuck in an event-loop》

4044824590-4941197dd7d918f2_fix732.png

上图所示,主线程运行,会产生堆和栈,栈中的代码调用WebAPIs,当满足触发条件后,会将指定的回调函数或事件进行入队。当栈中代码执行完毕,就会循环读取任务队列里的事件,如此往复。

从图中还可以获取一个信息点:任务队列中的任务类型不仅只有一种,它包含了如输入事件(鼠标滚动、点击)、微任务、文件读写、WebSocket、定时器等等。其中如输入事件、文件读写、WebSocket都属于异步请求,等待I/O设备完成即可。而定时器是如何指定代码在规定时间之后进行?微任务又是什么?

定时器

定时器主要由setTimeoutsetInterval两个函数,两者类似,区别在执行次数,前者一次性执行,后者则反复执行。以setTimeout为例,基本用法如下。

function helloWorld() {
  console.log('hello world')
}
let timer = setTimeout(helloWorld, 1000)

很简单,上述代码将通过setTimeout在1000ms后输出hello world
不知道你有没有疑问?上文提到,推入任务队列中的任务都是按顺序读取执行,那么定时器的回调函数是如何保证在指定时间内被调用?
翻阅资料,发现Chromium中有关于设计延迟队列的概念,而延迟队列中的任务都是根据发起时间和延迟时间计算是否到期。若任务到期,则会先执行完成到期任务,再进行下一次循环。
使用定时器,还有一些注意事项💢
若主线程任务执行时间过长,会影响定时器任务的执行。

function helloWorld() {
  console.log('hello world')
}
function main() {
  setTimeout(helloWorld, 0)
  for(let i = 0; i < 5000; i++) {
      console.log(i)
  }
}
main()

如上代码,setTimeout函数虽设置了一个0延时的回调函数,但回调需在执行5000次循环后才可调用。查看Performance面板执行helloWorld将近延迟了400ms,如下图所示。 image.png

如果定时器存在嵌套调用,系统会设置最短时间间隔为4ms

function helloWorld() { setTimeout(helloWorld, 0)}
setTimeout(helloWorld, 0)

Chrome中,定时器被嵌套调用5次以上,会判定当前方法阻塞,如果时间间隔小于4ms,会将每次间隔时间设置为4ms。如下图所示。
image.png

未激活页面,定时器执行最小间隔为1000ms
若标签页不是当前的激活标签,定时器最小时间间隔为1000ms,目的也是为了优化厚爱加载损耗及降低耗电量。

延迟页面时间最大值
ChromeSafariFirefox都是32bit存储延时值,所以最大只能存储2^31 - 1 = 2147483647(ms)31是因为二进制最高位是符号位,-1是因为有0的存在。

宏任务与微任务

了解微任务,那宏任务也得弄明白不是~。如下表,为宏任务与微任务相关技术。

宏任务微任务
setTimeoutMutationObserver(html5)
setIntervalprocess.nextTick(node)
I/O、事件Promise.then/catch/finally
setImmediate(node)queueMicrotask
script(整体代码块)
requestAnimationFrame
postMessage,MessageChannel

那宏任务与微任务在什么时候执行呢?

宏任务:新的任务添加到任务队列的尾部,当循环系统执行该任务的时候执行回调函数。
微任务:当前宏任务执行结束之前执行回调函数。

执行时机可以看出:每个宏任务都关联一个微任务队列
执行顺序可以得出:先执行宏任务,然后执行当前宏任务下的微任务,若微任务产生新的微任务,则继续执行微任务,微任务执行完毕后,再继续下一轮宏任务的事件循环。

实践是检验真理的唯一标准,举个Promise的🌰

console.log('start')

setTimeout(function() {  // 宏任务
  console.log('setTimeout')
}, 0)

let p = new Promise((resolve, reject) => {
   console.log('初始化Promise')
   resolve()
}).then(function() {
   console.log('内部Promise1') // 微任务
}).then(function() {
   console.log('内部Promise2') // 微任务
})

p.then(function() {
  console.log('外部Promise1') // 微任务
})
console.log('end')

image.png

  1. script是宏任务,开始执行代码,打印start
  2. 遇到setTimeout宏任务,入任务队列,等待下一次事件循环。
  3. 遇到Promise立即执行,打印初始化Promise
  4. 遇到new Promise().then微任务,入script宏任务的微任务队列,等待当前宏任务完成。
  5. 遇到p.then微任务,入script宏任务的微任务队列,等待当前宏任务完成。
  6. 打印end,当前script宏任务执行完成。
  7. 查看当前script宏任务的微任务队列,队列不为空,取出当前队首new Promise().then,执行打印内部Promise1,再次碰到then微任务,则继续执行打印内部Promise2,执行完毕,出队。
  8. script宏任务下的微任务队列不为空,继续取出p.then,执行打印外部Promise1,出队。
  9. script宏任务下的微任务队列空了,开始执行下一个宏任务。
  10. 执行宏任务setTimeout打印setTimeout。检查任务队列已空,程序结束。

参考

JavaScript中的Event Loop(事件循环)机制
什么是Event Loop
JavaScript 运行机制详解:再谈Event Loop

小工具

视频转GIF