最近、async/awaitについてちょっと考えてたんだけど。あれ、便利だよね。マジで。コールバック地獄から解放されて、同期処理みたいに書けるようになった。でも、なんか…その便利さの裏で、静かに「税金」を払わされてる感じがするんだ。
ほとんどのバグはasync/awaitそのものが原因じゃない。正直、僕たちの「使い方」が原因。気づかないうちに処理が直列化されてたり、誰も見てないところでタスクが暴走してたり、エラーが握りつぶされてたり。キャンセルなんて概念、最初からなかったみたいに。
今日はその「見えない税金」、つまりasync/awaitのよくある誤用パターンがどうやって遅延を生み出してるのかを掘り下げてみる。で、そのあと「構造化並行性」っていう考え方を使って、根本からアプローチを再構築する方法を探っていこうかな、と。
TL;DR
非同期処理をただawaitで並べるだけだと、見えないコストがどんどん積み重なっていく。だから「構造化並行性」っていう設計思想を取り入れて、タスクの親子関係とか、エラー時の自動キャンセルとか、そういうのをちゃんと管理できる仕組みで、コードをもっと堅牢に、そして予測可能にしようって話。
よくある落とし穴、たぶん君もハマってる
awaitって同期っぽく見えるけど、そうじゃない。これが全ての元凶かも。awaitすると、実際には裏でこんなことが起きてる。
- 後続の処理をマイクロタスクとしてキューに登録する。
- 一旦イベントループに制御を返す。
- 後で、全然違うタイミングで処理が再開される。
だからawaitを挟んで共有データをいじるコードは、タイミングに依存する賭けみたいなもの。いつか絶対負ける。
その1:滝のように流れる処理(ループ内await)
これ、本当によく見る。自戒も込めて。ループの中でawaitしちゃうやつ。レイテンシがアイテム数に比例してどんどん増えていく。
// やっちゃいがちな直列実行
for (const url of urls) {
// ネットワーク往復がN回、直列で発生する
const data = await fetchJson(url);
consume(data);
}
これの直し方は、もう知ってる人も多いと思う。先に全部並行で走らせて、後で結果をまとめる。そう、Promise.all。
// 並列化して、後で合流
const results = await Promise.all(urls.map(fetchJson));
results.forEach(consume);
でもね、Promise.allは一つでも失敗すると、全部が即座にリジェクトされるから注意が必要。一つや二つコケてもいいから、成功したやつだけ欲しい、みたいな「ベストエフォート」が求められる場面では、Promise.allSettledの方がいい。
const settled = await Promise.allSettled(urls.map(fetchJson));
// fulfilledになったものだけ取り出す
const ok = settled.flatMap(r => r.status === 'fulfilled' ? [r.value] : []);
もっと言うと、相手のAPIに負荷をかけすぎないように、同時実行数を制限したい時もある。そういうときは、自前で簡易的な並行処理プールを作るのも手だね。
その2:ゾンビタスク(放置されたPromise)
個人的にはこれが一番厄介だと思ってる。ユーザーがもう別のページに移動したとか、リクエストがタイムアウトしたとか、そういう状況なのに、裏で始めた処理が誰にも看取られずに走り続けてるやつ。エラーが出ても、ログの片隅に記録されるだけ。最悪なのは、最近のNode.js (v15以降) だと、ハンドルされないPromiseリジェクトはプロセスごとクラッシュさせる可能性があるってこと。
// 実行しっぱなし。誰も面倒を見ない
async function handle(req) {
doBackgroundSync(); // awaitもされないし、エラーもキャッチされない
return new Response('ok');
}
これの対策は、ちゃんとawaitするか、あるいは「監視付き」でバックグラウンド実行すること。
// 監視付きバックグラウンドタスク
function runDetached(task) {
task().catch(err => {
console.error('デタッチされたタスクが失敗しました', err);
});
}
…でも、もっと良い方法がある。それが次のセクションで話す「スコープ」の概念。スコープを使えば、こういうゾンビタスクの発生を原理的に防げるようになる。
その3:無関係なのに待っちゃう
これも意外とある。互いに依存関係がない非同期処理を、なぜか順番にawaitしちゃうパターン。
// 本来なら並行で取ってこれるはずの2つ
const user = await getUser();
const feed = await getFeed();
これも修正は簡単。両方同時にスタートさせて、Promise.allで待つだけ。
// 同時にスタート
const [user, feed] = await Promise.all([getUser(), getFeed()]);
単純だけど、塵も積もれば…で、こういう小さな待ち時間が積み重なって、全体のパフォーマンスをじわじわ悪化させていくんだ。
その4:キャンセルもタイムアウトもない世界
ネットワークの接続が切れたり、ユーザーがページを離れたりしても、タスクが延々と走り続ける。リトライ処理がどんどん積み重なって、システム全体に負荷をかける。心当たり、ない?
これを解決するのがAbortController。こいつを使って作ったsignalを、あちこちの非同期関数に引き回していく。
async function fetchJson(url, signal) {
const res = await fetch(url, { signal });
return res.json();
}
async function loadDashboard() {
const ac = new AbortController();
// 3秒でタイムアウトさせるタイマー
const t = setTimeout(() => ac.abort(new Error('deadline exceeded')), 3000);
try {
const [profile, widgets] = await Promise.all([
fetchJson('/api/profile', ac.signal),
fetchJson('/api/widgets', ac.signal),
]);
return { profile, widgets };
} finally {
// 成功しても失敗してもタイマーはクリアする
clearTimeout(t);
}
}
finallyブロックでちゃんと後始末するのがポイント。これで、時間のかかりすぎる処理や、不要になった処理を途中で打ち切れるようになる。
そこで出てくるのが「構造化並行性」
今まで見てきた問題って、個別のテクニック(Promise.allとかAbortController)で対症療法はできる。でも、もっと根本的な解決策がある。それが「構造化並行性 (Structured Concurrency)」っていう考え方。
他の言語、例えばKotlinのコルーチンやSwiftのConcurrencyなんかには、最初から「タスクスコープ」っていう概念が組み込まれてる。要するに、
- 子タスクが生きられる「場所」(スコープ)を提供する。
- 子タスクが一つでも失敗したら、他の兄弟タスクも全部キャンセルする。
- スコープを抜けるときには、起動した全ての子タスクが終了(成功 or 失敗 or キャンセル)していることを保証する。
JavaScriptには標準でこの仕組みはない。でも、小さなヘルパー関数で、この恩恵のほとんどを享受することは可能。ちょっと長いけど、これがその一例。
// 最小限の構造化並行スコープ
export async function withScope(body) {
const ac = new AbortController();
const tasks = new Set();
let failed = false;
const ctx = {
signal: ac.signal,
cancel: (reason) => ac.abort(reason),
async spawn(name, fn) {
if (ac.signal.aborted) {
throw new Error(`スコープがキャンセルされた後にspawnは呼べません: ${name}`);
}
const p = fn(ac.signal);
tasks.add(p);
try {
return await p;
} catch (e) {
failed = true;
ac.abort(e); // 一つでも失敗したら、全体にキャンセル信号を送る
throw e;
} finally {
tasks.delete(p);
}
}
};
try {
const result = await body(ctx);
return result;
} finally {
if (failed && !ac.signal.aborted) {
ac.abort(new Error('scope failure'));
}
// スコープ内の全タスクが完了するのを待つ
await Promise.allSettled([...tasks]);
}
}
これ、どうやって使うかっていうと、こんな感じ。
// withScope の使用例
const dashboardData = await withScope(async ({ spawn }) => {
// spawnでタスクを起動する
const profileP = spawn('profile', s => fetchJson('/api/profile', s));
const feedP = spawn('feed', s => fetchJson('/api/feed', s));
const metricsP = spawn('metrics', s => fetchJson('/api/metrics', s));
// 結果を待つのは今まで通り
const [profile, feed, metrics] = await Promise.all([profileP, feedP, metricsP]);
return { profile, feed, metrics };
});
これがなぜ良いのか?
- タスクの「やり忘れ」がなくなる。スコープが全部終わるのを待ってくれるから。
- 失敗が「構造的」になる。一つの子タスクが失敗 → 他の兄弟もキャンセル → スコープ全体が失敗、という流れが強制される。
- 既存の
AbortSignalを受け取る関数をそのまま使える。
ゾンビタスクは生まれないし、エラー処理も一箇所にまとめられる。コードの見通しが劇的に良くなるんだ。
どう使い分ける?パターン比較
じゃあ、結局いつ何を使えばいいのか。ちょっと整理してみよう。
| 評価項目 | 素朴なawait | Promise.all / allSettled | 構造化スコープ (withScope) |
|---|---|---|---|
| 並列実行 | 無理。直列になる。 | 基本はこれ。一番手軽。 | spawnを使えば自然に並列になる。こっちが本領。 |
| エラーハンドリング | 個別のtry/catch地獄。見通しが悪い…。 | allは即時失敗。allSettledは個別に対応が必要。でもタスクは止まらない。 | スコープ全体でキャッチできる。一つの失敗が兄弟タスクも止めてくれるのが最高。 |
| キャンセル処理 | 自分でAbortControllerを管理しないと。まず考えられてないことが多い。 | これも自前でsignalを渡さないとダメ。raceと組み合わせたり…面倒。 | スコープを抜ける時に自動で。タイムアウトも組み合わせやすい。 |
| ゾンビタスク | 一番作りやすい(笑)。放置されたPromiseの温床。 | これだけでは防げない。結局、監視してないタスクはゾンビになりうる。 | 原理的に発生しない。スコープが後片付けを保証してくれるから。 |
まだある、便利な非同期パターン
構造化並行性以外にも、知ってると役立つパターンがいくつかある。
共有ステートを守る「アクターモデル」
awaitをまたいで状態を変更するとき、競合状態 (Race Condition) が起きやすい。これを防ぐための一つの方法が、アクターモデルの真似事。要するに、状態への書き込みを「一人ずつ」に制限するキューを作る。
// シングルライターのキュー(簡易アクター)
class Mailer {
#queue = Promise.resolve();
send(msg) {
// 今あるキューの最後尾に新しい処理を繋げる
this.#queue = this.#queue.then(() => actuallySend(msg));
return this.#queue;
}
}
これで、sendが同時に何回呼ばれても、actuallySendは必ず一つずつ順番に実行される。状態の不整合を防ぐのにめっちゃ便利。
これ、全部銀の弾丸じゃないから注意ね
もちろん、これらのテクニックにも注意点はある。
例えば、AbortSignal.timeout()。あれ、すごく便利なんだけど、ランタイムによって投げるエラーの種類が違うことがある。MDNのドキュメント(主にUS版が先行するけど)だとTimeoutErrorを投げるって書いてあるけど、古いNode.jsのバージョンとかだと単なるAbortErrorだったりする。だから、エラーのnameプロパティを見て処理を分岐させるようなコードは、ちょっと慎重に書いたほうがいいかもね。こういうのは、公式ドキュメントだけじゃなくて、実際に使われている環境の挙動も確認するのが大事。
構造化スコープも、結局はスコープ内で呼び出す関数がちゃんとsignalを尊重してくれないと、キャンセルが効かない。fetchみたいに標準で対応してるものはいいけど、自作の重い処理なんかは、自分で定期的にsignal.abortedをチェックする処理を入れないと意味がない。
まとめ
async/awaitは、コードを読みやすくしてくれた。これは間違いない。でも、それだけだと不十分で、知らないうちにパフォーマンスや信頼性を損なうコードを書きがち。
「構造化並行性」っていう設計思想を取り入れて、タスクをスコープの中で管理する。エラーやキャンセルを構造的に扱えるようにする。そうすることで、僕たちの書く非同期コードは、もっと堅牢で、予測しやすいものになるはず。
これは単なるバグ回避じゃない。末端のレイテンシを削減して、スループットを安定させる、いわば「時間を取り戻す」ための投資なんだと思う。
ちなみに、みんなが今まで追いかけた非同期処理のバグで一番ヤバかったのって何? 放置されたゾンビタスク? それとも予期せぬレースコンディション? よかったらコメントで武勇伝、聞かせてよ。
