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
によりstart
とend
がまず出力された後にコールバック
が出力されます。
例 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.race
と Promise.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
- 第 0 章 はじめに (この記事)
- 第 1 章 JavaScript の歴史と特徴
- 第 2 章 JavaScript 実行モデル (ECMAScript が定める実行モデル)
- 第 3 章 ブラウザでの JavaScript の実行 (HTML Living Standard が定める実行モデル)
- 第 4 章 実装レベルで見る JavaScript の実行 (V8 & Chromium)
- 第 5 章 非同期処理の基本 (Callback)
- 第 6 章 非同期処理の基本 (jQUery Deferred)
- 第 7 章 Promise と async/await
- 第 8 章 まとめ
参考資料
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