2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに ~ JavaScript の非同期を理解してみる

Last updated at Posted at 2025-06-10

JavaScript 非同期処理クイズ

突然ですが、皆さんは以下のようなコードが何をしているかわかりますか?
各コードを実行したときのコンソール出力を考えてみてください。(JavaScript ES2017 以降を利用想定)

例 1 非同期処理の基本

console.log("start");
setTimeout(() => {
  console.log("コールバック");
}, 1000);
console.log("end");
回答
start
end
コールバック

ポイント
これは、基本的な非同期処理を行う WebAPI の動作を確認するための例です。

  • setTimeout の結果として、1000ms 後に コールバック が実行されます。
  • 内部的には、setTimeout の処理は Web APIs に移譲され、1000ms 後に コールバックTask Queue に追加されます。
  • そのため、console.log により startend がまず出力された後に コールバック が出力されます。

例 2 タスクキューの動作

console.log("start");

setTimeout(() => {
  console.log("0秒後のコールバック");
}, 0);

setTimeout(() => {
  console.log("3秒後のコールバック");
}, 3000);

setTimeout(() => {
  console.log("1秒後のコールバック");
}, 1000);

console.log("end");
回答
start
end
0秒後のコールバック
1秒後のコールバック
3秒後のコールバック

ポイント
これも例 1 と同じく基本的な非同期処理を行う WebAPI の動作を確認するための例です。

  • setTimeout は、指定した時間後に Callback 関数をキューに登録します。
  • 指定が 0ms だとしても、Task を新しく作成してキューに登録するため、その処理は、今の関数の同期処理が完了した後に実行されます。

例 3 async/await の動作

async function main() {
  console.log("main start");
  await Promise.resolve();
  console.log("main end");
}
console.log("start");
main();
console.log("end");
回答
start
main start
end
main end

ポイント
これは、Promise & async/await の非同期処理を理解しているかを確認するための例です。

  • main 関数内の await Promise.resolve() によって、後続の処理はマイクロタスクキューに登録され、main 関数は一時中断されます。
  • main() を呼び出すとすぐに Promise を返すため、関数本体の実行が一旦止まり、その間に console.log("end") が実行されて end が出力されます。
  • イベントループによって、マイクロタスクキューに登録されていた "main end" の処理が、現在のスタックが空になったあとに実行されます。

例 4 マイクロタスクとマクロタスク

setTimeout(() => {
  console.log("setTimeout");
}, 0);

Promise.resolve().then(() => {
  console.log("promise");
});

console.log("start");
回答
start
promise
setTimeout

ポイント
これは、WebAPI の非同期処理 とPromise(マイクロタスク)の実行優先度の違いを確認するための例です。

  • setTimeout は、通常の非同期処理 WebAPI であり、Callback 関数はタスクキューに登録されます。
  • Promise.resolve().then(() => { console.log("promise"); }) は、Promise のマイクロタスクキューに登録されます。
  • 内部処理では、マイクロタスクキューは通常のタスクキューよりも優先度が高いため、setTimeout よりも先に promise が実行されます。

例 5 基本的なエラーハンドリング

async function fetchUser(id) {
  if (id < 0) throw new Error("Invalid ID");
  return { id, name: "User" };
}

console.log("start");
fetchUser(-1)
  .then((user) => console.log("Success:", user))
  .catch((err) => console.log("Error:", err.message))
  .finally(() => console.log("Complete"));
console.log("end");
回答
start
end
Error: Invalid ID
Complete

ポイント
これは、基本的なエラーハンドリングを理解しているかを確認するための例です。

  • fetchUser 関数内で id が負の場合にエラーを投げています。
  • そのため、fetchUser 関数の結果を then で受け取ることができず、catch でエラーを処理します。
  • finally は、最後に必ず実行されます。

例 6 並列実行

function task(name, delay) {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(name + " 完了");
      resolve(name);
    }, delay);
  });
}

Promise.all([task("Task1", 500), task("Task2", 1000)]).then(([res1, res2]) => {
  console.log("全タスク完了:", res1, res2);
});
回答
Task1 完了
Task2 完了
全タスク完了: Task1 Task2

