イントロダクション
JavaScript を使う上で非同期処理、特に promise を理解することは非常に重要である。async/await も promise の上に成り立っているので、promise を理解して始めて正しく使うことができる。この記事では JavaScript における非同期処理をどのくらい理解できているかをチェックするための問題を紹介する。
それぞれのコードについて、実行したときにコンソールに何がどういう順序で出力されるかを考えてみよう。なお、コードはブラウザーで実行するものとし、解答は ECMAScript Specification と HTML Living Standard に沿ったものとなっている。
🚗 初級
問題1.1
const p1 = Promise.resolve(1)
const p2 = p1.then(x => x * 2)
console.log(p1 === p2)
解答
false
promise の then
catch
メソッドは呼び出すたびに新しい別の promise が作成され、それが戻り値となる。元の promise に変更を加えているわけではない(内部的には promise のイベントリスナーリスト的なものがありそれに追加しているのだが、少なくとも JavaScript のユーザーからは完全に隠蔽されている)。当たり前といえば当たり前だが、勘違いしてはいけない重要なポイントである。
問題1.2
Promise.resolve()
.then(() => { throw new Error("thrown") })
.then(() => console.log(1))
.then(() => console.log(2))
.catch(() => console.log("caught"))
.then(() => console.log(3))
解答
caught
3
promise の then
コールバックで例外が発生すると、then
の戻り値の promise は失敗したとみなされる。いったん promise が失敗すると、promise チェーン上をたどって catch
コールバック (あるいは then
の第2引数)に至るまで、then
コールバックは無視される。
catch
コールバックが成功すれば、catch
の戻り値の promise は成功したとみなされ、最後の then
コールバックも呼び出されることになる。
問題1.3
Promise.resolve().then(() => {
return Promise.resolve(Promise.resolve(Promise.resolve(1)))
}).then(console.log)
解答
1
promise の promise の promise...と何重にラップしても、then
コールバックに渡されるのはアンラップされた、非 promise の値である。(この意味で then
メソッドは promise の flatMap だと見ることもできる。)
問題1.4
Promise.resolve(1).then(
() => { throw new Error("oops") },
() => console.log(2)
).catch(() => console.log(3))
解答
3
p.then(f1, f2)
は f1
が失敗しても f2
は実行されない。p
が失敗したときのみ実行される。反対に、p.then(f1).catch(f2)
は f1
が失敗すると f2
が実行される。
🚄 中級
問題2.1
Promise.resolve().then(() => console.log(1))
console.log(2)
解答
2
1
promise のコールバックは、必ず現在実行されている同期処理が終了してから(=コールスタックが空になってから)実行される。console.log(2)
が実行されてからコールスタックが空になるため、1
が後に出力される。
問題2.2
setTimeout(() => console.log(1), 0)
console.log(2)
解答
2
1
前の問題と同じく、setTimeout
のコールバックはコールスタックが空になってから実行される。
問題2.3
const p1 = new Promise((resolve) => {
console.log(1)
resolve()
})
console.log(2)
解答
1
2
promise のコンストラクターに渡した関数は、同期的にすぐに実行される。ただし、同期的に resolve してもコールバックは同期的に呼び出されない。
問題2.4
Promise.resolve()
.then(() => console.log(1))
.then(() => console.log(2))
Promise.resolve()
.then(() => console.log(3))
.then(() => console.log(4))
解答
1
3
2
4
promise が成功するとそのコールバックは microtask queue というキュー(待ち行列)に入れられる。このコードが最後まで実行されると、() => console.log(1)
、() => console.log(3)
の順にキューに入った状態になる。このキューはFIFO(最初に入れられたものから最初に処理される)であるため、() => console.log(1)
がまず処理され、() => console.log(2)
がキューに入れられ、() => console.log(3)
が処理され、() => console.log(4)
がキューに入れられ...という具合に進む。
問題2.5
!(async () => {
console.log(1)
const x = await 1
console.log(2)
})()
console.log(3)
解答
1
3
2
async function 内のコードは、await
式が評価されるまでは同期的に実行される。そのため、1
が先に出力される。await
する値がたとえ promise でなくとも、それ以降の処理は Promise.resolve().then(() => { ... })
と同様に、コールスタックが空になってから(つまり非同期に)実行される。
問題2.6
!(async () => {
let x = 0
const p1 = Promise.resolve().then(() => {
x = 1
return 10
})
console.log(x + (await p1) * x)
})()
解答
10
これは式 x + (await p1) * x
がどのような順序で評価されるかを考慮する必要がある。といっても、この場合は左から計算していくだけなのでシンプルである。
- 左の
x
が評価される。この時点では0
なので0
が返る。 -
await p1
が評価される。x
が1
になり、10
が返る。 - 右の
x
が評価され、1
が返る。 -
(await p1) * x
が評価され、10 * 1 =10
が返る。 -
x + (await p1) * x
が評価され、0 + 10 =10
が返る。
🚀 上級
問題3.1
setTimeout(() => console.log(1), 0)
Promise.resolve().then(() => console.log(2))
解答
2
1
実行予定の promise のコールバックと MutationObserver のコールバックは microtask queue というキューに入れられる。一方でそれ以外の非同期なコールバックは task queue と呼ばれるものに入れられる。コールスタックが空になるとまず microtask queue から実行される。それが空になるとようやく task queue から1つだけ task が取り出され、また microtask queue が実行され...と繰り返される。
この問題では () => console.log(2)
が microtask、() => console.log(1)
が task となる。
問題3.2
setTimeout(() => { // callback1
console.log(1)
Promise.resolve().then(() => console.log(2))
}, 0)
setTimeout(() => { // callback2
console.log(3)
Promise.resolve().then(() => console.log(4))
}, 0)
Promise.resolve()
.then(() => console.log(5))
.then(() => console.log(6))
解答
5
6
1
2
3
4
1つ前の問題と同様に考えればよい。ただし、microtask queue は空になるまで実行されるということと、task queue は1つづつしか実行されないということに注意する必要がある。
- callback1 と callback2 が task queue に入れられ、
() => console.log(5)
が microtask queue に入れられる。 -
() => console.log(5)
が実行され、() => console.log(6)
が microtask queue に入れられる。 -
() => console.log(6)
が実行され、microtask queue が空になる。 - callback1 が実行され、
() => console.log(2)
が microtask queue に入れられる。 -
() => console.log(2)
が実行され、microtask queue が空になる。 - callback2 が実行され、
() => console.log(4)
が microtask queue に入れられる。 -
() => console.log(4)
が実行される。
問題3.3
<script> // script1
console.log(1)
setTimeout(() => console.log(2), 0)
Promise.resolve().then(() => console.log(3))
</script>
<script> // script2
console.log(4)
setTimeout(() => console.log(5), 0)
Promise.resolve().then(() => console.log(6))
</script>
解答
1
3
4
6
2
5
この問題のポイントは、script タグが実行し終わって終了タグまで来るとコールスタックが空になり microtask queue が実行される1ということである。そのため、script タグと script タグの間で microtask queue が実行される。しかし、ページ全体のHTMLをパージングし終わるまでが1つの task なので、それまで次の task は実行されない。この点に注意して前の問題と同様に考えれば良い。
- script1 が実行され、
() => console.log(2)
が task queue に、() => console.log(3)
が microtask queue に入れられる。 - コールスタックが空になり、microtask
() => console.log(3)
が実行される。 - script2 が実行され、
() => console.log(5)
が task queue に、() => console.log(6)
が microtask queue に入れられる。 - コールスタックが空になり、microtask
() => console.log(6)
が実行される。 - HTML全体をパースするタスクが終了し、task
() => console.log(2)
が実行される。 - 5 の task が終了し、microtask queue が空なので task
() => console.log(5)
が実行される。
最後に
JavaScript を普段使いするためには少なくとも初級の問題は理解しておいたほうがよいだろう。microtask と task、コールスタックについては筆者の別記事でより分かりやすく説明している。
注意すべき点として、Node.js はイベントループの仕組みがブラウザのそれとは異なり、ここであげたコードを実行すると違う結果になることがある。また、v11 で非同期処理の挙動が変わっているようなので、使用するバージョンに気をつけたほうがいいだろう。
仕様の該当箇所
- HTML Living Standard
- イベントループの実行モデル: 8.1.4.3 Processing model
- microtask queue に追加する処理: 8.1.3.7.1 EnqueueJob(queueName, job, arguments)
- ECMAScript Specification にも EnqueueJob があるが、ブラウザーではこちらが代わりに実行される
- script タグの終わりに到達したときの処理: 12.2.6.4.8 The "text" insertion mode
-
setTimeout
: 8.5 Timers
- ECMAScript Language Specification
- promise
then
メソッド: 25.6.5.4 Promise.prototype.then ( onFulfilled, onRejected ) - promise
catch
メソッド: 25.6.5.1 Promise.prototype.catch ( onRejected ) - promise コンストラクター: 25.6.3.1 Promise ( executor )
-
await
: 14.7.14 Runtime Semantics: Evaluation の AwaitExpression の所
- promise
-
script タグが実行し終わると clean up after running script が実行されてその中で microtask checkpoint というものが実行され、microtask queue 内の microtask が実行される。 ↩