JavaScript 微任务/宏任务踩坑

本文最后更新于:2020年3月11日 晚上

场景

SegmentFault

在使用 async-await 时,吾辈总是习惯把它们当作同步,终于,现在踩到坑里去了。
使用 setTimeoutsetInterval 实现的基于 Promisewait 函数,然而测试边界情况的时候却发现了一些问题!

实现代码

/**
 * 等待指定的时间/等待指定表达式成立
 * 如果未指定等待条件则立刻执行
 * @param {Number|Function} [param] 等待时间/等待条件
 * @returns {Promise} Promise 对象
 */
export const wait = (param) => {
  return new Promise((resolve) => {
    if (typeof param === "number") {
      setTimeout(resolve, param);
    } else if (typeof param === "function") {
      const timer = setInterval(() => {
        if (param()) {
          clearInterval(timer);
          resolve();
        }
      }, 100);
    } else {
      resolve();
    }
  });
};

测试代码

(async () => {
  // 标识当前是否有异步函数 add 在运行了
  let taskIsRun = false;
  const add = async (_v, i) => {
    // 如果已经有运行的 add 函数,则等待
    if (taskIsRun) {
      console.log(i + " 判断前: ");
      await wait(() => {
        return !taskIsRun;
      });
      console.log(i + " 判断后: " + taskIsRun);
    }
    try {
      taskIsRun = true;
      console.log(i + " 执行前: " + taskIsRun);
      await wait(100);
    } finally {
      console.log(i + " 执行后: ");
      taskIsRun = false;
    }
  };

  const start = Date.now();
  await Promise.all(Array(10).fill(0).map(add));
  console.log(Date.now() - start);
})();

那么,先不要往下看,猜一下最后打印的大概会是多少呢?

实际执行结果

0 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

1 判断前:  ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

2 判断前:  ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

3 判断前:  ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

4 判断前:  ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

5 判断前:  ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

6 判断前:  ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

7 判断前:  ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

8 判断前:  ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

9 判断前:  ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

0 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

1 判断后: false ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

1 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

// 这儿的 1 执行前,结果 2 就已经判断通过并准备执行了???发生了什么?

2 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

2 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

3 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

3 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

4 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

4 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

5 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

5 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

6 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

6 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

7 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

7 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

8 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

8 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

9 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

9 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

1 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

2 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

3 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

4 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

5 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

6 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

7 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

8 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

9 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

307 ​​​​​at ​​​Date.now() - start​​​ ​src/module/function/wait.js:52:2​

可以看到,很神奇的是 判断后 => 执行前 => 判断后…=> 执行后…_,并不是预想中的 _判断后 => 执行前 => 执行后… 的循环,所以,到底发生了什么呢?

思考

这个问题卡了吾辈两天之久,直到吾辈在 StackOverflow 提出的另一个相关的问题被外国友人回答了,瞬间吾辈就想起了 – async-await 本质上还是异步

是的,为什么会出现 wait 一直在执行而后面的 taskIsRun = true 却并没有执行?因为 JavaScript 中的 async-await 虽然可以写出来很像同步代码的异步代码,但实际上还是异步的,原理还是基于 Promise

我们改造一下代码,将之使用原生 Promise 实现一下

/**
 * 等待指定的时间/等待指定表达式成立
 * 如果未指定等待条件则立刻执行
 * @param {Number|Function} [param] 等待时间/等待条件
 * @returns {Promise} Promise 对象
 */
export const wait = (param) => {
  return new Promise((resolve) => {
    if (typeof param === "number") {
      setTimeout(resolve, param);
    } else if (typeof param === "function") {
      const timer = setInterval(() => {
        if (param()) {
          clearInterval(timer);
          resolve();
        }
      }, 100);
    } else {
      resolve();
    }
  });
};
(() => {
  // 标识当前是否有异步函数 add 在运行了
  let taskIsRun = false;
  const add = (_v, i) => {
    // 如果已经有运行的 add 函数,则等待
    return Promise.resolve()
      .then(() => {
        if (taskIsRun) {
          console.log(i + " 判断前: ");
          // 关键在于这里,实际上执行完成之后并不会到下一个 then,而是继续另一个 wait 的判断
          return wait(() => !taskIsRun).then(() => {
            console.log(i + " 判断后: " + taskIsRun);
          });
        }
      })
      .then(() => {
        taskIsRun = true;
        console.log(i + " 执行前: " + taskIsRun);
        return wait(100);
      })
      .catch(() => {})
      .then(() => {
        console.log(i + " 执行后: ");
        taskIsRun = false;
      });
  };

  const start = Date.now();
  Promise.all(Array(10).fill(0).map(add)).then(() =>
    console.log(Date.now() - start)
  );
})();

这个时候就可以看出来了,判断逻辑是处在一个 then 后继里面的。那么,执行完 console.log(i + ' 判断后: ' + taskIsRun) 之后,就一定会继续执行下面的 then 函数么?并不,这时候 wait 函数内部实现中的 setInterval 还在运转,实际上 nodejs 并不会优先继续 then 这种 microtask(微任务),而是会继续进行 setInterval 这种 macrotask(宏任务)。这是 nodejs 与浏览器实现不一致的地方,吾辈将这些代码复制到浏览器上,确实可以正常执行并得到预期的结果。

微任务与宏任务参考

if (taskIsRun) {
  console.log(i + " 判断前: ");
  return wait(() => !taskIsRun).then(() => {
    console.log(i + " 判断后: " + taskIsRun);
  });
}

当然,nodejs 11 修复了这个问题,参考 https://github.com/nodejs/node/pull/22842。然而目前 NodeJS LTS 为 10,最新版本为 12,这个问题可能还要持续一段时间。

解决

那么,难道吾辈就必须等到 NodeJS LTS 最新版之后才能用 wait 么?或者说,吾辈就必须依赖于浏览器的 microtask/macrotask 么?并不,吾辈对之手动进行了处理即可!

/**
 * 等待指定的时间/等待指定表达式成立
 * 如果未指定等待条件则立刻执行
 * @param {Number|Function} [param] 等待时间/等待条件
 * @returns {Promise} Promise 对象
 */
export const wait = (param) => {
  return new Promise((resolve) => {
    if (typeof param === "number") {
      setTimeout(resolve, param);
    } else if (typeof param === "function") {
      const timer = setInterval(() => {
        if (param()) {
          clearInterval(timer);
          resolve();
        }
      }, 100);
    } else {
      resolve();
    }
  });
};
(async () => {
  // 标识当前是否有异步函数 add 在运行了
  let taskIsRun = false;
  const add = async (_v, i) => {
    // 如果已经有运行的 add 函数,则等待
    if (taskIsRun) {
      console.log(i + " 判断前: ");
      await wait(() => {
        const result = !taskIsRun;
        // 关键在于这里
        if (result) {
          taskIsRun = true;
        }
        return result;
      });
      console.log(i + " 判断后: " + taskIsRun);
    }
    try {
      taskIsRun = true;
      console.log(i + " 执行前: " + taskIsRun);
      await wait(100);
    } finally {
      console.log(i + " 执行后: ");
      taskIsRun = false;
    }
  };

  const start = Date.now();
  await Promise.all(Array(10).fill(0).map(add));
  console.log(Date.now() - start);
})();

吾辈在 wait 函数中,即 setInterval 循环调用的函数中对 taskIsRun 进行了修改,而不是在 wait 后面,即 then 之后的 microtask 中进行修改,结果便一切如同吾辈所期待的一样了!