ポイント
これは、Promise.all による非同期処理の並列実行を理解しているかを確認するための例です。

  • Promise.all は、複数の非同期処理を並列実行し、全ての処理が完了したら、その結果を配列で返します。
  • 一つでも失敗すると、Promise.all は失敗します。
// 一部が失敗し Promise.all が失敗する例

function task(name, delay) {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(name + " 完了");
      resolve(name);
    }, delay);
  });
}

function failedTask(name, delay) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log(name + " 完了");
      reject(name);
    }, delay);
  });
}

Promise.all([
  task("Task1", 500),
  task("Task2", 1000),
  failedTask("Task3", 1500),
])
  .then(([res1, res2, res3]) => {
    console.log("全タスク完了:", res1, res2, res3);
  })
  .catch(() => {
    console.log("failed");
  });

例 7 複数の非同期処理の並列実行

// モックのfetch関数
const mockFetch = (url) => {
  return new Promise((resolve, reject) => {
    setTimeout(
      () => {
        // api1は即座に失敗
        if (url.includes("api1")) {
          reject(new Error("Network Error"));
        }
        // api2は1秒後に成功
        else if (url.includes("api2")) {
          resolve({
            ok: true,
            json: () => Promise.resolve({ data: "Success from api2" }),
          });
        }
        // api3は2秒後に404
        else if (url.includes("api3")) {
          resolve({
            ok: false,
            status: 404,
            statusText: "Not Found",
          });
        }
      },
      url.includes("api2") ? 1000 : 0
    );
  });
};

// 実行関数
function fetchWithFallback(urls, timeout = 3000) {
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(new Error("All requests timed out")), timeout);
  });

  const fetchPromises = urls.map((url) =>
    mockFetch(url)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .catch((err) => {
        console.log(`${url} failed:`, err.message);
        return Promise.reject(err);
      })
  );

  return Promise.race([
    Promise.any(fetchPromises).catch((err) =>
      Promise.reject(new Error("All requests failed"))
    ),
    timeoutPromise,
  ]);
}

// 以下3つのURLへの連続的なfetchを実行 (Mock済みです)
const urls = [
  "https://api1.example.com/data", // 即座に失敗
  "https://api2.example.com/data", // 1秒後に成功
  "https://api3.example.com/data", // 404エラー
];

console.log("開始");
fetchWithFallback(urls, 2000)
  .then((data) => console.log("Success:", data))
  .catch((err) => console.log("Error:", err.message))
  .finally(() => console.log("Complete"));
回答
開始
https://api1.example.com/data failed: Network Error
https://api3.example.com/data failed: HTTP 404
Success: {data: "Success from api2"}
Complete

ポイント
これは、Promise.racePromise.any の違いを理解しているかを確認するための例です。

  • Promise.race は、複数の非同期処理を並列実行し、最初に解決/失敗した処理の結果を返します。
  • Promise.any は、複数の非同期処理を並列実行し、最初に成功した処理の結果を返します。

url1, 3 は失敗しますが、 Promise.any は 成功した url2 の結果を返します。そのため、Promise.race は成功として、url2 の結果を返します。これは各リクエストの結果をログに残しつつ、実行したリクエストのどれか一つでも成功したときに、その結果を利用するというケースを模擬しています。

例 8 再帰的な非同期処理

* 例 8 は実際に実行することは避けてください。
以下の 8-a, 8-b の違いを回答してください。

8-a

let loopDepth = 0;
async function loop() {
  loopDepth++;
  console.log("loop depth", loopDepth);
  await loop();
}

async function main() {
  try {
    await loop();
  } catch (error) {
    console.error(error);
  }
}

main();

8-b

let loopDepth = 0;
function loop() {
  loopDepth++;
  console.log("loop depth", loopDepth);
  Promise.resolve().then(() => loop());
}

async function main() {
  try {
    loop();
  } catch (error) {
    console.error(error);
  }
}

// Run the main function
main();
回答
// 8-a
Uncaught (in promise) RangeError: Maximum call stack size exceeded

