本文 AI 產出,尚未審核

JavaScript 事件循環與非同步

並行與競態條件(Race Condition)


簡介

在 JavaScript 的執行環境(瀏覽器或 Node.js)中,事件循環(Event Loop) 是程式碼能夠同時處理多件事的核心機制。即使 JavaScript 本身是單執行緒語言,我們仍然可以透過非同步 API(setTimeoutPromisefetch…)實現「看似」同時執行的工作。

然而,當多個非同步操作競爭同一資源相互依賴時,常會遇到 競態條件(Race Condition)。如果沒有妥善控制執行順序,程式的行為可能會變得不可預期,甚至產生嚴重的錯誤。
了解競態條件的形成原因、如何偵測以及最佳的防範策略,是每位 JavaScript 開發者在撰寫可靠非同步程式時不可或缺的基礎。


核心概念

1. 什麼是競態條件?

競態條件發生在 兩個或以上的非同步任務同時存取或修改同一份資料,且它們的執行順序會影響最終結果。因為 JavaScript 的事件循環會把任務排入「任務佇列」或「微任務佇列」中,何時被取出執行取決於許多因素(例如 I/O 完成時間、setTimeout 的延遲、Promise 的解決時機),所以 順序往往是不可預測的

例子:兩個 API 同時寫入同一個全域變數,最終變數的值取決於哪個請求先完成。


2. 任務佇列 vs. 微任務佇列

類型 何時加入佇列 何時執行
宏任務(Macro‑task) setTimeoutsetIntervalI/OUI 事件 事件循環每次迭代結束後,從宏任務佇列取出最前面的任務
微任務(Micro‑task) Promise.then / catch / finallyqueueMicrotaskMutationObserver 在同一次事件迭代結束前,會先清空所有微任務再處理下一個宏任務

了解這兩種佇列的差異,有助於 預測非同步程式的執行順序,從而避免競態條件。


3. 競態條件的常見類型

類型 說明 典型情境
讀寫競爭(Read‑Write Race) 多個任務同時讀取/寫入同一變數 多個 fetch 同時更新全域快取
初始化競爭(Initialization Race) 資源在未完成初始化前被使用 第一次呼叫 API 時,尚未完成 token 取得
順序競爭(Order Race) 必須依序執行的步驟被打亂 先上傳檔案再取得檔案 URL,卻先取得 URL 失敗

4. 觀察競態條件的徵兆

  1. 結果不穩定:相同的輸入,執行多次得到不同的輸出。
  2. 偶發錯誤:只有在高併發或特定環境下才會拋出例外。
  3. 資料不一致:如快取與資料庫的值不同步。

當以上情況出現時,通常是競態條件在作怪。


程式碼範例

以下示範 5 個常見的競態條件情境,並提供 解決方案(使用 async/awaitPromise.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;
}

改寫確保順序,使用 awaitPromise.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,程式提前往下執行 始終使用 awaitthen,必要時加上 ESLint 規則 (promise/always-return)
同時觸發多次相同請求 API 流量浪費、資料不一致 去重(debounce / throttle)共享 Promise(如範例 2)
使用全域變數作為快取 多個任務同時寫入導致競爭 使用封裝好的快取物件,或 Mutex
未處理 Promise.race 的後續執行 超時後仍有未取消的 I/O,可能佔用資源 AbortControllerfinally 清理
在 UI 事件中直接修改狀態 事件觸發頻繁時導致渲染抖動 批次更新(batching)requestAnimationFrame

具體的最佳實踐

  1. 將非同步流程視為資料流:使用 RxJSAsync GeneratorsObservables,讓資料的「產生」與「消費」分離,減少意外的競爭。
  2. 最小化共享可變狀態:盡量把資料封裝在閉包或模組內,避免直接操作全域變數。
  3. 使用原子操作或鎖:在 Node.js 中可利用 worker_threads + Atomics,在瀏覽器端可自行實作 Mutex(利用 Promise 排程)。
  4. 明確定義 API 的「一次性」行為:例如「取得使用者資料」只允許同時發出一次請求,其餘呼叫直接返回同一個 Promise
  5. 加入單元測試與壓力測試:利用 jestmocha 搭配 fake timersnock,模擬高併發情境,驗證結果的一致性。

實際應用場景

場景 競態條件可能出現的原因 解決方式
即時聊天系統(WebSocket + 本地快取) 多條訊息同時寫入本地訊息列表,導致訊息順序錯亂 使用 FIFO QueueImmutable.js,每次更新都產生新陣列
圖片上傳與縮圖服務 多個檔案同時上傳,縮圖服務需要先取得 檔案 ID 再生成 URL Promise chainingasync/await 確保「上傳 → 產生 URL」的順序
金融交易平台 同時發送多筆交易請求,若不排程可能導致 超額扣款 分布式鎖(Redis SETNX)或 序列號(transaction ID)保證唯一性
SPA 前端路由 使用 fetch 取得頁面資料,使用者快速切換路由,舊的請求仍可能回傳覆寫新頁面的資料 在每次路由切換時 取消前一個請求AbortController)或 檢查回傳的 token 是否仍有效
Node.js 背景工作(批次處理) 多個 worker 同時寫入同一個檔案或資料庫 使用 資料庫鎖檔案鎖flock)或 工作隊列(Bull、Bee-Queue)

總結

  • 競態條件 是非同步程式中最常見且最難偵測的問題之一。
  • 透過 了解事件循環的任務排程(宏任務 vs. 微任務),我們能預測哪些程式碼可能同時執行。
  • 避免共享可變狀態使用原子操作或鎖共享同一個 Promise,是防止競態條件的核心技巧。
  • 在實務開發中,明確規劃非同步流程加入取消機制(如 AbortController)以及做好測試,才能寫出既高效又可靠的 JavaScript 應用。

掌握了這些概念與實作模式,你就能在日常開發中自信地處理各種非同步情境,讓程式碼在高併發環境下仍保持正確與可預測。祝你寫程式愉快,寫出無競態的乾淨代碼!