JS异步、事件循环与消息队列、微任务与宏任务

757 阅读6分钟

单线程和多线程

Javascript 是一门单线程、异步、非阻塞、解析类型脚本语言。

JavaScript 的设计就是为了处理浏览器网页的交互(DOM操作的处理、UI动画等),决定了它是一门单线程语言。如果有多个线程,它们同时在操作 DOM,那网页将会一团糟。

所以,JS处理任务是一件接着一件处理,从上往下执行的。

console.log('开始')
console.log('中间')
console.log('结束')

// 开始
// 中间
// 结束

但是,如果一个任务的处理耗时(或者是等待)很久的话,如:网络请求、定时器、等待鼠标点击等,后面的任务也就会被阻塞,也就是说会阻塞所有的用户交互(按钮、滚动条等),会带来极不友好的体验。比如:

console.log('开始')
console.log('中间')
setTimeout(() => {
  console.log('timer over')
}, 1000)
console.log('结束')

// 开始
// 中间
// 结束
// timer over

timer over会在 打印 结束 之后打印,也就是说计时器并没有阻塞后面的代码。

所以,其实JavaScript单线程指的是浏览器中负责解释和执行JavaScript代码的只有一个线程,即为JS引擎线程,但是浏览器的渲染进程是提供多个线程的,如下:

  • JS引擎线程
  • 事件触发线程
  • 定时触发器线程
  • 异步http请求线程
  • GUI渲染线程 综上:当遇到计时器、DOM事件监听或者网络请求的时候,JS引擎会将他们直接交给浏览器相对应的线程进行处理,而JS继续处理之后任务,这便实现了异步非阻塞。 其他线程处理完成之后会把对应的回调函数交给消息队列维护,JS引擎也会在适当的时候去取出任务并执行该任务。

事件循环与消息队列 (也叫任务队列)

上面说到,JS引擎也会在适当的时候去消息队列中取出任务并执行,这是如何解决的呢?这就要用到事件循环(event loop)机制

事件循环机制 和 消息队列 的维护都是由 事件触发线程 控制的。

JS引擎线程遇到异步(DOM事件监听、网络请求、setTimeout计时器等...),会交给相应的线程单独去维护异步任务,等待某个时机(计时器结束、网络请求成功、用户点击DOM),然后由事件触发线程将异步对应的 回调函数加入到消息队列中,消息队列中的回调函数等待被执行。

同时, JS引擎线程也会维护一个执行栈同步代码会依次加入执行栈中执行,结束便会退出执行栈。

如果执行栈里的任务执行完成,即 执行栈为空 的时候(即JS引擎线程空闲),事件触发线程才会从消息队列取出一个任务(即异步的回调函数)放入执行栈中执行。

1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
2. 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
3. 一但"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
4. 主线程不断重复上面的第三步。

只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制。这个过程会不断重复,这种机制就被称为事件循环(event loop)机制。

1.jpg (异步一般包含:网络请求、计时器、DOM事件监听)

宏任务与微任务

console.log('script start')

setTimeout(function() {
    console.log('timer over')
}, 0)

Promise.resolve().then(function() {
    console.log('promise1')
}).then(function() {
    console.log('promise2')
})

console.log('script end')

// script start
// script end
// promise1
// promise2
// timer over

问:"promise 1" "promise 2" 在 "timer over" 之前打印了?
答: 这里就涉及到了一个新的概念:macro-task(宏任务)micro-task(微任务)

macro-task(宏任务):

一个event loop有一个或者多个task队列。task任务源非常宽泛,比如ajax的onloadclick事件,基本上我们经常绑定的各种事件都是task任务源,还有数据库操作(IndexedDB ),需要注意的是setTimeout、setInterval、setImmediate也是task任务源。总结来说task任务源:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • requestAnimationFrame
  • UI rendering

micro-task(微任务):

microtask 队列和task 队列有些相似,都是先进先出的队列,由指定的任务源去提供任务,不同的是一个 event loop里只有一个microtask 队列。另外microtask执行时机和Macrotasks也有所差异,主要有:

  • process.nextTick
  • promises
  • Object.observe
  • MutationObserver (在node环境下,process.nextTick的优先级高于Promise,也就是说:在宏任务结束后会先执行微任务队列中的nextTickQueue,然后才会执行微任务中的Promise。)

两者的区别:

  • 宏队列可以有多个,微任务队列只有一个,所以每创建一个新的settimeout都是一个新的宏任务队列,执行完一个宏任务队列后,都会去checkpoint 微任务
  • 一个事件循环后,微任务队列执行完了,再执行宏任务队列
  • 一个事件循环中,在执行完一个宏队列之后,就会去check 微任务队列。

执行机制:

  • JS引擎线程执行一个主代码块(栈中没有就从事件队列中获取)
  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
  • 渲染完毕后,JS引擎线程继续,开始下一个宏任务(从宏任务队列中获取)

2.jpg

总结

一个页签就是一个进程,一个进程包含多个线程,所以在页面渲染的过程中会调用多个线程去进行处理。我们所说的JS单线程的特点,其实是指执行处理JS的线程只有一个,就是JS引擎线程。当一个页面渲染过程中会有一些事件处理、定时器、异步请求、GUI页面渲染啥的,这些JS都会将他们交给对应的线程去进行处理,对应的线程处理完成之后,会生成对应的回调函数,这些回调函数会暂时存放在消息队列中,当主线程(JS引擎线程)中的宏任务以及其产生的微任务都处理完成之后,即主线程为空时,就会去消息队列中依次拿回调函数进行处理。当一个回调函数处理完成之后,JS线程会再去消息队列中拿回调函数,如此往复,直到所有事情处理完成,这也叫事件循环机制(event loop)

参考:zhuanlan.zhihu.com/p/139967525