从一个题目看async的调用过程

935 阅读4分钟

题目

逛掘金发现一个题目,很有意思

//JS实现一个带并发限制的异步调度器Scheduler,保证同时运行的任务最多有两个。完善代码中Scheduler类,使得以下程序能正确输出
class Scheduler {
  add(promiseCreator) { ... }
  // ...
}

const timeout = (time) => new Promise(resolve => {
  setTimeout(resolve, time)
})

const scheduler = new Scheduler()
const addTask = (time, order) => {
  scheduler.add(() => timeout(time))
    .then(() => console.log(order))
}

addTask(1000, '1')
addTask(500, '2')
addTask(300, '3')
addTask(400, '4')
// output: 2 3 1 4

// 一开始,1、2两个任务进入队列
// 500ms时,2完成,输出2,任务3进队
// 800ms时,3完成,输出3,任务4进队
// 1000ms时,1完成,输出1
// 1200ms时,4完成,输出4

juejin.cn/post/684490…

解答

评论区大神createAny给了一个解法,之前对async函数的理解不够,没看懂,断点调了一下才搞清楚,加深了对async函数的理解

class Scheduler { 
    constructor() { 
        this.awaitArr = []; 
        this.count = 0; 
    } 
    async add(promiseCreator) { 
        this.count >= 2 ? await new Promise(resolve => this.awaitArr.push(resolve)) : ''; 
        this.count++; 
        const res = await promiseCreator(); 
        this.count--; 
        this.awaitArr.length && this.awaitArr.shift()(); 
        return res; 
    } 
}

分析

为了搞清楚程序的流程,我添加了一些打印信息,代码如下。

要不要试试手写一下打印结果?如果写的完全正确,说明你对async函数的流程真的掌握的很清楚了。

但是菜鸡如我,还是一步一步分析吧

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Document</title>
  </head>
  <body>
    <script>
      let i = 0;
      class Scheduler {
        constructor() {
          this.awaitArr = [];
          this.count = 0;
        }
        async add(promiseCreator) {
          console.log("start scheduler.add");
          let resultOfFirstAwait
          if (this.count >= 2) {
            console.log("this.count >= 2");
            resultOfFirstAwait = await new Promise(resolve => {
              this.awaitArr.push(() => {
                console.log("before resolve");
                resolve(console.log(`i = ${i}`) || i++);
                console.log("after resolve");
              });
            });
          }

          console.log(`resultOfFirstAwait = ${resultOfFirstAwait}`);

          this.count++;
          const res = await promiseCreator();

          console.log("=====================");
          console.log(performance.now());
          this.count--;

          this.awaitArr.length && this.awaitArr.shift()();
          console.log("before return scheduler.add");
          return res;
        }
      }

      const timeout = time =>  new Promise(resolve => setTimeout(() => resolve(time), time))
    
      const scheduler = new Scheduler();
      const addTask = (time, order) => {
        console.log("-------------------");
        console.log("start addTask");
        const p = scheduler.add(() => {
          console.log("calling promiseCreator");
          return timeout(time);
        });
        console.log("got promise from scheduler.add");
        p.then(res => {
          console.log(
            `res = ${res}, order = ${order}, now = ${performance.now()}`
          );
        });
        console.log("end addTask");
      };

      addTask(10000, "1");
      addTask(5000, "2");
      addTask(3000, "3");
      addTask(4000, "4");
    </script>
  </body>
</html>

