JavaScript 事件循環與非同步
並行與競態條件(Race Condition)
簡介
在 JavaScript 的執行環境(瀏覽器或 Node.js)中,事件循環(Event Loop) 是程式碼能夠同時處理多件事的核心機制。即使 JavaScript 本身是單執行緒語言,我們仍然可以透過非同步 API(setTimeout、Promise、fetch…)實現「看似」同時執行的工作。
然而,當多個非同步操作競爭同一資源或相互依賴時,常會遇到 競態條件(Race Condition)。如果沒有妥善控制執行順序,程式的行為可能會變得不可預期,甚至產生嚴重的錯誤。
了解競態條件的形成原因、如何偵測以及最佳的防範策略,是每位 JavaScript 開發者在撰寫可靠非同步程式時不可或缺的基礎。
核心概念
1. 什麼是競態條件?
競態條件發生在 兩個或以上的非同步任務同時存取或修改同一份資料,且它們的執行順序會影響最終結果。因為 JavaScript 的事件循環會把任務排入「任務佇列」或「微任務佇列」中,何時被取出執行取決於許多因素(例如 I/O 完成時間、setTimeout 的延遲、Promise 的解決時機),所以 順序往往是不可預測的。
例子:兩個 API 同時寫入同一個全域變數,最終變數的值取決於哪個請求先完成。
2. 任務佇列 vs. 微任務佇列
| 類型 | 何時加入佇列 | 何時執行 |
|---|---|---|
| 宏任務(Macro‑task) | setTimeout、setInterval、I/O、UI 事件 等 |
事件循環每次迭代結束後,從宏任務佇列取出最前面的任務 |
| 微任務(Micro‑task) | Promise.then / catch / finally、queueMicrotask、MutationObserver |
在同一次事件迭代結束前,會先清空所有微任務再處理下一個宏任務 |
了解這兩種佇列的差異,有助於 預測非同步程式的執行順序,從而避免競態條件。
3. 競態條件的常見類型
| 類型 | 說明 | 典型情境 |
|---|---|---|
| 讀寫競爭(Read‑Write Race) | 多個任務同時讀取/寫入同一變數 | 多個 fetch 同時更新全域快取 |
| 初始化競爭(Initialization Race) | 資源在未完成初始化前被使用 | 第一次呼叫 API 時,尚未完成 token 取得 |
| 順序競爭(Order Race) | 必須依序執行的步驟被打亂 | 先上傳檔案再取得檔案 URL,卻先取得 URL 失敗 |
4. 觀察競態條件的徵兆
- 結果不穩定:相同的輸入,執行多次得到不同的輸出。
- 偶發錯誤:只有在高併發或特定環境下才會拋出例外。
- 資料不一致:如快取與資料庫的值不同步。
當以上情況出現時,通常是競態條件在作怪。
程式碼範例
以下示範 5 個常見的競態條件情境,並提供 解決方案(使用 async/await、Promise.all、鎖(lock)等)作為參考。
範例 1:簡單的讀寫競爭
let counter = 0;
// 兩個非同步任務同時遞增 counter
function incAsync() {
return new Promise(resolve => {
setTimeout(() => {
const before = counter;
// 模擬其他運算
const after = before + 1;
counter = after; // 可能被另一個任務覆蓋
resolve(counter);
}, Math.random() * 100);
});
}
Promise.all([incAsync(), incAsync()]).then(values => {
console.log('final counter:', counter); // 可能是 1,也可能是 2
});
問題:兩個 setTimeout 同時讀取舊的 counter,導致 遺失 其中一次遞增。
解法:使用 原子操作 或 鎖(此處示範 async/await 搭配 Mutex):
class Mutex {
constructor() { this._locked = false; this._queue = []; }
lock() {
const unlock = () => {
this._locked = false;
if (this._queue.length) this._queue.shift()();
};
if (this._locked) return new Promise(res => this._queue.push(() => { this._locked = true; res(unlock); }));
this._locked = true;
return Promise.resolve(unlock);
}
}
const mutex = new Mutex();
async function incSafe() {
const release = await mutex.lock();
try {
counter += 1;
} finally {
release(); // 必須釋放鎖
}
}
Promise.all([incSafe(), incSafe()]).then(() => {
console.log('safe counter:', counter); // 永遠是 2
});
範例 2:初始化競爭(Token 取得)
let authToken = null;
// 假設多個 API 同時需要 token
async function getToken() {
if (authToken) return authToken; // 已有 token
// 同時發出多個請求會造成多次取得
const res = await fetch('/api/token');
const data = await res.json();
authToken = data.token;
return authToken;
}
// 兩個請求同時呼叫
Promise.all([getToken(), getToken()]).then(tokens => {
console.log(tokens); // 兩個相同 token,但實際上發送了兩次 HTTP 請求
});
解法:一次取得,重複使用,並在取得期間共享同一個 Promise:
let tokenPromise = null;
function getTokenOnce() {
if (authToken) return Promise.resolve(authToken);
if (!tokenPromise) {
tokenPromise = fetch('/api/token')
.then(r => r.json())
.then(data => {
authToken = data.token;
tokenPromise = null; // 清除暫存
return authToken;
});
}
return tokenPromise;
}
// 兩個呼叫只會發送一次請求
Promise.all([getTokenOnce(), getTokenOnce()]).then(tokens => {
console.log('single request tokens:', tokens);
});
範例 3:順序競爭(上傳檔案後取得 URL)
let fileUrl = null;
// 假設先上傳檔案,再從伺服器取得可分享的 URL
async function upload(file) {
const res = await fetch('/upload', { method: 'POST', body: file });
const data = await res.json();
return data.id; // 回傳檔案 ID
}
async function getUrl(id) {
const res = await fetch(`/file/${id}`);
const data = await res.json();
return data.url;
}
// 錯誤寫法:兩個 async 任務同時執行,可能先執行 getUrl
async function wrongFlow(file) {
const idPromise = upload(file);
const urlPromise = getUrl(await idPromise); // 仍然會等 id,但若寫成下面的錯誤方式就會出問題
return urlPromise;
}
改寫:確保順序,使用 await 或 Promise.then 鎖定先後:
async function correctFlow(file) {
const id = await upload(file); // 必須先完成上傳
const url = await getUrl(id); // 再取得 URL
return url;
}
範例 4:使用 Promise.race 時的競態條件
function fetchWithTimeout(url, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), ms)
);
const fetchP = fetch(url).then(r => r.json());
// 若 fetch 完成較慢,timeout 會先 reject,後續 fetch 仍在執行
return Promise.race([fetchP, timeout]);
}
// 呼叫時若不處理後續 fetch,可能造成資源浪費或不一致狀態
fetchWithTimeout('/api/data', 2000)
.catch(err => console.error(err));
解法:在 timeout 後取消請求(若瀏覽器支援 AbortController):
function fetchWithAbort(url, ms) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ms);
return fetch(url, { signal: controller.signal })
.then(r => r.json())
.finally(() => clearTimeout(timeout));
}
範例 5:多執行緒(Node.js Worker)共享變數的競態條件
// main.js
const { Worker, isMainThread, parentPort } = require('worker_threads');
let shared = { count: 0 };
function startWorker() {
const worker = new Worker(__filename);
worker.on('message', delta => {
shared.count += delta; // 可能同時被多個 worker 更新
});
}
if (isMainThread) {
startWorker();
startWorker();
} else {
// worker 執行緒
parentPort.postMessage(1); // 每個 worker 只傳送 1
}
解法:使用 Atomics 搭配 SharedArrayBuffer:
// main.js
const { Worker, isMainThread, parentPort } = require('worker_threads');
const sharedBuf = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
const sharedArr = new Int32Array(sharedBuf); // 初始值 0
function startWorker() {
const worker = new Worker(__filename, { workerData: sharedBuf });
}
if (isMainThread) {
startWorker();
startWorker();
// 5 秒後檢查結果
setTimeout(() => console.log('final count', Atomics.load(sharedArr, 0)), 100);
} else {
const { workerData } = require('worker_threads');
const arr = new Int32Array(workerData);
Atomics.add(arr, 0, 1); // 原子遞增
}
常見陷阱與最佳實踐
| 陷阱 | 可能的後果 | 防範方式 |
|---|---|---|
忘記 await |
產生隱式的 Promise,程式提前往下執行 |
始終使用 await 或 then,必要時加上 ESLint 規則 (promise/always-return) |
| 同時觸發多次相同請求 | API 流量浪費、資料不一致 | 去重(debounce / throttle)、共享 Promise(如範例 2) |
| 使用全域變數作為快取 | 多個任務同時寫入導致競爭 | 使用封裝好的快取物件,或 Mutex |
未處理 Promise.race 的後續執行 |
超時後仍有未取消的 I/O,可能佔用資源 | AbortController、finally 清理 |
| 在 UI 事件中直接修改狀態 | 事件觸發頻繁時導致渲染抖動 | 批次更新(batching)、requestAnimationFrame |
具體的最佳實踐
- 將非同步流程視為資料流:使用 RxJS、Async Generators 或 Observables,讓資料的「產生」與「消費」分離,減少意外的競爭。
- 最小化共享可變狀態:盡量把資料封裝在閉包或模組內,避免直接操作全域變數。
- 使用原子操作或鎖:在 Node.js 中可利用
worker_threads+Atomics,在瀏覽器端可自行實作 Mutex(利用Promise排程)。 - 明確定義 API 的「一次性」行為:例如「取得使用者資料」只允許同時發出一次請求,其餘呼叫直接返回同一個
Promise。 - 加入單元測試與壓力測試:利用
jest、mocha搭配fake timers或nock,模擬高併發情境,驗證結果的一致性。
實際應用場景
| 場景 | 競態條件可能出現的原因 | 解決方式 |
|---|---|---|
| 即時聊天系統(WebSocket + 本地快取) | 多條訊息同時寫入本地訊息列表,導致訊息順序錯亂 | 使用 FIFO Queue 或 Immutable.js,每次更新都產生新陣列 |
| 圖片上傳與縮圖服務 | 多個檔案同時上傳,縮圖服務需要先取得 檔案 ID 再生成 URL | Promise chaining 或 async/await 確保「上傳 → 產生 URL」的順序 |
| 金融交易平台 | 同時發送多筆交易請求,若不排程可能導致 超額扣款 | 分布式鎖(Redis SETNX)或 序列號(transaction ID)保證唯一性 |
| SPA 前端路由 | 使用 fetch 取得頁面資料,使用者快速切換路由,舊的請求仍可能回傳覆寫新頁面的資料 |
在每次路由切換時 取消前一個請求(AbortController)或 檢查回傳的 token 是否仍有效 |
| Node.js 背景工作(批次處理) | 多個 worker 同時寫入同一個檔案或資料庫 | 使用 資料庫鎖、檔案鎖(flock)或 工作隊列(Bull、Bee-Queue) |
總結
- 競態條件 是非同步程式中最常見且最難偵測的問題之一。
- 透過 了解事件循環的任務排程(宏任務 vs. 微任務),我們能預測哪些程式碼可能同時執行。
- 避免共享可變狀態、使用原子操作或鎖、共享同一個 Promise,是防止競態條件的核心技巧。
- 在實務開發中,明確規劃非同步流程、加入取消機制(如
AbortController)以及做好測試,才能寫出既高效又可靠的 JavaScript 應用。
掌握了這些概念與實作模式,你就能在日常開發中自信地處理各種非同步情境,讓程式碼在高併發環境下仍保持正確與可預測。祝你寫程式愉快,寫出無競態的乾淨代碼!