JavaScript 微任务/宏任务踩坑
本文最后更新于:2020年3月11日 晚上
场景
在使用 async-await
时,吾辈总是习惯把它们当作同步,终于,现在踩到坑里去了。
使用 setTimeout
和 setInterval
实现的基于 Promise
的 wait
函数,然而测试边界情况的时候却发现了一些问题!
实现代码
/**
* 等待指定的时间/等待指定表达式成立
* 如果未指定等待条件则立刻执行
* @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
中进行修改,结果便一切如同吾辈所期待的一样了!