並行処理・並列処理・競態は混同されやすい言葉ですが、まず押さえておきたいのは、競態は処理の実行方式ではなく、複数の処理が重なったときに起こり得る不具合の一種だということです。
それでも、JavaScriptを書いていると次の疑問が残ります。
JavaScriptは単一スレッドなのに、なぜ競態(Race Condition)が起きるのか。
この記事では、検索ボックスで複数のリクエストが重なるケースから、JavaScriptの非同期処理で競態が発生する理由と、その考え方・対策を整理します。
1. まずは普通の検索ボックスから考える
次のような検索処理があるとします。
async function search(keyword) {
const res = await fetch(`/api/search?q=${keyword}`);
const data = await res.json();
renderList(data);
}
入力欄が変わるたびに呼び出します。
input.addEventListener("input", (event) => {
search(event.target.value);
});
一見すると問題はありません。
ユーザーが最初にvueと入力し、その直後にreactと入力したとします。すると、vueに対するリクエストAのあとに、reactに対するリクエストBが送られます。
業務上ほしい結果は明確です。
最後に入力されたのは
reactなので、最終的な画面もreactの検索結果を表示する。
しかし、ネットワークでは先に送ったリクエストが先に返るとは限りません。
時間 ───────────────────────────────→
リクエストA: vue
├───────────────────────────────● 応答
リクエストB: react
├────────────● 応答
この場合、送信順はA→Bでも、完了順はB→Aです。
Bが返る
↓
画面にreactの結果を表示
Aが返る
↓
画面がvueの結果で上書きされる
最終的に画面へ表示されるのはvueです。しかし、ユーザーが最後に入力したのはreactです。
これが典型的な**Race Condition(競態)**です。
2. 競態では何が「競争」しているのか
「競態」と聞くと、二つのコードが同時に実行され、CPU上で競争しているように考えがちです。
しかし、フロントエンドで起きる競態の多くは、二つのJavaScriptコードが文字どおり同時に実行されることを意味しません。
本質は次の点にあります。
複数のタスクが同じ状態を変更しようとしており、最終結果が制御できない完了順に依存している。
簡単にまとめると、次の三つがそろったときに競態の危険が生まれます。
複数のタスク
+
同じ状態を変更する
+
完了順が不確定
↓
競態のリスク
先ほどの検索なら、リクエストAとBの両方が同じ検索結果を更新できます。
リクエストA ─┐
├──→ 検索結果 list
リクエストB ─┘
AにもBにもlist = dataを実行する権限があることが問題です。
3. コーヒーの注文に置き換えてみる
生活上の例に置き換えると分かりやすくなります。
9時に同僚Aへ「アメリカーノを買ってきて」と頼んだあと、1分後に気が変わり、同僚Bへ「やっぱりカフェラテをお願い」と頼んだとします。
今ほしいのはカフェラテです。
ところがBは近い店へ行ったので5分で戻り、Aは混んでいる店へ行ったため20分後に戻ってきました。
もしプログラムの規則が「最後に戻ってきた人の注文結果を採用する」なら、最終的に手元へ残るのはアメリカーノです。
しかし、本来の規則はそうではありません。
ユーザーの最新の意図を表す処理を採用する。
ここで重要なのは、完了が遅いからといって、その処理が新しいわけではないという点です。
最後に完了したタスクが、最終状態を書き込む権利を持つとは限りません。
4. JavaScriptは単一スレッドなのに、なぜ競態が起きるのか
JavaScriptのメインスレッドは、簡略化すれば「一度に一つのJavaScriptコードを実行する」と考えられます。
それでも競態が起きるのは、非同期タスクの完了順までは保証されていないからです。
一人しかレジ係がいないドリンク店を想像してみます。注文を受ける処理自体は一件ずつです。
客A
↓
レジで注文を受ける
客B
↓
レジで注文を受ける
しかし、裏側で作るドリンクの所要時間は同じではありません。
注文A: タピオカミルクティー 8分
注文B: アメリカーノ 2分
Aを先に受けても、Bが先に完成することがあります。
JavaScriptの非同期処理も同じです。
const data = await request();
awaitに到達したときに停止するのは、そのasync関数の続きです。JavaScript全体が停止するわけではありません。
待っている間にも、次のような処理は進みます。
- ユーザーは別のボタンをクリックできる
watchなど別の監視処理が発火できる- 新しいリクエストを開始できる
- ルートが変わることもある
そのため、リクエストAが待機中にリクエストBが始まり、複数の非同期タスクが時間的に重なります。
JavaScriptの単一スレッド性はコードの実行方法についての話で、競態は非同期タスク同士の時系列についての話です。
この二つは矛盾しません。
5. awaitだけでは競態を解決できない
async/awaitを使うとコードが上から下へ読めるため、「一件ずつ処理される」ように感じることがあります。
async function load(keyword) {
const data = await request(keyword);
state.value = data;
}
しかし、次のように短時間で二度呼び出せば、二つのload()は独立して進みます。
load("vue");
load("react");
起こり得る流れは次のとおりです。
load("vue")
│
├── await ───────────────────┐
│ │
load("react") │
│ │
├── await ───────┐ │
│ │
● react完了 │
│
● vue完了
それぞれのload()内部では順序が守られています。しかし、二つのload()同士には完了順の保証がありません。
awaitが解決しているのは、非同期コードを同期的に読める形で書くことです。
一方で競態で必要なのは、複数の非同期タスクが重なったときに、どのタスクを採用するかという規則です。
6. 競態を判断する「三つの質問」
非同期処理で問題が起きたとき、最初からEvent Loop、マイクロタスク、マクロタスクまで掘る必要はありません。
まず、次の三つを確認すると整理しやすくなります。
1. 複数のタスクが同時期に存在するか
たとえば、リクエストAがまだ終わっていないのに、リクエストBが始まる状態です。
複数のタスクが重ならないなら、その二つが同じ状態を競うこともありません。
2. それらが同じ状態を変更するか
たとえば、AとBの両方が次を書き込みます。
list.value = data;
この場合、list.valueは共有状態です。
共有状態はデータ本体だけではありません。次のようなUI状態も対象になります。
loading
error
currentPage
selected
form
store
3. 完了順を保証できるか
完了順を保証できないなら、競態が成立する条件はほぼそろっています。
複数のタスク
+
共有状態
+
不確定な時系列
=
競態のリスク
7. 対策を選ぶ前に「誰を勝たせるか」を決める
競態を見つけると、すぐに「古いリクエストをキャンセルしよう」と考えたくなります。
しかし、その前に決めるべきことがあります。
競合した二つのタスクのうち、業務上どちらを採用するのか。
検索ボックスでは、通常は最後の入力を反映したいので、**Latest Wins(最新の操作を採用)**が自然です。
一方、「注文を確定する」のような処理では、最初の一回だけを有効にして後続の重複操作を無視するFirst Winsが適切な場合もあります。
さらに、Aの完了後にB、その後にCを実行しなければならない処理なら、競わせるのではなくキューへ入れて順番に処理するべきです。
つまり、競態対策の出発点はAPI選びではありません。
まず業務上の勝者を定義する。
8. 検索ではリクエストに「番号札」を付ける
検索ボックスではLatest Winsを採用するとします。
最も単純な方法の一つは、リクエストごとに番号を付け、戻ってきた時点で「自分がまだ最新か」を確認することです。
let requestId = 0;
async function search(keyword) {
const currentId = ++requestId;
const res = await fetch(`/api/search?q=${keyword}`);
const data = await res.json();
if (currentId !== requestId) {
return;
}
renderList(data);
}
最初のvue検索ではrequestIdが1になり、リクエストAはcurrentId = 1を持ちます。
その後react検索が始まると、requestIdは2になり、リクエストBはcurrentId = 2を持ちます。
Bが先に返れば、currentIdと現在のrequestIdはどちらも2なので、Bはまだ最新です。そのまま画面を更新できます。
あとからAが返ると、Aが持っているcurrentIdは1ですが、現在のrequestIdは2です。
currentId = 1
requestId = 2
つまりAが待っている間に、より新しい処理が始まっています。Aの結果は捨てます。
リクエストA #1 ─────────────────────●
│
└─ 1 !== 2
破棄
リクエストB #2 ───────●
│
└─ 2 === 2
画面を更新
重要なのはrequestIdという変数名そのものではありません。
非同期タスクは完了しただけで状態を書き換えるのではなく、その時点で書き込み資格が残っているかを確認する。
この設計がポイントです。
9. requestIdは「バージョン番号」と考えられる
requestIdは、より一般化すると状態のバージョン番号です。
1回目の操作: version = 1
2回目の操作: version = 2
3回目の操作: version = 3
非同期処理が戻ってきたときに確認するのは、一つだけです。
自分が処理していたのは現在のバージョンか。
違うなら、その結果は破棄します。
実装によって名前は異なります。
requestId
version
sequence
token
generation
しかし考え方は同じです。
古いタスクは新しい状態を書き換えられない。
10. AbortControllerは何を解決するのか
バージョン番号による判定だけでも、「古い結果で最新状態を上書きしない」という正しさは確保できます。
ただし、不要になった古いリクエスト自体は動き続けます。
それを止めたい場合はAbortControllerを使えます。
let controller;
async function search(keyword) {
controller?.abort();
controller = new AbortController();
try {
const res = await fetch(`/api/search?q=${keyword}`, {
signal: controller.signal,
});
const data = await res.json();
renderList(data);
} catch (error) {
if (error.name === "AbortError") {
return;
}
throw error;
}
}
新しい検索が始まったとき、以前のリクエストをabort()し、新しいリクエストへ切り替えます。
ここでは役割を分けて考えると分かりやすくなります。
| 方法 | 主に解決すること |
|---|---|
| バージョン番号 | 古い結果に状態を書き込ませない |
AbortController | 不要になった古い処理を続けない |
つまり、バージョン管理とキャンセルは同時に使えます。
一方は正しさを守るための書き込み制御、もう一方は不要な処理を減らすためのキャンセルです。
11. loadingも競態する
競態を調べるとき、data.valueだけを見てしまうことがあります。しかし、loading.valueも共有状態です。
次の処理を考えます。
async function load() {
loading.value = true;
try {
await request();
} finally {
loading.value = false;
}
}
AとBの二つのリクエストが重なると、次のようになる可能性があります。
A開始
loading = true
B開始
loading = true
A完了
loading = false
Bはまだ完了していない
Bが動いているのに、Aのfinallyによってloadingがfalseになりました。画面上ではローディング表示が消えてしまいます。
競態の対象になり得るのは、検索結果だけではありません。
dataloadingerrordisabledselectedpage
複数の非同期タスクが同じ値を書き換えるなら、どの状態でも同じ問題が起こり得ます。
12. debounceは競態を解決するのか
debounce(デバウンス)だけでは競態を解決できません。
たとえば検索処理を300msのdebounceでまとめれば、入力のたびにリクエストを送らずに済みます。
debounce(search, 300);
debounceが解決しているのは、リクエストを何回送るかという問題です。
しかし、結果として二つのリクエストが重なれば、後から送ったBが先に返ることはあります。
A ─────────────────●
B ───────●
役割を分けると次のようになります。
| 方法 | 対象 |
|---|---|
| debounce | 何回発火・送信するかを減らす |
requestId | 重なった結果のうち、誰が書き込めるかを決める |
AbortController | 不要になった処理を中止する |
| queue | 実行順を直列化する |
発火回数の制御と、競合後の勝者を決めることは別問題です。
13. 「誰でも状態を書ける」構造を減らす
複雑なプロジェクトで非同期処理が追いにくくなる原因の一つは、同じ状態を書き換える場所が増えすぎることです。
リクエストA ─────→ state
リクエストB ─────→ state
リクエストC ─────→ state
リクエストD ─────→ state
この状態では、「現在のstateは誰が書いた値なのか」を追うだけでも難しくなります。
できるだけ、結果の生成と状態への反映を分けます。
リクエストA ─┐
リクエストB ─┼──→ 一か所で採用判定 ──→ state
リクエストC ─┘
非同期処理は結果を返し、一か所のロジックが「どの結果に書き込み資格があるか」を判断する形です。
こうすると、状態変更のルールを追いやすくなります。
14. 競態が厄介なのは、安定して再現しないから
普通の不具合なら、「この操作をすると必ず壊れる」という形で再現できることがあります。
競態はそうとは限りません。10回試して9回は正常でも、10回目だけ突然壊れることがあります。
原因は時系列への依存です。
普段は次のようにAが先に終わっているかもしれません。
A: 100ms
B: 500ms
しかし、一度だけネットワーク状況が変わると逆転します。
A: 2000ms
B: 100ms
競態を調べるときは、意図的に完了順を乱すと見つけやすくなります。
たとえばランダムな遅延を入れます。
function randomDelay() {
return new Promise((resolve) => {
setTimeout(resolve, Math.random() * 3000);
});
}
ブラウザのDevToolsでネットワークを低速化する方法もあります。
その状態で、検索語の連続入力、タブ切り替え、ルート切り替え、フィルター変更などを素早く行うと、通常は隠れている競態が表面化しやすくなります。
15. 「どちらが先に送られたか」ではなく「誰がまだ書けるか」を見る
競態を理解すると、非同期処理を見るときの質問が変わります。
以前は次のように考えがちです。
どちらのリクエストが先に送られたか。
しかし、重要なのはこちらです。
このリクエストは今も状態を書き換える資格を持っているか。
前者は実行順を見ています。後者は業務ルールを見ています。
信頼できるコードは、「Aが先に戻ってくれるはず」「ネットワークが安定しているはず」「サーバーが十分速いはず」といった偶然に依存しません。
完了順が入れ替わっても、最終状態が正しくなるように設計します。
16. 競態では「時間」が業務判断へ入り込んでいる
検索ボックスで本当に実現したい規則は、次のものです。
ユーザーが最後に入力したキーワードの検索結果を表示する。
ところが競態を放置したコードは、実質的に別の規則を実装しています。
最後に返ってきたリクエストの検索結果を表示する。
この二つは同じではありません。
本来の業務ルールは「最後に入力されたものが勝つ」です。しかし、実装は「最後に完了したものが勝つ」になっています。
その結果、本来は結果を決めるべきでないネットワークの速度が、業務上の最終状態を決める要因になります。
競態の本質は、プログラムの正しさが誤ってタスクの完了時刻へ依存してしまうこととも考えられます。
17. 非同期処理を見たら確認したい流れ
await xxx()のようなコードを見たら、次の順番で確認すると競態を見つけやすくなります。
待っている間に、同じ処理をもう一度開始できるか?
↓
できるなら、複数の処理が同じ状態を変更するか?
↓
変更するなら、完了順を保証できるか?
↓
保証できないなら、最終的に誰が書き込むべきか?
判断の式は次のようにまとめられます。
競態のリスク
=
複数のタスク
+
共有状態
+
制御できない時系列
対策側は、次の二つです。
競態の制御
=
業務ルールを明確にする
+
状態への書き込み資格を制御する
18. まとめ
JavaScriptの競態を理解するうえで、特に重要なのは次の点です。
- JavaScriptが単一スレッドでも、非同期タスクは開始順どおりに完了するとは限らない。
awaitが停止させるのは現在のasync関数であり、アプリケーション全体ではない。- 競態の中心は「同時実行しているか」より、複数のタスクが同じ状態を競っているかどうかにある。
- 対策を選ぶ前に、Latest Wins、First Wins、直列実行など、業務上の勝者を決める必要がある。
- 非同期タスクが戻ってきたときは、「完了したか」だけでなく「まだ状態を書き換える資格があるか」を考える。
信頼できる非同期コードは、タスクが偶然望ましい順序で完了することを前提にしません。
完了順が入れ替わっても、最終結果が正しくなるように設計します。
async/awaitを書けることから一歩進み、非同期状態を制御できることが、競態を扱ううえで重要です。