JS事件循环

先看一个简单的问题

setTimeout(()=>{
   console.log(1) 
},0)

Promise.resolve().then(()=>{
   console.log(2) 
})

console.log(0) 
复制代码

这个问题是一个对JS事件循环的最基本考察,包括对宏任务、微任务的理解。
执行结果是:

0
2
1
复制代码

如果对于这类问题你还是一知半解,那么你需要了解一下什么是JS事件循环(event loop)

Event loop

image.png
我们知道JS是单线程、非阻塞的

  • 这里的单线程并不是指整个JS事件循环是单线程(事实上事件循环中有多个线程参与),而是JS引擎执行线程是单线程(在chrome里就是V8所在的线程),也就是上图中左侧部分。该线程只负责解析JS代码并处理JS执行栈中的代码块。
  • 非阻塞指的是JS引擎执行线程不会被I/O阻塞(网络请求、文件读取等)

这得益于JS使用的事件循环机制,也就是Event Loop。

setTimeout函数

先来看一下setTimeout这个最常用的异步函数是如何在Event Loop中运作的

setTimeout(() => 
  console.log(1)
}, 0)

console.log(0)
复制代码
  • 「JS引擎线程」运行JS执行栈中执行如上代码,调用了浏览器的web api:setTimeout函数,如图中「1」

在上图中可以看到,setTimeout这类异步接口实际上不在JS引擎中,而是由浏览器中的Web(图中的V8是chrome中的JS引擎,safari、firefox则是各自的引擎,参考《主流浏览器内核及JS引擎》)

  • 然后「JS引擎线程」执行了console.log(0),控制台中输出了0
  • 浏览器中的「定时器线程」接管该定时任务,当定时任务超时后,将回调函数即console.log(1)塞入到宏任务队列中,等待调度,如图中「2」
  • 「event loop线程」检查JS执行栈是否为空,如果是,则将宏任务队列中的任务塞入JS执行栈中,如图中「8」
  • 「JS引擎线程」处理执行栈中的console.log(1)代码,控制台中输出1

从这个流程可以看出,无论超时设置的多短,setTimeout中的回调函数必须等到下一次事件循环才能被处理,这就不难理解为什么0会在1之前输出了

promise.then函数

Promise.resolve().then(()=>{
   console.log(2) 
})

console.log(0)
复制代码
  • 「JS引擎线程」运行JS执行栈中执行如上代码,由于Promise首先执行了resolve函数变为终结状态,因此立即执行promise.then方法,此方法实际上是创建了一个微任务,并将console.log(2)塞入微任务队列中,如图「5」所示。
  • 然后「JS引擎线程」执行了console.log(0),控制台中输出了0
  • 「event loop线程」检查JS执行栈是否为空,如果是,则将微任务队列中的任务塞入JS执行栈中,如图中「8」
  • 「JS引擎线程」处理执行栈中的console.log(2)代码,控制台中输出2

宏任务与微任务

再回头看看文章开始的那个问题,只是在上述场景的基础上混合了宏任务与微任务

setTimeout(()=>{
   console.log(1) 
},0)

Promise.resolve().then(()=>{
   console.log(2) 
})

console.log(0) 
复制代码
  • setTimeout和promise.then的回调函数依次被塞入宏任务队列和微任务队列
  • console.log(0)执行完后JS执行栈被清空
  • Event Loop线程检测到JS执行栈为空,由于微任务队列比宏任务队列的优先级高,微任务被优先放入JS执行栈中处理
  • 微任务处理完后,再处理宏任务队列中的任务
  • 所以输出结果是0, 2, 1

任务分类

JS事件循环中的任务分为两大类

  • 宏任务,即task
  • 微任务,即microTask,也称job

对于浏览器环境和Node环境,提供的接口会有些许差别

宏任务接口

接口 浏览器 Node
I/O操作
setTimeout
setInterval
setImmediate
requestAnimationFrame

微任务接口

接口 浏览器 Node
process.nextTick
promise.then/catch/finally
MutationObserver

任务优先级

  • 整体上,微任务的处理优先于宏任务。准确的说是微任务会在本此事件循环结束前处理,宏任务会在下次事件循环开始时处理。
  • 在Node环境中,process.nextTick产生的微任务优先级最高,会被插到微任务队列的最前端优先处理;而setImmediate产生的宏任务优先级最高,会在所有宏任务之前执行

