🏐

非同期処理、実はここが誤解だった :同期・非同期・Promiseの挙動を実験で理解するイベントループ

に公開

はじめに

非同期処理を理解するにあたって、処理される順番とその仕組みというのを知る必要があります。
その仕組みについては以下の記事にまとめています。
ここでの内容を前提に話したいと思います。

ざっくりEvent Loopを理解する

今回は、処理される順番を手を動かしながら、実験的に理解していこうと思います。

同期コールバック関数

同期コールバック関数とは、呼び出されたその場で実行されるコールバック関数です。
JavaScript のメインスレッドで即時に実行され、イベントループの待機を必要としません。つまり、 Call Stack に入った瞬間に処理されます。

よく使われる同期コールバック関数

  • Array.prototype.forEach
  • Array.prototype.map
  • Array.prototype.filter
  • Array.prototype.reduce
  • String.prototype.replace(関数引数付き)
  • sort((a, b) => {}) の比較関数

非同期コールバック関数

すぐには実行されず、後で(イベントループ経由で)実行されるコールバック関数です。
これは macrotask(タスク) や microtask(マイクロタスク) にキューされます。

よく使われる非同期コールバック関数

macrotask系(タスクキューに入る)

  • setTimeout(callback, delay)
  • setInterval(callback, delay)
  • setImmediate(callback)(Node.js)
  • requestAnimationFrame(callback)(ブラウザ)

microtask系(マイクロタスクキューに入る)

  • Promise.then, .catch, .finally
  • queueMicrotask(callback)
  • MutationObserver の callback(DOM変更検知)

今回実験する関数たちとその違い

実際はいろんな関数を試してみましたが、ここでは以下にある代表的な関数を使って挙動を見ていきたいと思います。

種類 即時実行 イベントループ待ち キュー種別 関数
同期 ✅ はい ❌ しない なし forEach
非同期 (macrotask) ❌ しない ✅ する タスクキュー setTimeout
非同期 (microtask) ❌ しない ✅ する マイクロタスクキュー Promise.then

同期 - forEach

結果

コード

const array = ["a", "b", "c"];
const button = document.querySelector('button');

//コールバック関数
function outputArray() {
    array.forEach((element) => console.log(element));
}

button.addEventListener('click', function onClick(){
    outputArray();
})

出力

a
b
c

気づき

ボタンが押された瞬間に、outputArray関数 (今回のコールバック関数)のforEachが実行されています。

非同期 (macrotask) - setTimeout

結果

コード
先ほどのコードに、非同期コールバック関数のsetTimeoutを追加しました。

const array = ["a", "b", "c"];
const button = document.querySelector('button');

function outputArray() {
    array.forEach((element) => console.log(element));
}

button.addEventListener('click', function onClick(){
    console.log("ボタンがクリックされました");

    // 非同期コールバック
    setTimeout(() => {
        console.log("非同期コールバックのsetTimeoutです");
    }, 1000);

    // 同期コールバック
    outputArray();
})

出力

ボタンがクリックされました
a
b
c
非同期コールバックのsetTimeoutです

気づき

非同期コールバックであるsetTimeoutは、同期コールバック関数の前にあるにも関わらず、1番最後に実行されました。setTimeout は Web API 環境で待機された後、指定ミリ秒が経過すると macrotask queue にコールバック関数が入ります。そして、Call Stack が空になったタイミングでそのコールバックが実行されます。

非同期 (microtask) - Promise.then

結果

コード

const array = ["a", "b", "c"];
const button = document.querySelector('button');

button.addEventListener('click', function onClick(){
    console.log("ボタンがクリックされました"); //同期処理

    Promise.resolve().then(() => {
        array.forEach((element) => console.log(element)); // ← microtask
        console.log("配列の要素をすべて出力しました"); //← microtask
    });

    console.log("Hello~!"); //同期処理
});

出力

ボタンがクリックされました
Hello~!
a
b
c
配列の要素をすべて出力しました

気づき

同期処理が実行された後に、Promise.then内の関数が実行されていますね。
Promise.then内の関数が、一度microtaskに入り、他の処理が全て終わった後に、Call Stackに呼び出され実行されていることがわかります。

非同期 (microtask)は、非同期 (macrotask) よりも優先度が高い。

コード

button.addEventListener('click', function onClick(){
    console.log("ボタンがクリックされました");

    setTimeout(() => {
        console.log("setTimeoutが実行されました(macrotask)");
    }, 1000);

    Promise.resolve().then(() => {
        console.log("Promiseが実行されました(microtask)");
    });

    console.log("Hello~!");
});

出力

ボタンがクリックされました
Hello~!
Promiseが実行されました(microtask)
setTimeoutが実行されました(macrotask)

私がPromiseを使った処理に誤解があったことに気づいた

以下のコードでは、Promise内の実行処理と実行後の処理を分けて書いています。

const array = ["a", "b", "c"];
const button = document.querySelector('button');

button.addEventListener('click', function onClick(){
    console.log("ボタンがクリックされました");

    const outputArray = new Promise((resolve) => {
        array.forEach((element) => console.log(element)); // ←同期出力
        resolve(); // すぐ resolve
    });

    outputArray.then(() => {
        console.log("配列の要素をすべて出力しました"); // ← microtask
    }).catch(() => {
        console.log("エラーが発生しました");
    });

    console.log("Hello~!"); // ←同期出力
});

出力結果

ボタンがクリックされました
a
b
c
Hello~!
配列の要素をすべて出力しました

何が誤解だったのか

誤解❌: Promise内の処理は全て、それが同期処理であっても、他の処理が終わった後に実行される。
正解⭕️: Promise内の処理は即時に実行される。他の処理が終わった後に実行される処理というのは、Promise内の処理実行が成功または失敗時の処理である。

Promiseに関するよくある誤解とその正体

  • executor 関数(Promiseの中の関数)は非同期ではなく、**すぐに実行される「同期処理」**である
  • .then() / .catch() の中身は 非同期的に(microtask)実行される
  • executor の中で非同期処理を使えば、当然その処理が終わるまで resolve() は遅れる
    → つまり 「Promise本体 ≠ 非同期」ではないことに注意

まとめ

実際に手を動かして試してみると、気づきがたくさんあってびっくり!
読んだだけで、知ったかぶりになっていることはいろんなことに多くあるかもと思った。
実行順序、完璧に予測できる気がするところまで理解深めれた。

Discussion