JavaScript 微任务/宏任务踩坑

JavaScript 微任务/宏任务踩坑

场景

SegmentFault

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

实现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 等待指定的时间/等待指定表达式成立
* 如果未指定等待条件则立刻执行
* @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()
}
})
}

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
;(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)
})()

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

实际执行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
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 实现一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/**
* 等待指定的时间/等待指定表达式成立
* 如果未指定等待条件则立刻执行
* @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 与浏览器实现不一致的地方,吾辈将这些代码复制到浏览器上,确实可以正常执行并得到预期的结果。

微任务与宏任务参考

1
2
3
4
5
6
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 么?并不,吾辈对之手动进行了处理即可!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
* 等待指定的时间/等待指定表达式成立
* 如果未指定等待条件则立刻执行
* @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 中进行修改,结果便一切如同吾辈所期待的一样了!