Promise

Promise是一种异步解决方案,比以前的回调函数方式使用起来更直观和合理。

  • 一个Promise对象有三种状态:pending(挂起), fulfilled(成功), rejected(失败)
  • 当我们通过new Promise方法创建一个Promise对象时,这个对象处于pending状态。
  • 对于Promise.then方法,有如下几点:
    • 首先,为了保证Promise的链式调用,Promise规范定义then方法必须返回一个Promise对象
    • 其次,then方法中会将成功和时报回调函数用微任务包装
    • 最后,会根据当前Promise对象的状态来决定如何处理:
      • pending状态下,then方法会将包装后的成功和失败回调函数放入各自的队列中保存(暂且称之为成功回调队列,失败回调队列),这两个队列由resolve和reject方法来处理
      • fulfilled状态下,then方法会直接调用包装后的成功回调,即将成功回调函数放入微任务队列中,等待Event Loop调度
      • 同样,rejected状态下,then方法会直接调用包装后的失败回调,即将失败回调函数放入微任务队列中,等待Event Loop调度
  • 对于Promise的resolve和reject方法,以resolve为例,其会将成功回调队列中的所有任务一次执行,由于成功回调队列中的任务都被微任务包装过,执行他们即会将回调函数依次放入微任务队列中,等待Event Loop调度

来看下面的例子:

let promise = new Promise((resolve, reject) => {
  console.log(1)
  setTimeout(() => {
    resolve(2)
  }, 1000)
})

promise.then(data => {
  console.log(data)
})

console.log(0)
复制代码
  • 执行new Promise构造函数
    • 控制台打印 1
    • 创建一个定时任务,超时后会执行resolve方法
  • 执行promise.then,由于此时promise还处于pending状态,因此将console.log(data)函数用微任务包装后,塞入成功回调队列,伪代码如下:
// 此处是伪代码,实际包装比这个复杂
const successCb = () => {
  queueMicroTask((data) => {
  	console.log(data)
	})
}

successCbQueue.push(successCb)
复制代码
  • 执行控制台输出0
  • 1秒后定时器超时,将resolve(2)回调函数放入宏任务队列
  • Event Loop调度宏任务,并由JS执行线程执行了resolve(2),此操作会导致promise对象的状态改变,并开始处理成功回调队列中的任务
  • 处理回调任务队列,将(data) => console.log(data)放入微任务队列中
  • Event Loop调度微任务,执行(data) => console.log(data),此时的入参data是resolve(2)传入的参数2,因此此时控制台输出2
  • 打印顺序为 1, 0, 2

链式调用

通过上面的例子,Promise的基本执行顺序应该能清楚了,下面再看一个更复杂的问题,即当Promise.then链式调用时,代码是如何运行的。

问题来源于这片文章:《从一道让我失眠的 Promise 面试题开始,深入分析 Promise 实现细节

Promise.resolve().then(() => {
    console.log(0);
    return Promise.resolve(4);
}).then((res) => {
    console.log(res)
})

Promise.resolve().then(() => {
    console.log(1);
}).then(() => {
    console.log(2);
}).then(() => {
    console.log(3);
}).then(() => {
    console.log(5);
})
复制代码

在看这个复杂的问题前,先看两个关于Promise.resolve和Promise.then的简单例子

Promise.resolve()

Promise.resolve是一个静态方法,它实际上是创建了一个立即resolve的Promise对象,以下两段代码是等同的

Promise.resolve(0)

// 等同于
new Promise((resolve, reject) => {
  resolve(0)
})
复制代码

所以,

Promise.resolve(0).then(data => console.log(data))
复制代码

这段代码的执行过程:

  • 创建一个Promise对象,立即将其状态改变为fulfilled
  • 执行promise.then方法,由于此时的promise状态是fulfilled,会直接将其后的成功回调函数塞入微任务队列,等待调度

Promise.then()

我们知道,then方法会返回一个新的Promise对象,从而提供了链式调用的特性。
来看这个例子:

Promise.resolve(0).then(data => {
  console.log(data)
  return 1
}).then(data => {
  console.log(data)
})
复制代码

我们省略细节,简单描述这个事件循环的过程

  • 第一个then创建了一个微任务
  • 执行第一个微任务,控制台输出0,并且返回1,返回的1将作为下一次then的成功回调函数的入参
  • 第二个then创建一个新的微任务
  • 执行第二个微任务,控制台输出1

