深入理解setTimeout async promise执行顺序

下面是今日头条的一道前端面试题:

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,依次执行下去直至所有任务执行结束。

可能有的同学看到这里云里雾里,下面举例说明:

  1. setTimeout:
console.log('第一行')
setTimeout(() => {
    console.log('第三行')
});
console.log('第五行')
// 输出顺序第一行->第五行->第三行

1.1. 运行打印第一行
1.2. 遇到宏任务setTimeout,把回调函数加入宏任务队列
1.3. 向下执行打印第五行


在第三步执行完成时

1.4. 同步执行完毕,没有微任务,去宏任务读取任务队列,取出setTimeout回调函数,执行打印第三行


执行宏任务
  1. 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

执行图
  1. 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 函数体

  1. 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

  1. console.log("script start"); // 同步代码1这句代码毫无疑问是同步执行的 ;
  2. setTimeout()是异步任务,加入异步队列,不执行;
  3. 然后调用async1(),执行这个方法体内的同步函数,打印console.log("async1 start"); // 同步代码2
  4. 向下执行,遇到await关键字,调用async2(),执行同步代码打印console.log("async2"); // 同步代码3,让出线程。await是让出当前函数线程,交给函数外的代码执行;
  5. 线程跳出async1(),向下执行Promise(),执行里面的同步代码打印promise1resolve是异步函数,加入异步队列,此时继续执行同步函数,回到await关键字处,执行剩余代码;
  6. async2()是异步方法,默认返回promise,所以把返回的promise加入异步队列;
  7. 此时没有同步任务,就去执行异步任务,因为setTimeout()的优先级低于promise,所以会优先执行promise队列。
  8. 此时异步队列任务顺序: setTimeout()-new Promise().resolve()-async2().resolve(),setTimeout优先级低,所以先执行下一个,打印console.log("promise end");
  9. 继续执行异步任务,async2()执行完毕,同步await,这时候同步向下执行console.log("async1 end")
  10. 最后执行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

  1. console.log("script start"); // 同步代码1这句代码毫无疑问是同步执行的 ;
  2. setTimeout()是异步任务,加入宏任务队列,不执行;
  3. 然后调用async1(),执行这个方法体内的同步函数,打印console.log("async1 start"); // 同步代码2
  4. 向下执行,遇到await关键字,调用async2(),执行同步代码打印console.log("async2"); // 同步代码3,让出线程。await是让出当前函数线程,交给函数外的代码执行;
  5. 线程跳出async1(),向下执行Promise(),执行里面的同步代码打印promise1resolve是异步函数,加入微任务队列,此时继续执行同步函数,回到await关键字处,执行剩余代码;
  6. 此时没有同步任务,就去执行微任务队列任务,所以会优先执行promise队列。
  7. 此时微队列任务顺序:new Promise().resolve()- console.log("async1 end");,s所以先执行下一个,打印 console.log("promise end")`;
  8. 这时候同步向下执行console.log("async1 end")
  9. 最后执行宏任务setTimeout()。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容