流程分析

  • 62行
  • 开始执行addTask,打印47,48行
  • 开始执行scheduler.add,打印15行
  • this.count === 0,走到28行,此时resultOfFirstAwait === undefined,打印28行
  • 走到30行,this.count变为1
  • 走到31行,await promiseCreator()
  • 调用promiseCreator,打印50行
  • promiseCreator返回的是一个promise,这个promise是timeout函数的返回值,是一个10s之后将10给resolve的promise
  • 由于31行的await语句后面的promise当前处于pending状态,JS将继续执行同步代码
  • 这个时候来到了我开始没搞清楚的地方。下一句打印的是53行。虽然scheduler.add没有走到return,但是49行的p已经被赋值了一个promise。
    • 这个promise不是scheduler.add里await或return的任何promise,而是JS内部生成的一个promise。
    • async函数一旦遇到了第一个await或return就会退出,返回一个内部生成的promise
    • 这个promise会把async函数最后return的东西给resolve了。也就是说,第54行的res是async函数最后return的东西。如果async函数return的是一个promise,那么第54行的res是这个promise给resolve了的结果。
  • 下面走到54行,给通过scheduler.add这个async函数得到的promise添加一个.then,添加一个微任务。
  • 打印59行,第一个addTask函数执行完(addTask是一个同步函数)
  • 走到63行,执行第二个addTask
  • 类似上面的,依次打印 47, 48, 15, 28, 50, 53, 59行。第二个addTask函数执行完
  • 走到64行,执行第三个addTask
  • 打印47,48,15行
  • 这个时候this.count === 2,打印18行
  • 调用await后的函数,我们知道new其实是一个语法糖,是同步代码,所以会立即执行。this.awaitArr给push了第1个函数
  • 像上面说的那样,”async函数一旦遇到了第一个await或return就会退出,返回一个内部生成的promise“,所以代码走到49行,p现在是一个promise了
  • 打印53行
  • 给这个promise添加一个.then
  • 打印59行,第三个addTask执行完
  • 走到65行,执行第四个addTask
  • 类似的,依次打印47, 48, 15, 18, 53, 59行

上面是同步代码,也就是4个addTask函数的执行部分

下面执行异步代码。


5s之后......

  • 63行的addTask对应的promiseCreator被resolve了
  • 走到31行,res被赋值。res现在是什么呢?
    • 我们知道,await后面跟的是一个promise,res就是这个promise给resolve了的结果
    • 这个promise是什么呢?是promiseCreator的返回结果,也就是第43行的timeout的返回值,也就是43行new的那个promise
    • 这个promise给传进来的time给resolve了,所以res就是这个time,也就是5000
  • 打印33,34行
  • 走到35行,this.count === 1
  • 走到37行,this.awairArr的队首弹出队列,执行之。
  • 走到21行,打印21行
  • 走到22行,执行resolve函数。
    • 这个resolve函数是JS内置的函数
    • 首先调用 console.log(i = ${i})打印22行,因为console.log的返回值是undefined,所以resolve的是i,当前为0
    • 问题来了,有2个问题
      • 第一,这个时候已经调用resolve了,下面的23行还会打印吗?
      • 第二,假如23不执行,那么下一步打印什么?假如23行执行,那么执行23行以后打印什么?
    • 现在回答第一个问题:
      • 会打印23行。调用resolve以后,promise不会立即被resolve,而是继续执行剩下的同步代码
    • 回答第二个问题,23行代码执行晚一会,执行什么?
      • 也许你的想法是:promise被resolve了,那么第19行的await就应该有结果了,所以会给19行的resultOfFirstAwait赋值,所以打印28行
      • 然鹅真实情况是:打印38行
      • 为了叙述方便,第63行的addTask对应的调用栈称为A栈,第64行的addTask对应的调用栈称为B栈。可以看出,调用resolve函数虽然会立即把promise的状态置位resolved,但是因为当前A栈还有代码为执行,所以会继续执行A栈的代码,不会立即跳到B栈。
  • 38行执行完以后,下面打印什么呢?
    • 这个问题在node和浏览器环境下表现不一样。
    • node环境下,会先打印55行的代码,然后再打印28行
    • 浏览器环境下,会先打印28行代码,再打印55行
    • 我认为这是系统实现事件循环的方式不一样。node应该是把resolve以后的调用栈放到下一个任务队列的队首,而浏览器是放到当前任务队列的末尾
  • 所以在浏览器环境下,打印28行,B栈
  • 接下来调用promiseCreator,打印50行,B栈
  • 打印55行,A栈

3s之后...

类似上面的执行过程,

  • 64行的addTask对应的promiseCreator被resolve了
  • 打印33, 34, 21, 22, 23, 38, 28, 50, 55行

2s之后...

  • 61行的addTask对应的promiseCreator被resolve了
  • 打印33, 34行
  • 现在this.awaitArr空了,所以不会执行this.awaitArr.shift()()
  • 打印38行
  • 打印55行

2s之后...

  • 65行的addTask对应的promiseCreator被resolve了
  • 打印33, 34, 38, 55行

结束

小结

  1. async函数一旦遇到了第一个await或return就会退出,返回一个内部生成的promise
  2. 调用promise的resolve函数会立即把promise的状态置为resolved,但不会立刻跳转调用栈。并且node和浏览器的实现方式有差异