可以看到,当then返回一个普通对象时,会简单的将此对象作为入参交给下一次then调用。但是,如果当then返回一个Promise对象时会发生什么?

来看这段代码

Promise.resolve(0).then(data => {
  console.log(data)
  return Promise.resolve(1)
}).then(data => {
  console.log(data)
})
复制代码

在第一个then中,我们返回了一个fulfilled状态的Promise对象,如果还是按照普通对象的处理逻辑,直接把该Promise对象作为入参交给下一个then函数,那么上述代码的输出会变成

0
Promise {<fulfilled>: 1}
复制代码

显然,这不是我们预期的结果。我们希望的是将Promise.resolve(1)执行完毕后,将其resolve的结果(例子里就是1)交给下一个then来使用。
所以,实际当then方法中返回一个Promise对象时,会先执行这个Promise对象,然后再处理其后的then方法。
为了理解这个机制,可以简单的理解为,在两个then函数中,插入了一个then函数

// 简化理解代码执行顺序,实际处理过程中会多塞一次微任务队列,后面会谈到
Promise.resolve(0).then(data => {
  console.log(data)
}).then(data => {
	return 1
}).then(data => {
  console.log(data)
})
复制代码

例子中代码的执行结果是

0
1
复制代码

实际上,为了处理then方法中返回的Promise对象,机制要比上面的简化写法复杂。

  • V8首先创建了一个 NewPromiseResolveThenableJobTask类型的微任务,然后将此任务放入微任务队列中等待处理(微任务+1)
  • 当上面的微任务被调度,处理NewPromiseResolveThenableJobTask时,实际就是调用了Promise.then方法,此时又创建了一个微任务(微任务+1)
  • 所以,实际上需要两次微任务才能把这个Promise对象转变为fulfilled状态

详细源码分析请参考这篇文章:juejin.cn/post/695345…

综合分析

有了以上基础,我们再回到那个复杂的问题

// PromiseA
Promise.resolve().then(() => {
    console.log(0);
    return Promise.resolve(4);
}).then((res) => {
    console.log(res)
})

// PromiseB
Promise.resolve().then(() => {
    console.log(1);
}).then(() => {
    console.log(2);
}).then(() => {
    console.log(3);
}).then(() => {
    console.log(5);
})
复制代码

为了便于说明,将上述代码做一个等价的改写

// 1.创建一个状态为fulfilled的Promise
const PromiseA = Promise.resolve()

// 2.由于PromiseA的状态为fulfilled,其后then函数中的成功回调会直接放入微任务中等待处理,PromiseA1的状态为pending
const PromiseA1 = PromiseA.then(() => {
    console.log(0);
    return Promise.resolve(4);
})

// 3.由于PromiseA1的状态为pending,其后then函数会被放入成功回调队列中,等待PromiseA1的状态变为fulfilled时再处理
const PromiseA2 = PromiseA1.then((res) => {
    console.log(res)
})

// 4.创建一个状态为fulfilled的Promise
const PromiseB = Promise.resolve()

// 5.由于PromiseB的状态为fulfilled,其后then函数中的成功回调会直接放入微任务中等待处理,PromiseB1的状态为pending
const PromiseB1 = PromiseB.then(() => {
    console.log(1);
})

// 6.由于PromiseB1的状态为pending,其后then函数会被放入成功回调队列中,等待PromiseB1的状态变为fulfilled时再处理
const PromiseB2 = PromiseB1.then(() => {
    console.log(2);
})

// 7.同上,放入PromiseB2的成功回调队列中
const PromiseB3 = PromiseB2.then(() => {
    console.log(3);
})

// 8.同上,放入PromiseB3的成功回调队列中
const PromiseB4 = PromiseB3.then(() => {
    console.log(5);
})
复制代码

上述代码会先按顺序同步执行,可以看到,此时的微任务队列中只有第2步和第5步创建的两个微任务任务

console.log(0)
return Promise.resolve(4);

console.log(1)

这两个任务按顺序依次执行,产生的结果为

  • 控制台依次输出0, 1
  • 由于PromiseA的成功回调中返回了一个Promise对象,根据我们之前的讨论,此操作需要两轮微任务才能将此PromiseA1的状态改变为fulfilled,所以PromiseA2需要两轮后才能执行
  • PromiseB1状态变为fulfilled,其后的then方法的成功回调会继续放入微任务中处理

