下面是今日头条的一道前端面试题:
async function async1() {
console.log("async1 start");
await async2();
console.log("async1 end");
}
async function async2() {
console.log("async2");
}
console.log("script start");
setTimeout(function() {
// setTimeout放入event-loop中的macro-tasks队列,暂不执行
console.log("setTimeout");
}, 0);
async1();
new Promise(function(resolve) {
console.log("promise1");
resolve();
}).then(function() {
console.log("promise end");
});
console.log("script end");
运行结果:
script start
async1 start
async2
promise1
script end
promise end
async1 end
setTimeout
这里涉及到Microtasks、Macrotasks、event loop 以及 JS 的异步运行机制。
一、 单线程模型
单线程模型指的是,JavaScript 只在一个线程上运行。也就是说,JavaScript 同时只能执行一个任务,其他任务都必须在后面排队等待。
注意,JavaScript 只在一个线程上运行,不代表 JavaScript 引擎只有一个线程。事实上,JavaScript 引擎有多个线程,单个脚本只能在一个线程上运行(称为主线程),其他线程都是在后台配合。
二、同步任务和异步任务
程序里面所有的任务,可以分成两类:同步任务(synchronous)和异步任务(asynchronous)。
同步任务是那些没有被引擎挂起、在主线程上排队执行的任务。只有前一个任务执行完毕,才能执行后一个任务。
异步任务是那些被引擎放在一边,不进入主线程、而进入任务队列的任务。只有引擎认为某个异步任务可以执行了(比如 Ajax 操作从服务器得到了结果),该任务(采用回调函数的形式)才会进入主线程执行。排在异步任务后面的代码,不用等待异步任务结束会马上运行,也就是说,异步任务不具有“堵塞”效应。
举例来说,Ajax 操作可以当作同步任务处理,也可以当作异步任务处理,由开发者决定。如果是同步任务,主线程就等着 Ajax 操作返回结果,再往下执行;如果是异步任务,主线程在发出 Ajax 请求以后,就直接往下执行,等到 Ajax 操作有了结果,主线程再执行对应的回调函数。
三、任务队列和事件循环
JavaScript 运行时,除了一个正在运行的主线程,引擎还提供一个任务队列(task queue),里面是各种需要当前程序处理的异步任务。(实际上,根据异步任务的类型,存在多个任务队列。为了方便理解,这里假设只存在一个队列。)
首先,主线程会去执行所有的同步任务。等到同步任务全部执行完,就会去看任务队列里面的异步任务。如果满足条件,那么异步任务就重新进入主线程开始执行,这时它就变成同步任务了。等到执行完,下一个异步任务再进入主线程开始执行。一旦任务队列清空,程序就结束执行。
异步任务的写法通常是回调函数。一旦异步任务重新进入主线程,就会执行对应的回调函数。如果一个异步任务没有回调函数,就不会进入任务队列,也就是说,不会重新进入主线程,因为没有用回调函数指定下一步的操作。
JavaScript 引擎怎么知道异步任务有没有结果,能不能进入主线程呢?答案就是引擎在不停地检查,一遍又一遍,只要同步任务执行完了,引擎就会去检查那些挂起来的异步任务,是不是可以进入主线程了。这种循环检查的机制,就叫做事件循环(Event Loop)。维基百科的定义是:“事件循环是一个程序结构,用于等待和发送消息和事件(a programming construct that waits for and dispatches events or messages in a program)”。
四、Microtasks(微任务)、Macrotasks(宏任务)
在高层次上,JavaScript 中有 microtasks 和 macrotasks(task),它们是异步任务的一种类型,Microtasks的优先级要高于macrotasks,microtasks 用于处理 I/O 和计时器等事件,每次执行一个。microtask 为 async/await 和 Promise 实现延迟执行,并在每个 task 结束时执行。在每一个事件循环之前,microtask 队列总是被清空(执行)。
其中宏任务包括:
- script(整体代码)
- setTimeout
- setImmediate
- setInterval
- I/O
- UI 渲染
ajax请求不属于宏任务,js线程遇到ajax请求,会将请求交给对应的http线程处理,一旦请求返回结果,就会将对应的回调放入宏任务队列,等请求完成执行。
微任务包括:
- process.nextTick
- Promise
- Object.observe(已废弃)
- MutationObserver(html5新特性)
执行过程
上面第三条说了JS 主线程拥有一个 执行栈(同步任务) 和 一个 任务队列(microtasks queue),主线程会依次执行代码:
- 当遇到函数(同步)时,会先将函数入栈,函数运行结束后再将该函数出栈;
- 当遇到 task 任务(异步)时,这些 task 会返回一个值,让主线程不在此阻塞,使主线程继续执行下去,而真正的 task 任务将交给 浏览器内核 执行,浏览器内核执行结束后,会将该任务事先定义好的回调函数加入相应的任务队列(microtasks queue/ macrotasks queue)中。
- 当JS主线程清空执行栈之后,会按先入先出的顺序读取microtasks queue中的回调函数,并将该函数入栈,继续运行执行栈,直到清空执行栈,再去读取任务队列。
- 当microtasks queue中的任务执行完成后,会提取 macrotask queue 的一个任务加入 microtask queue, 接着继续执行microtask queue,依次执行下去直至所有任务执行结束。
可能有的同学看到这里云里雾里,下面举例说明:
- setTimeout:
console.log('第一行')
setTimeout(() => {
console.log('第三行')
});
console.log('第五行')
// 输出顺序第一行->第五行->第三行
1.1. 运行打印第一行
1.2. 遇到宏任务setTimeout,把回调函数加入宏任务队列
1.3. 向下执行打印第五行
1.4. 同步执行完毕,没有微任务,去宏任务读取任务队列,取出setTimeout回调函数,执行打印第三行
- Promise:
console.log("第一行");
let promise = new Promise(function(resolve) {
console.log("before resolve");
resolve();
console.log("after resolve");
}).then(function() {
console.log("promise.then");
});
console.log("script end");
// 输出顺序: 第一行->promise1->before resolve->after resolve->script end->promise.then
2.1. 运行打印第一行
2.2. promise构造函数是同步的,执行console.log("before resolve");
2.3. resolve()是异步的,.then回调放入微任务队列,向下执行,
2.4. 打印after resolve
2.5. 继续执行打印script end
2.6. 取出微任务,打印promise.then
- async await:
async function async1(){
console.log('async1 start');
await async2();
console.log('async1 end')
}
async function async2(){
console.log('async2')
}
console.log('script start');
async1();
console.log('script end')
// 输出顺序:script start->async1 start->async2->script end->async1 end
async 函数返回一个 Promise 对象,当函数执行的时候,一旦遇到 await 就会先返回,等到触发的异步操作完成,再执行函数体内后面的语句。可以理解为,是让出了线程,跳出了 async 函数体
- setTimeout+Promise
console.log("start");
setTimeout(function() {
console.log("timeout");
}, 0);
new Promise(function(resolve) {
console.log("promise");
resolve();
}).then(function() {
console.log("promise resolved");
});
console.log("end");
// 执行顺序start->promise->end->promise resolved->timeout
4.1. 输出start
4.2. setTimeout回调函数放入宏任务
4.3. 输出promise
4.4. resolve()异步,回调函数放入微任务
4.5. 输出end
4.6. 执行微任务
4.7. 输出promise resolved
4.8. 执行宏任务
4.9. 输出timeout
第一步,执行同步代码:
async function async1() {
console.log("async1 start"); // 同步代码2
await async2(); // 调用async2(),async2()的返回值是promise,不执行promise的resolve,让出线程
console.log("async1 end");
}
async function async2() {
console.log("async2"); // 同步代码3
}
console.log("script start"); // 同步代码1
setTimeout(function() {
// 异步 setTimeout放入event-loop中的macro-tasks队列,暂不执行
console.log("setTimeout");
}, 0);
async1();
new Promise(function(resolve) {
console.log("promise1"); // 同步代码4
resolve();
}).then(function() {
console.log("promise end"); // 不执行
});
console.log("script end"); // 同步代码5
-
console.log("script start"); // 同步代码1
这句代码毫无疑问是同步执行的 ; -
setTimeout()
是异步任务,加入异步队列,不执行; - 然后调用
async1()
,执行这个方法体内的同步函数,打印console.log("async1 start"); // 同步代码2
; - 向下执行,遇到
await
关键字,调用async2()
,执行同步代码打印console.log("async2"); // 同步代码3
,让出线程。await是让出当前函数线程,交给函数外的代码执行; - 线程跳出
async1()
,向下执行Promise()
,执行里面的同步代码打印promise1
,resolve
是异步函数,加入异步队列,此时继续执行同步函数,回到await关键字处,执行剩余代码; -
async2()
是异步方法,默认返回promise,所以把返回的promise加入异步队列; - 此时没有同步任务,就去执行异步任务,因为setTimeout()的优先级低于promise,所以会优先执行promise队列。
- 此时异步队列任务顺序:
setTimeout()
-new Promise().resolve()
-async2().resolve()
,setTimeout优先级低,所以先执行下一个,打印console.log("promise end")
; - 继续执行异步任务,async2()执行完毕,同步await,这时候同步向下执行
console.log("async1 end")
; -
最后执行setTimeout()。
回到最初的面试题:
async function async1() {
console.log("async1 start"); // 同步代码2
await async2(); // 调用async2(),async2()的返回值是promise,不执行promise的resolve,让出线程
console.log("async1 end");
}
async function async2() {
console.log("async2"); // 同步代码3
}
console.log("script start"); // 同步代码1
setTimeout(function() {
// 异步 setTimeout放入event-loop中的macro-tasks队列,暂不执行
console.log("setTimeout");
}, 0);
async1();
new Promise(function(resolve) {
console.log("promise1"); // 同步代码4
resolve();
}).then(function() {
console.log("promise end"); // 不执行
});
console.log("script end"); // 同步代码5
-
console.log("script start"); // 同步代码1
这句代码毫无疑问是同步执行的 ; -
setTimeout()
是异步任务,加入宏任务队列,不执行; - 然后调用
async1()
,执行这个方法体内的同步函数,打印console.log("async1 start"); // 同步代码2
; - 向下执行,遇到
await
关键字,调用async2()
,执行同步代码打印console.log("async2"); // 同步代码3
,让出线程。await是让出当前函数线程,交给函数外的代码执行; - 线程跳出
async1()
,向下执行Promise()
,执行里面的同步代码打印promise1
,resolve
是异步函数,加入微任务队列,此时继续执行同步函数,回到await关键字处,执行剩余代码; - 此时没有同步任务,就去执行微任务队列任务,所以会优先执行promise队列。
- 此时微队列任务顺序:
new Promise().resolve()
- console.log("async1 end");,s所以先执行下一个,打印
console.log("promise end")`; - 这时候同步向下执行
console.log("async1 end")
; - 最后执行宏任务setTimeout()。