// 8-b
loop depth 1
loop depth 2
loop depth 3
loop depth 4
loop depth 5 ...

ポイント
これは再帰処理の中でPromise & async/await におけるマイクロタスクの登録タイミングとスタックの動作を理解しているかを確認するための例です。

  • 8-a では、await を使っており、loop関数の結果を待ち続けるため、関数がスタックに積まれていき、スタックオーバーフローが発生します。
  • 8-b では、Promise.resolve().then(() => loop()) によって、loop関数自体を Callback としてマイクロタスクキューに登録しています。そのため、スタックに関数が積まれることなく、無限に呼び出しされます。

例 9 リトライロジック

// モックfetch
let callCount = 0;
function mockFetch() {
  return new Promise((resolve, reject) => {
    callCount++;
    console.log(`fetch called: ${callCount}回目`);

    if (callCount === 3) {
      resolve({ ok: true, data: "成功データ" });
    } else {
      reject(new Error(`${callCount}回目の呼び出しで失敗`));
    }
  });
}

function withRetry(promiseFn, maxRetries = 3) {
  return new Promise(async (resolve, reject) => {
    for (let i = 0; i < maxRetries; i++) {
      try {
        const result = await promiseFn();
        return resolve(result);
      } catch (err) {
        if (i === maxRetries - 1) reject(err);
        await new Promise((r) => setTimeout(r, 1000 * (i + 1)));
      }
    }
  });
}

console.log("開始");
withRetry(() => mockFetch())
  .then((result) => console.log("成功:", result))
  .catch((err) => console.log("最終的な失敗:", err.message));
回答
開始
fetch called: 1回目
fetch called: 2回目
fetch called: 3回目
成功: {ok: true, data: "成功データ"}

ポイント
これは、典型的なリトライロジックを実装している例です。API のリクエストが失敗した場合に、一定時間後に再試行するというケースを模擬しています。内容としては、特段難しくないですが、非常によくあるケースとして掲載しています。

  • withRetry 関数は、promiseFn を最大 maxRetries 回リトライします。
  • リトライ回数が maxRetries 回を超えた場合に、エラーを投げます。

答え合わせ

➡️ 全てわかった方: おめでとうございます。この記事を読んでも得るものは少ないので時間をお返しします。よければ "いいね"をいただけると励みになります。

➡️ 6~8 問解けた方: おめでとうございます。非同期についてかなり理解されていると思います。周りにJavaScript の非同期処理を学習している方がいたら、この記事をお勧めしていただけるとありがたいです。

➡️ 3~5 問解けた方: おめでとうございます。非同期について基本的な挙動は理解されていると思うので、復習の意を込めて記事を読んでいただけるとありがたいです。

➡️ 1~2 問解けた方: おめでとうございます。非同期処理が実現する仕組みを理解することで、より深い理解ができると思います。ぜひ記事を読んでいただけるとありがたいです。

➡️ 1 問もわからなかった方: ありがとうございます。数ヶ月前の私と同じです。

恥ずかしながら自分は最初、jQuery deferred や async/await が、何をしているのかちゃんと理解していませんでした。そこで、JavaScript の非同期について調べ始めました。

調べ始めてわかったのが、JavaScript の非同期処理は、他言語でのマルチスレッド処理や並列処理と呼ばれるものとは全く異なる流れで処理されているということでした。そして、その非同期処理の流れを理解するためには、JavaScript 自体について理解を深める必要があるということを理解しました。

このシリーズは、私個人が JavaScript について勉強した内容をまとめたものです。自分のための備忘録として、そして自分と同じように非同期処理がわかっていない人が、何となくわかったつもりになれるような内容を目指しています。
(できる限り正確を期するようにしていますが、もし誤りなどあれば、コメントいただけるととても嬉しいです。)

Table of Contents

参考資料

HTML Living Standard
ECMAScript® 262 Language Specification
What the heck is the event loop anyway? | Philip Roberts | JSConf EU
Further Adventures of the Event Loop - Erin Zimmer - JSConf EU 2018
JavaScript execution model

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?