非同期処理の設計判断 — コールバック地獄の構造と、同期処理のままでよい場面
非同期処理の基礎の応用編。コールバック地獄が発生する構造的な条件、なぜ結果を戻り値で返せないのか、同期処理のままで問題ない場面の判断基準を掘り下げる
TL;DR
- コールバック地獄が起きるのは「①逐次依存がある」「②結果を戻り値ではなくコールバックの引数としてしか受け取れない」の2条件が両方そろったときだけ
- 「結果を戻り値で返せない」のは技術的な限界ではなく、シングルスレッドを止めないための意図的な設計判断(
fs.readFileSyncのように戻り値ベースの版が別に存在することからも分かる) - 非同期処理が有効なのは「I/O待ちが発生し、かつ同じスレッドを共有する他の処理があるとき」に限られる。待つものがない純粋な計算やCPUを使う重い計算そのものには非同期は効かない
この記事でやること
非同期処理の基礎 では、コールバック・Promise・async/awaitという3つの「書き方」を扱った。この記事ではもう一段掘り下げて、「なぜコールバック地獄が起きるのか」「なぜ非同期が必要になる場面とそうでない場面があるのか」という構造・判断基準を扱う。
前提
- 非同期処理の基礎 を読んでいること
コールバックは本来「非同期」を意味しない
「コールバック関数を渡す」という構文自体は、同期・非同期どちらの場合にも使われる。
// 同期(ブロッキング)なコールバック
[1, 2, 3].forEach((item) => console.log(item));
// 配列を舐めながらその場で即座に呼ばれる。待つものは何もない// 非同期(ノンブロッキング)なコールバック
fs.readFile("data.txt", (err, data) => console.log(data));
// OSのI/O完了後、イベントループ経由で後から呼ばれるコールバックが同期的に呼ばれるか非同期的に呼ばれるかは、呼び出される側の関数の実装次第であり、「コールバックだから非同期」という一般ルールはない。非同期処理の基礎の getUser / getOrders / getOrderDetail は、fs.readFile と同様にDBやAPIへの問い合わせを表す想定(=非同期I/O)として扱っている。
コールバック地獄が発生する条件
基礎記事のコールバック地獄の例を振り返る。
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);
});
});
});getUser → getOrders → getOrderDetail の間には、getOrders が getUser の結果(user.id)を必要とし、getOrderDetail が getOrders の結果(orders[0].id)を必要とするというデータの依存関係がある。これにより3者は結果的に順番が固定された逐次実行になる。
ただし、この「逐次依存」だけではネストは強制されない。もし getUser などが同期関数で戻り値を返すなら、依存関係があってもフラットに書ける。
// 逐次依存はあるが、同期(戻り値ベース)ならネストは不要
const user = getUser(userId);
const orders = getOrders(user.id);
const detail = getOrderDetail(orders[0].id);ネストが強制される直接の原因は、非同期処理が「結果を戻り値として同期的に返せず、後から呼ばれるコールバック関数の引数としてしか渡せない」という結果の受け渡し方式(継続渡しスタイル、Continuation-Passing Style)にある。次の getOrders を呼ぶための材料(user.id)は getUser のコールバック引数の中にしか存在しないため、getOrders の呼び出し自体をそのコールバックの中に書かざるを得ない。これが1段ずつ積み重なっていく。
まとめると、コールバック地獄が発生するのは次の2条件が両方そろったときである。
- 逐次依存がある(前の結果がないと次を呼べない)
- その結果が戻り値ではなく、後から呼ばれるコールバックの引数としてしか受け取れない
どちらか一方が欠けていれば、ネストは発生しない。逐次依存がなければ(互いに独立した非同期処理なら)並べて呼ぶだけでよいし、同期で戻り値が返るなら普通の変数代入で済む。
なぜ結果を戻り値で返せないのか
「戻り値で返せない」のは技術的な限界ではなく、意図的な設計判断である。実際 fs.readFile(非同期)に対して fs.readFileSync(同期)という戻り値ベースの版も存在する。
// 同期版:技術的には書けるが…
const data = fs.readFileSync("data.txt", "utf-8");readFileSync を使うと、ディスクの読み込みが終わるまでスレッド全体が完全に停止し、その間は他のリクエストや処理が一切進められなくなる。自分で getUser をゼロから書いても事情は同じである。
// 自分で書いても、同期実装は「できるが、やるべきではない」
function getUserSync(userId: string): User {
const response = httpRequestSync(`/users/${userId}`); // ネットワーク応答が来るまでスレッドが停止
return JSON.parse(response.body);
}
// だからあえてこう書く:即座にreturn(値はまだない)し、
// 値の受け渡しをコールバックに委ねる
function getUser(userId: string, callback: (err: Error | null, user?: User) => void) {
httpRequestAsync(`/users/${userId}`, (err, response) => {
if (err) return callback(err);
callback(null, JSON.parse(response.body));
});
}「触れないモジュールだから仕方なく」ではなく、「シングルスレッドを止めないために、値がまだ存在しない時点であえて即座に return し、値が用意できたら後からコールバックで届ける」という設計上の選択が本質である。
同期処理のままでよい場面
「非同期処理の基礎」を読むと「基本はすべて非同期にすべき」と感じるかもしれないが、実際のコードベースには同期処理も多く残っている。これは設計ミスではなく、次のいずれかに該当する処理だからである。
graph TD
A["この処理を非同期にすべきか?"]
A --> B{"待つもの(I/O)があるか?"}
B -->|ない、純粋な計算| C["非同期にする理由がない<br/>同期のままでよい"]
B -->|ある| D{"同じスレッドを共有する<br/>他の処理があるか?"}
D -->|ない(CLI・起動処理など)| E["ブロッキングしても実害なし<br/>同期のままでもよい"]
D -->|ある(サーバーなど)| F{"CPUを使う重い計算か?"}
F -->|YES| G["asyncで包むだけでは解決しない<br/>Worker Threads等で別スレッドへ"]
F -->|NO(I/O待ちのみ)| H["非同期にすべき"]
1. 待つものがない純粋な計算
DTOのバリデーション、金額計算、文字列整形、配列のフィルタ・ソートなど、業務ロジックの多くはそもそも待つものがない計算である。これらを async にしても待つ対象がなく、Promiseの管理コストが余計にかかるだけである。
2. 同じスレッドを共有する他の処理がない場面
非同期が重要なのは「シングルスレッドを止めると他のリクエストや処理も巻き添えで止まる」からである。逆に、同じスレッド上で同時に捌くべき他の処理がなければ、ブロッキングしても実害はない。CLIツールやビルドスクリプト、サーバー起動前の設定読み込みなどでは、意図的に同期で書いて「確実に完了してから次に進む」ことを保証する場合がある。
3. CPUを使う重い計算そのもの
async function で包んでも、計算中は依然としてスレッドを占有し続ける。async/await が有効なのは、内部でOS・カーネルレベルの非同期I/O(ディスク・ネットワーク)を使っている場合だけである。純粋なCPU計算を並行化したいなら、Worker Threads(別スレッド)や子プロセスに逃がす必要があり、構文を async にするだけでは解決しない。