第二轮微任务队列状态

等待PromiseA1状态改变为fulfilled

console.log(2)

同理,第三轮微任务队列状态

继续等待PromiseA1状态改变为fulfilled

console.log(3)

第四轮中,PromiseA1的状态终于改变为fulfilled,其后的then方法的成功回调会可以放入微任务中处理

console.log(res) // res = 4

console.log(3)

第五轮

console.log(5)

所以,控制台的输出结果是

0
1
2
3
4
5
复制代码

async/await

async/await语法糖的出现,让JS代码书写和阅读起来更加直观,让我们能够使用同步的思维来书写异步逻辑。

相对于Promise的等价写法

async/await实际上是使用Genorator机制实现的语法糖,为了便于理解其执行顺序,对于这两个语法糖,可以简化理解为:

  • async:被async修饰的function,会自动返回一个Promise对象,比如有如下函数funcB
async function funcB() {
	console.log("I'm function B")
}

// 等同于
function funcB() {
  return new Promise((resolve, reject) => {
    console.log("I'm function B")
    resolve()
  })
}

// 可以看到,funcB会同步执行控制台输出I'm function B,然后返回一个状态为fulfilled的Promise对象
复制代码
  • await:在一个函数内,出现在await语句后的代码(不包含await这一行),会被包裹到一个then函数中,以下面的例子来说明:
async function funcB() {
	console.log("I'm function B")
}
  
async function funcA() {
  console.log("funcA start")
  await funcB()
  console.log("funcA end")
}

// 由上面的例子知道,funcB实际被包装成了一个Promise,那么上述代码可以简单的等价于
function funcA() {
  console.log("funcA start")
  new Promise((resolve, reject) => {
    console.log("I'm function B")
    resolve()
  }).then(() => {
    // 将await语句后的内容包裹在then函数中执行
    console.log("funcA end")
  })
}
复制代码

关于async的额外探讨

如果async func中本身就返回了一个Promise,那么是如何处理呢?

// 对于如下的funcB
async function funcB() {
	console.log("I'm function B")
  return Promise.resolve().then(() => {
    console.log('function B end')
  })
}

// 是等价于这种额外的Promise包装
new Promise((resolve, reject) => {
  console.log("I'm function B")
  return Promise.resolve().then(() => {
    console.log('function B end')
  }).then(() => {
    resolve()
  })
})

// 还是等价于直接返回原Promise
console.log("I'm function B")
return Promise.resolve().then(() => {
  console.log('function B end')
})

// 结果是第二种,直接返回原来的Promise,不再做额外的包装
复制代码

为了验证上面的说法,来看下面这个例子

async function funcA() {
  await funcB()
  await funcC()
  console.log(4)
}

async function funcB() {
	console.log(1)
}

async function funcC() {
  console.log(2)
	return Promise.resolve().then(() => {
		console.log(3)
  })
}

funcA()

Promise.resolve().then(() => {
  console.log(5)
}).then(() => {
  console.log(6)
}).then(() => {
  console.log(7)
}).then(() => {
  console.log(8)
})

// 1, 2, 5, 3, 6, 7, 4, 8
复制代码

值得注意的是,「4」会输出在「7」之后。看完下面的等价写法就能理解为何会如此

以上代码等价于

// 等价写法
new Promise((resolve,reject) => {
  console.log(1)
  resolve()
}).then(() => {
  console.log(2)
  return Promise.resolve().then(() => {
    console.log(3)
  })
}).then(() => {
  console.log(4) 
})


Promise.resolve().then(() => {
  console.log(5)
}).then(() => {
  console.log(6)
}).then(() => {
  console.log(7)
}).then(() => {
  console.log(8)
})

// 1, 2, 5, 3, 6, 7, 4, 8
复制代码

注意:这里涉及到了我们之前讨论的一个点,即then中返回Promise,会需要两次微任务处理才能完成状态转化,因此这里的「4」输出在了「7」后面。这也解释了async/await写法下,为何「4」会输出在「7」后面。

有了以上的分析,我们再来看看如下代码的执行顺序

async function funcA() {
  console.log("funcA start")
  await funcB()
  console.log("funcA end")
}

async function funcB() {
  console.log("funcB start")
	return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log("funcB end")
      resolve()
    }, 1000)
  })
}

console.log("script start")
funcA()
console.log("script end")
复制代码

