先看一道题目,如下:
//求打印顺序,答案在最后
async function async1() {
console.log('async1 start');
await async2();
console.log('asnyc1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(() => {
console.log('setTimeOut');
}, 0);
async1();
new Promise(function (reslove) {
console.log('promise1');
reslove();
}).then(function () {
console.log('promise2');
})
console.log('script end');
前言:JS为何是单线程?
在讨论什么是宏任务和微任务之前,我们先来了解一下JS。JS是一种单线程语言,JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。即使H5提出了web worker标准,它有很多限制,受主线程控制,是主线程的子线程。
简单的说就是:只有一条通道,那么在任务多的情况下,就会出现拥挤的情况,这种情况下就产生了 “多线程” ,但是这种“多线程”是通过单线程模仿的,也就是假的。那么就产生了同步任务和异步任务。
非阻塞:通过
Event Loop
实现。
一、同步任务和异步任务
单线程,就是指一次只能完成一件任务,如果在同个时间有多个任务的话,这些任务就需要进行排队,前一个任务执行完,才会执行下一个任务。但如果有一个任务的执行时间很长,比如文件的读取或者数据的请求等等,那么后面的任务就要一直等待,这就会影响用户的使用体验。
为了解决这种情况,Javascript语言将任务的执行模式分成两种:同步(Synchronous)和异步(Asynchronous)。
同步模式:就是前一个任务执行完成后,再执行下一个任务,程序的执行顺序与任务的排列顺序是一致的、同步的;
异步模式:则完全不同,每一个任务有一个或多个回调函数(callback),前一个任务结束后,不是执行队列上的后一个任务,而是执行回调函数;后一个任务则是不等前一个任务结束(执行回调函数)而执行,所以程序的执行顺序与任务的排列顺序是不一致的、异步的。
JavaScript中实现异步编程模式的4种方法:回调函数(callback)、事件监听(Listener)、观察者模式(发布/订阅)、Promises对象。
二、事件循环(Event Loop)
同步任务和异步任务分别进入不同的执行”场所”;同步任务进入主线程,异步任务进入Event Table
并注册回调函数。
当异步事件完成时,Event Table
会将这个回调函数移入任务队列(task quene
),等待主线程的任务执行完毕;
当栈中的代码执行完毕,执行栈(call stack
)中的任务为空时,就会读取任务队列(task quene
)中的任务,去执行对应的回调;
如此循环,就形成js的事件循环机制(Event Loop
)。
执行过程:
- 执行script中代码,有一些是同步任务,有一些是异步任务,所有同步任务都在主线程上执行,形成一个执行栈(
execution context stack
)。 - 遇异步任务,进入
Event Table
中(可以理解为宏任务放进宏队列中,微任务放入微队列中)并注册回调函数;等到指定的事件完成(如ajax请求响应返回, setTimeout延迟到指定时间)时,Event Table
会将这个回调函数移入Event Queue
。 - 当栈中的代码执行完毕,执行栈(
call stack
)中的任务为空时,主线程会先检查micro-task
(微任务)队列中是否有任务。如果有,就将micro-task(微任务)
队列中的所有任务依次执行,直到micro-task
(微任务)队列为空; - 接着再检查
macro-task
(宏任务)队列中是否有任务,如果有,则取出第一个macro-task
(宏任务)加入到执行栈中,之后再清空执行栈,检查micro-task
(微任务)。 - 以此循环,直到全部任务执行完毕。
三、事件表格(Event Table)
Event Table
可以理解成一张事件->回调函数 对应表。
用来存储 JS 中的异步事件 (request, setTimeout, IO等) 及其对应的回调函数的列表。
指定的事件完成(如ajax请求响应返回,setTimeout延迟到指定时间)时,Event Table
会将这个回调函数移入Event Queue
,即macro-task
(宏任务)队列 或 micro-task
(微任务)队列。
四、宏任务和微任务概念
ES6 规范中,
microtask
称为jobs
,macrotask
称为tasks
宏任务是由宿主发起的,而微任务由JavaScript自身发起。
在ES3以及以前的版本中,JavaScript本身没有发起异步请求的能力,也就没有微任务的存在。在ES5之后,JavaScript引入了Promise
,这样,不需要浏览器,JavaScript引擎自身也能够发起异步任务了。
宏任务(macrotask) | 微任务(microtask) | |
---|---|---|
谁发起的 | 宿主(Node、浏览器) | JS引擎 |
具体事件 | 1. script (可以理解为外层同步代码) 2. setTimeout/setInterval 3. UI rendering/UI事件 4. postMessage,MessageChannel 5. setImmediate,I/O(Node.js) |
1. Promise 2. MutaionObserver(html5新特性) 3. Object.observe(已废弃;Proxy 对象替代) 4. process.nextTick(Node.js) 5.Async/Await(实际就是promise) |
谁先运行 | 后运行 | 先运行 |
会触发新一轮Tick吗 | 会 | 不会 |
1.宏任务
宏任务:macrotask
,也叫task
。 一些异步任务的回调会依次进入macro task queue
,等待后续被调用。
浏览器为了能够使得JS内部任务与DOM任务能够有序的执行,会在一个任务执行结束后,在下一个任务执行开始前,对页面进行重新渲染。
2.微任务
微任务:microtask
,也叫jobs
。 另一些异步任务的回调会依次进入micro task queue
,等待后续被调用。
通常来说就是需要在当前同步任务执行结束后立即执行的任务,比如对一系列动作做出反馈,或者是需要异步的执行任务而又不需要分配一个新的任务,这样便可以减小一点性能的开销。
3.执行顺序
宏任务->微任务->宏任务->微任务···
1、先执行主线程
2、遇到宏队列(macrotask)放到宏队列(macrotask)
3、遇到微队列(microtask)放到微队列(microtask)
4、主线程执行完毕
5、执行微队列(microtask),微队列(microtask)执行完毕
6、执行一次宏队列(macrotask)中的一个任务,执行完毕
7、执行微队列(microtask),执行完毕
8、依次循环
注意:
setTimeout
并不是直接的把你的回掉函数放进上述的异步队列中去,而是在定时器的时间到了之后,把回掉函数放到执行异步队列中去。如果此时这个队列已经有很多任务了,那就排在他们的后面。这也就解释了为什么setTimeout
为什么不能精准的执行的问题了。
setTimeout
执行需要满足两个条件:
- 主线程必须是空闲的状态,如果到时间了,主线程不空闲也不会执行你的回调函数
- 这个回调函数需要等到插入异步队列时前面的异步函数都执行完了,才会执行
4.promise、async/await
new Promise()
是同步的任务,会被放到主线程中去立即执行。而.then()
函数是异步任务会放到异步队列中去。
那什么时候放到异步队列中去呢?当你的promise
状态结束的时候,就会立即放进异步队列中去了。
带async
关键字的函数会返回一个promise
对象,如果里面没有await
,执行起来等同于普通函数,并没有什么效果。
await
关键字要在 async
关键字函数的内部,await
写在外面会报错;await
如同他的语意,就是在等待,等待右侧的表达式完成。此时的await
会让出线程,阻塞async
内后续的代码,先去执行async
外的代码。等外面的同步代码执行完毕,才会执行里面的后续代码。就算await
的不是promise
对象,是一个同步函数,也会等这样操作。
我们再来看看上面代码执行的流程 :
async function async1() {
console.log('async1 start');
await async2();
console.log('asnyc1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(() => {
console.log('setTimeOut');
}, 0);
async1();
new Promise(function (reslove) {
console.log('promise1');
reslove();
}).then(function () {
console.log('promise2');
})
console.log('script end');
输出结果为:
script start
async1 start
async2
promise1
script end
asnyc1 end
promise2
setTimeOut
其输出过程:
- 整个代码片段(script)作为一个宏任务执行
console.log('script start')
,输出script start
; - 执行
setTimeout
,是一个异步任务,放入宏任务异步队列中; - 执行
async1()
,输出async1 start
,继续向下执行; - 执行
async2()
,输出async2
,并返回了一个promise
对象,await
让出了线程,因为在await
下面的代码需要等待await
右边的代码执行完,所以把await
下面的代码加入了微任务异步队列; - 执行
new Promise()
,输出promise1
,然后将resolve()
放入微任务异步队列; - 执行
console.log('script end')
,输出script end
; - 到此同步的代码就都执行完成了,然后去微任务异步队列里去获取任务
- 接下来执行
await async2()
下面的代码,输出了async1 end
; - 然后执行
resolve(new Promise的)
,输出了promise2
; - 最后执行
setTimeout
,输出了settimeout
。
在第4步中, await
这里有一个机制, 就是 await
的等待, 不会阻塞外部函数的执行, 而 await
等待的 如果是一个 new Promise()
则 new Promise()
里面的代码还是同步执行, 如果不是 new Promise()
,就会使用 Promise.resolve
来进行封装。
这里的 async2
是一个 async
方法, 里面的打印会同步执行, 而 await async2()
后面的代码会放到微任务队列中根据队列先进先出的原则进行依次触发。
五、NodeJS中的Event Loop事件循环
Node中的Event Loop
是基于libuv实现的,而libuv是 Node 的新跨平台抽象层,libuv使用异步,事件驱动的编程方式,核心是提供i/o的事件循环和异步回调。libuv的API包含有时间,非阻塞的网络,异步文件操作,子进程等等。 Node的事件循环存在几个阶段。
如果是node10及其之前版本,microtask
会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行 microtask
队列中的任务。
node版本更新到11之后,Event Loop
运行原理发生了变化,一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate)就立刻执行微任务队列,跟浏览器趋于一致。下面例子中的代码是按照最新的去进行分析的。
- timers: 执行
setTimeout
和setInterval
中到期的callback
。 - pending callback: 上一轮循环中少数的
callback
会放在这一阶段执行。 - idle, prepare: 仅在内部使用。
- poll: 最重要的阶段,执行
pending callback
,在适当的情况下回阻塞在这个阶段。 - check: 执行
setImmediate
(setImmediate()
是将事件插入到事件队列尾部,主线程和事件队列的函数执行完成之后立即执行setImmediate
指定的回调函数)的callback。 - close callbacks: 执行close事件的
callback
,例如socket.on('close'[,fn])
或者http.server.on('close, fn)
。
1.和浏览器环境有何不同
浏览器环境下,microtask
的任务队列是每个 macrotask
执行完之后执行。而在 Node.js 中,microtask
会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行 microtask
队列的任务。
2.总结:
- 浏览器和 Node 环境下,
microtask
任务队列的执行时机不同 - Node 端,
microtask
在事件循环的各个阶段之间执行 - 浏览器端,
microtask
在事件循环的macrotask
执行完之后执行