非同期処理の基礎 — コールバック・Promise・async/await の違いと読み方
JavaScript/Node.jsの非同期処理を、コールバック→Promise→async/awaitという進化の流れで理解し、他人のコードを読み書きできる状態を作る
TL;DR
- 非同期処理とは「時間のかかる処理の完了を待たずに次のコードを進め、完了したら結果を受け取る」書き方のこと。JavaScriptは基本的にシングルスレッドなので、これがないとファイル読み込みやAPIリクエストの間、他の処理が全部止まってしまう
- コールバック → Promise → async/await は同じ非同期処理を書くための別の書き方であり、内部的にはPromiseがベースになっている。新しく書くコードは基本的に async/await でよい
- 他人のコードを読むときに詰まりやすいのは「これは同期処理か非同期処理か」「このエラーはどこでcatchされるか」の2点。まずこの2つを意識して読めば大体のコードは追える
この記事でやること
コールバック、Promise、async/await の3つを、なぜ生まれたか・何が違うか・どう読むかという観点で順に説明する。ゴールは、業務でよく出てくる非同期コードを読んでコードレビューできる状態になること。
前提
- Node.js または ブラウザ上で動くJavaScript/TypeScriptを想定
- 「関数」「引数」など基本的なJavaScriptの文法は理解している前提
非同期処理とは
同期処理(synchronous)は、コードを書いた順番どおりに、1つの処理が終わるまで次の処理に進まない実行方式。それに対して非同期処理(asynchronous)は、時間のかかる処理を「後で終わったら教えてもらう」形で裏に投げておき、その完了を待たずに次のコードへ進む実行方式である。
言葉だけだと分かりにくいので、実行順序で比較する。
// src/example/sync-vs-async.ts
console.log("1");
setTimeout(() => {
console.log("2");
}, 1000); // 1秒後に実行される非同期処理
console.log("3");このコードの出力順は 1 → 3 → 2 になる。setTimeout は「1秒後にこの関数を実行してほしい」と非同期に依頼するだけで、その場では実行されない。そのため、待たされることなく console.log("3") が先に実行され、1秒後にようやく console.log("2") が実行される。
もし setTimeout が同期処理だったら、出力順は書いた順どおり 1 → 2 → 3 になり、かつ2が出るまでの1秒間、他の処理は一切進められない。この「待っている間も他の処理を進められる」性質をノンブロッキングと呼ぶ。
なぜ非同期処理が必要か
JavaScriptの実行環境(ブラウザやNode.js)は基本的にシングルスレッドで動く。1本の道路を1台の車しか通れないイメージで、コードは基本的に上から順番に1つずつしか実行できない。
ここで、ファイル読み込みやDBへの問い合わせ、外部APIへのリクエストのような「結果が返ってくるまで時間がかかる処理」をそのまま書くとどうなるか。
graph TD
A["処理開始"] --> B["DBに問い合わせ"]
B --> C{"どう書くか?"}
C -->|同期的に書く| D["結果が返るまで何もできない<br/>画面もフリーズする"]
C -->|非同期処理を使う| E["問い合わせだけ投げて次に進む"]
E --> F["結果が返ってきたタイミングで<br/>続きの処理を実行"]
もし同期的に(=結果が返ってくるまで待つ形で)書いてしまうと、その間ブラウザなら画面がフリーズし、Node.jsのサーバーなら他のリクエストを一切処理できなくなる。これを避けるために「時間のかかる処理は裏で進めておいて、終わったら教えてもらう」という非同期処理の仕組みが必要になる。
コールバックによる非同期処理
最も原始的なやり方がコールバック関数である。「処理が終わったら呼んでほしい関数」を引数として渡しておく方式。
// src/example/callback.ts
import fs from "fs";
fs.readFile("data.txt", "utf-8", (err, data) => {
if (err) {
console.error("読み込み失敗", err);
return;
}
console.log("読み込んだ内容:", data);
});
console.log("readFileの呼び出し後、すぐここが実行される");fs.readFile はファイルの読み込みが終わった時点で、渡しておいた (err, data) => {...} を実行する。読み込み中も処理は止まらず、次の console.log が先に実行される。
コールバックの問題点:コールバック地獄
非同期処理を複数つなげたいとき、コールバックの中にコールバックをネストしていくことになる。
// src/example/callback-hell.ts
getUser(userId, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getOrderDetail(orders[0].id, (err, detail) => {
if (err) return handleError(err);
console.log(detail);
});
});
});これがコールバック地獄(callback hell)と呼ばれる状態。処理が増えるほどネストが深くなり、エラーハンドリングも各階層で書く必要があって読みにくく・直しにくくなる。この問題を解決するために生まれたのがPromiseである。
Promise
Promiseは「非同期処理の結果(成功か失敗か)を表すオブジェクト」である。処理が完了したら成功(resolve)か失敗(reject)のどちらかの状態になる。
graph LR
A["pending<br/>実行中・未確定"] -->|成功| B["fulfilled<br/>resolve(値)"]
A -->|失敗| C["rejected<br/>reject(エラー)"]
Promiseは3つの状態のいずれかを取る。
- pending … 実行中でまだ結果が確定していない
- fulfilled … 処理が成功し、値が確定した
- rejected … 処理が失敗し、エラーが確定した
一度 fulfilled か rejected になったPromiseは、それ以降状態が変わらない。
Promiseを作る
// src/example/promise-basic.ts
function readFilePromise(path: string): Promise<string> {
return new Promise((resolve, reject) => {
fs.readFile(path, "utf-8", (err, data) => {
if (err) {
reject(err); // 失敗を伝える
} else {
resolve(data); // 成功を伝える
}
});
});
}Promiseを使う:then / catch
Promiseを受け取る側は .then() で成功時、.catch() で失敗時の処理をつなげる。
// src/example/promise-then.ts
readFilePromise("data.txt")
.then((data) => {
console.log("読み込んだ内容:", data);
return readFilePromise("data2.txt"); // Promiseを返すとチェーンできる
})
.then((data2) => {
console.log("2つ目のファイル:", data2);
})
.catch((err) => {
console.error("どこかで失敗した:", err);
});先ほどのコールバック地獄と比較すると、.then() を横に並べる形(Promiseチェーン)で書けるためネストが浅くなる。さらに .catch() を1つ置くだけで、チェーンのどこで失敗してもそこに処理が流れてくる。
チェーンの仕組み
チェーン(chain)とは「鎖のようにつなげる」という意味で、Promiseの世界では「.then() を連続してつなげて書けること」を指す。これができるのは、.then()自体が新しいPromiseを返すためである。
graph LR
P0["readFilePromise()<br/>Promise<string>"] -->|resolve| T1[".then(data => ...)"]
T1 -->|戻り値を包んだ<br/>新しいPromise| T2[".then(data2 => ...)"]
T2 -->|戻り値を包んだ<br/>新しいPromise| T3[".catch(err => ...)"]
.then()の中で通常の値をreturnすると、次の.then()にその値がそのまま渡る.then()の中でPromiseをreturnすると、そのPromiseが解決するまで次の.then()の実行が待たされ、解決した値が渡る(先ほどの例のreturn readFilePromise("data2.txt")がこれにあたる)- どこかの
.then()の中で例外が発生する、またはrejectされたPromiseを返すと、それ以降の.then()はすべてスキップされ、一番近い.catch()に処理が飛ぶ
async/await
Promiseチェーンは、コールバック地獄よりは読みやすいが、.then() が増えてくるとやはり追いにくくなる。async/awaitは、Promiseをあたかも同期処理のように書けるようにする構文である。
// src/example/async-await.ts
async function loadFiles() {
try {
const data = await readFilePromise("data.txt");
console.log("読み込んだ内容:", data);
const data2 = await readFilePromise("data2.txt");
console.log("2つ目のファイル:", data2);
} catch (err) {
console.error("どこかで失敗した:", err);
}
}先ほどの .then() チェーンと処理内容は同じだが、上から下に読めば処理の順番がそのまま追える。ポイントは次の2つ。
- 関数の先頭に
asyncを付けると、その関数は自動的にPromiseを返す関数になる - 関数の中で
awaitを付けると、そのPromiseが解決(resolve/reject)されるまでその関数の中だけ処理を止めて待つ
エラーハンドリングは try/catch
async/awaitでは、Promiseが reject されると await の箇所で例外(throw)が発生した扱いになる。そのため、Promiseの .catch() の代わりに通常の try/catch でエラーハンドリングできる。
sequenceDiagram
participant Caller as 呼び出し元
participant Func as async関数
participant Promise as Promise(非同期処理)
Caller->>Func: loadFiles() を呼ぶ
Func->>Promise: await readFilePromise()
Note over Func: ここで処理が一時停止
Promise-->>Func: resolve(data) または reject(err)
alt 成功
Func->>Func: 続きの処理を実行
else 失敗
Func->>Func: catchブロックへジャンプ
end
Func-->>Caller: 結果を返す(Promiseとして)
3つの書き方の比較
同じ「非同期でデータを取得してログに出す」処理を3通りで書くと次のようになる。
| 観点 | コールバック | Promise(.then) | async/await |
|---|---|---|---|
| 複数の非同期処理を連結 | ネストが深くなる | .then() を横に並べる | 上から下に順番に書ける |
| エラーハンドリング | 各コールバックで個別に書く | .catch() で一括 | try/catch で一括 |
| 可読性 | 処理が増えると悪化 | コールバックよりは良い | 同期処理に近く読みやすい |
| 内部的な仕組み | 独自 | Promiseそのもの | Promiseの糖衣構文(syntax sugar) |
糖衣構文(syntax sugar)とは、内部的な仕組みは変えずに、より書きやすい・読みやすい見た目を提供する構文のこと。async/awaitは内部的にはPromiseそのものであり、await は「Promiseが解決するまで待って、結果を取り出す」という .then() の言い換えにすぎない。
エラーハンドリングの方法
非同期処理のエラーハンドリングは書き方ごとにルールが異なる。ここで3つを並べて整理する。
コールバック:エラーファースト規約
Node.jsの標準APIの多くは、コールバックの第1引数にエラーを渡すという慣習に従っている。これをエラーファースト・コールバック(error-first callback)と呼ぶ。
// src/example/error-first.ts
fs.readFile("data.txt", "utf-8", (err, data) => {
if (err) {
// 第1引数にエラーが入っていたら失敗
console.error(err);
return;
}
// エラーがなければ第2引数以降が結果
console.log(data);
});if (err) のチェックを一箇所でも書き忘れると、そのエラーは握りつぶされて誰にも気づかれなくなる。呼び出し箇所すべてで手動チェックが必要な点が、後述の2つとの大きな違い。
Promise:.catch()
Promiseチェーンでは、.then() をいくつつなげても、末尾に .catch() を1つ置くだけでチェーン全体のエラーを一箇所で拾える(詳しくは前述のチェーンの仕組みを参照)。
readFilePromise("data.txt")
.then((data) => doSomething(data))
.then((result) => doSomethingElse(result))
.catch((err) => console.error(err)); // どの.then()で失敗してもここに来るasync/await:try/catch
async/awaitでは、await した処理が reject されると通常の例外(throw)と同じ扱いになるため、複数の await をまとめて1つの try/catch で囲める。
try {
const data = await readFilePromise("data.txt");
const result = await doSomething(data);
const final = await doSomethingElse(result);
} catch (err) {
console.error(err); // どのawaitで失敗してもここに来る
}3つの比較
| 書き方 | エラーの受け取り方 | 書き忘れた場合 |
|---|---|---|
| コールバック | 第1引数の err を毎回チェック | エラーが握りつぶされ、何も起きずに処理が止まる |
| Promise | .catch() をチェーン末尾に1つ置く | 未処理のPromise拒否(unhandled rejection)になる |
| async/await | try/catch で囲む | 例外が呼び出し元へそのまま伝播する |
よくある落とし穴
await を書き忘れる
// NG: awaitを忘れるとPromiseオブジェクトそのものが返る
async function getUserName(id: string) {
const user = getUser(id); // Promise<User> が代入される
return user.name; // undefined(userはPromiseなので.nameは存在しない)
}// OK
async function getUserName(id: string) {
const user = await getUser(id); // User本体が代入される
return user.name;
}async が付いた関数を呼ぶときは、必ずPromiseが返ってくることを意識し、値が欲しい場合は await を付ける。
forEach の中で await しても待ってくれない
// NG: forEachのコールバックはawaitしても外側の処理を待たない
async function processAll(ids: string[]) {
ids.forEach(async (id) => {
await processOne(id);
});
console.log("全部終わった"); // 実際は全部終わる前に実行される
}forEach は非同期関数を渡しても、その完了を待たずに次のループへ進んでしまう。順番に確実に待ちたい場合は for...of を使う。
// OK: for...ofならawaitがちゃんと機能する
async function processAll(ids: string[]) {
for (const id of ids) {
await processOne(id);
}
console.log("全部終わった");
}並行に実行してよいなら Promise.all と map を組み合わせる方法もある。
// OK: 順番を問わず並行実行してよい場合
async function processAll(ids: string[]) {
await Promise.all(ids.map((id) => processOne(id)));
console.log("全部終わった");
}async関数内のエラーをtry/catchし忘れる
await した処理が reject されたのに try/catch がないと、その例外は関数の外へそのまま伝播する。呼び出し元でも catch していないと、未処理のPromise拒否(unhandled rejection)としてプロセスが警告を出したり、Node.jsのバージョンによっては落ちたりする。非同期処理を呼ぶ側では、必ずどこかで catch する経路を用意しておく。