上述代码等价于

console.log("script start")
console.log("funcA start")
console.log("funcB start")
new Promise((resolve, reject) => {
  setTimeout(() => {
    console.log("funcB end")
    resolve()
  }, 1000)
}).then(() => {
  console.log("funcA end")
})
console.log("script end")
复制代码
  • 首先控制台会依次输出「script start」、「funcA start」、「funcB start」
  • 执行setTimeout,等待超时
  • 将console.log(“funcA end”)挂入Promise的成功执行队
  • 输出「script end」
  • 定时器超时,插入宏任务,等待调度
  • Event Loop线程调度宏任务,输出「funcB end」,并改变Promise状态为fulfiiled,执行成功队列中的任务(微任务包装后的console.log(“funcA end”))
  • 将console.log(“funcA end”)放入微任务队列,等待调度
  • Event Loop线程调度微任务,输出「funcA end」
  • 因此,输出结果为「script start」、「funcA start」、「funcB start」、「script end」、「funcB end」、「funcA end」

我们再来点变化,在funcB中,不返回包含了定时器的Promise

async function funcA() {
  console.log("funcA start")
  await funcB()
  console.log("funcA end")
}

// 去掉了return
async function funcB() {
  console.log("funcB start")
  new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log("funcB end")
      resolve()
    }, 1000)
  })
}

console.log("script start")
funcA()
console.log("script end")
复制代码

输出结果变为了「script start」、「funcA start」、「funcB start」、「script end」、「funcA end」、「funcB end」
这里其实不难理解,由于funcB中去掉了return,其不需要等待定时器的Promise执行结束,因此,「funcA end」会在「funcB end」之前执行。为了更直观,同样做一个等价写法

console.log("script start")
console.log("funcA start")

// 由于funcB本身没有返回Promise,我们需要为其额外的包装一层Promise
new Promise((resolve, reject) => {
  console.log("funcB start")
  new Promise((resolveInner, rejectInner) => {
    setTimeout(() => {
      console.log("funcB end")
      resolveInner()
    }, 1000)
  })
  resolve()
}).then(() => {
  console.log("funcA end")
})

console.log("script end")
复制代码

以上,等价写法仅用于直观理解await/async的执行顺序,不等同与实现原理。

小结

  • 理解Event Loop的关键,
    • 首先知道Event Loop运行所涉及的几个主要模块有哪些:JS引擎执行线程、JS执行栈、Web/Nodejs Api、宏任务、微任务、Event Loop调度线程。
    • 其次要知道他们之间的关系,V8仅包含了JS引擎执行线程、JS执行栈、Promise接口,其余的部分由浏览器或node环境提供
  • JS代码的执行顺序,只需要理清哪些是宏任务,哪些是微任务。微任务在本次事件循环末尾执行,宏任务在下次事件循环开始执行,所以表象是微任务的会在宏任务之前执行。
  • 微任务中process.nextTick优先级高于其它微任务
  • 微任务没有递归限制,宏任务有栈溢出限制。所以微任务使用是要注意不要滥用递归,否则会导致无限循环。
  • setTimeout的超时时间准确的说是在至少在多长时间之后执行该回调,比如setTimeout(() => xxx, 1000)指的是至少1000ms后执行xxx操作。因为setTimeout的回调在宏任务中,如果本次事件循环中执行了大量的同步操作或微任务,会推迟该宏任务的调度。
  • Promise的执行需要理解resolve和then函数的行为
    • resolve会改变Promise对象的状态,并执行该Promise对象下所有的成功回调函数。
    • then会根据Promise对象的状态来包装成功/失败回调函数,此回调函数执行时会先进入微任务队列
    • then本身会返回一个新的Promise对象
    • 如果then的回调函数中返回了一个Promise对象,则需要执行两次微任务才能将then返回的Promise对象状态改变为fulfilled状态
  • async/await可以用Promise来等价改写,async将函数包装成一个Promise对象,await则是将其后的代码放入then中执行。

以下参考文章都可深度阅读了解

参考

MDN: 并发模型与时间循环

JS执行栈图解

Tasks, microtasks, queues and schedules(重点推荐,直观展示JS执行栈、微任务、宏任务的执行过程)

菲利普·罗伯茨在JS 2014 conf关于EventLoop的演讲

event loop可视化(菲利普·罗伯茨做的一个可视化工具,配合视频看)

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享