iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0
Software Development

消除你程式碼的臭味系列 第 27

Day 27- 同步問題:管理多執行緒與競爭條件

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250903/20124462P1N8QjGguI.png

消除你程式碼的臭味 Day 27- 同步問題:管理多執行緒與競爭條件

不要「處理」鎖,去解決你的設計問題

很多人看到競爭條件(Race Condition),反射性地就想用鎖(Lock) 去「解決」。

這就像是頭痛醫頭、腳痛醫腳。
一個好的工程師看到競爭條件,會先問:「為什麼我會有一個可變的狀態,需要被多個執行緒共享?」

好設計先消除競爭的根源,不需要修補競爭的後果。

程式碼的品質,看的不是演算法,而是資料結構
並行問題的根源,幾乎永遠都是糟糕的資料結構設計。

臭味的源頭:共享可變狀態 (Shared Mutable State)

// 🔴 臭味本體:這行程式碼本身就是問題所在
let balance = 100;

async function withdraw(amount) {
  // ...一堆混亂的讀取-修改-寫入...
}

問題不在 withdraw 函式,而在於 let balance = 100;
創造了一個誰都可以隨時讀寫的全域變數,然後才驚訝地發現人們會同時去寫它。
這是「自找麻煩」。

https://ithelp.ithome.com.tw/upload/images/20250929/20124462EgWSNb24lT.png

解決方案的正確順序

解決並行問題的唯一乾淨方法,是確保在任何一個時間點,只有一個實體(Entity)對某塊資料擁有寫入的權力。

其他的程式碼不是去「修改」資料,而是去「請求所有者進行修改」。

層級 1 (首選):消除共享狀態

與其自己管理狀態,不如將這個複雜的任務交給更專業的系統。

對於 Web 應用,答案通常是:用資料庫。

// 🟢 好味道:我根本不持有狀態,我只發出指令
async function withdraw(userId, amount) {
  // 指令的意圖很清楚:「在餘額足夠的前提下,減去指定金額」 
  // 資料庫會保證這個操作的原子性。所有競爭條件都在資料庫層級被解決了。
  const result = await db.query(
    'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2 AND balance >= $1',
    [amount, userId]
  );
  
  if (result.rowCount === 0) {
    throw new Error('餘額不足或使用者不存在');
  }
}

應用程式碼裡沒有鎖、沒有 try...finally、沒有複雜的狀態管理。
只有一個簡單的、描述「意圖」的指令,就是「好味道」。

另一個方式是訊息傳遞 (Message Passing)。讓不同的執行緒透過佇列溝通,只有一個消費者執行緒負責修改狀態。這從架構上消除了競爭。
https://ithelp.ithome.com.tw/upload/images/20250929/20124462WjlJ1UPik2.png

層級 2 (高效能記憶體內策略):使用原子操作

當你確實需要在記憶體中處理簡單的共享狀態(例如:監控指標、快取命中計數)且效能至關重要時,原子操作 (Atomics) 是比鎖更輕量、更高效的選擇。

原子操作由硬體層級保證其不可中斷性,避免了鎖帶來的上下文切換、死鎖等複雜問題。

JavaScript 在這方面通常不具備原生能力,但在 Node.js 的 worker_threads 環境中,可以透過 SharedArrayBufferAtomics 來實現。

// 🟢 Node.js 範例:使用 Atomics
// 假設在一個 Worker Thread 中
const { parentPort, workerData } = require('worker_threads');

// workerData.buffer 是一個由主執行緒共享的 ArrayBuffer
const sharedArray = new Int32Array(workerData.buffer);

// 從 index 0 的位置取出值並加上 1
// Atomics.add 是一個不可中斷的操作(原子操作),高效且執行緒安全
Atomics.add(sharedArray, 0, 1);

parentPort.postMessage('更新完成');

適用場景:主要用於計數器、旗標等簡單數值類型的無鎖(Lock-Free)操作。對於複雜的資料結構,原子操作就顯得力不從心。
https://ithelp.ithome.com.tw/upload/images/20250929/20124462Sbco1TGpOV.png

層級 3 (當鎖成為必要之選):審慎地使用真正的鎖

如果面對的邏輯非常複雜,無法用資料庫或原子操作解決(這種情況比你想像的要少得多),才需要考慮鎖。

某些高效能場景,無法避免地需要處理複雜的共享資料結構。此時,鎖成為了必要的工具。

但使用鎖,請遵守兩條鐵律:

  1. 永遠不要自己發明鎖。 請使用你的語言或框架提供的、經過千錘百鍊的 Mutex (互斥鎖) 或 Semaphore (信號量)。

  2. 鎖定的臨界區(Critical Section)要極小,且絕不包含 I/O 或其他耗時操作。

這些函式庫之所以可靠,是因為它們依賴於作業系統或底層語言提供的原子原語

錯誤的鎖用法:

// 🔴 臭味:在鎖內部執行 await 是常見的效能殺手和死鎖來源
async function badWithdraw(amount) {
  await lock.runExclusive(async () => {
    const currentBalance = balance;
    await someAsyncDelay(); // 災難!鎖被長時間佔用,等待 I/O
    balance = currentBalance - amount;
  });
}

正確的鎖用法: 鎖的目的是保護記憶體操作的瞬間,而不是整個業務流程。

// 🟢 鎖只保護必要的記憶體讀寫,立即釋放
const { Mutex } = require('async-mutex');
const lock = new Mutex();
let balance = 100;

async function withdraw(amount) {
  let newBalance;
  
  // 1. 進入臨界區,快速完成記憶體操作
  await lock.runExclusive(() => {
    if (balance >= amount) {
      balance -= amount;
      newBalance = balance;
    } else {
      throw new Error('餘額不足');
    }
  });

  // 2. 離開臨界區後,再執行耗時的 I/O 操作
  await logTransaction(newBalance);
}

即便正確使用,鎖依然會帶來死鎖、效能開銷等風險,因此它應該是我們工具箱中,經過深思熟慮後才拿出的工具。

https://ithelp.ithome.com.tw/upload/images/20250929/20124462ONpYb7AZA7.png

外部互動:冪等性是基本要求

無論你採用哪種同步策略,在分散式系統中,網路是不可靠的。
你隨時可能收到重複的請求。確保你的 API 操作可以安全地重試,這就是冪等性 (Idempotency)

// 🟢 透過唯一 ID 和狀態檢查,實現安全的重試
async function processPayment(orderId) {
  //  使用唯一的 orderId 查詢
  const order = await db.findOrder(orderId);
  
  //  檢查狀態,如果已處理,則直接成功返回
  if (order.status === 'paid') {
    return 'Already Paid';
  }

  //  在同一個交易中執行操作並更新狀態
  await db.transaction(async (tx) => {
    await paymentGateway.charge(order.amount, { transaction: tx });
    await tx.updateOrderStatus(orderId, 'paid');
  });
}

https://ithelp.ithome.com.tw/upload/images/20250929/20124462odvWTPnVE3.png

結論

停止迷戀複雜的工具。並行問題的解決方案優先順序很清楚:

  1. 首選:重新設計資料結構,消除共享狀態。 把問題交給資料庫。

  2. 進階:如果必須在記憶體中操作,使用原子操作。

  3. 最後手段:萬不得已,使用經過驗證的函式庫提供的鎖。

  4. 基本功:所有外部 API 都必須是冪等的。

在並行(Concurrent)或平行(Parallel)的環境中,許多 Bug 並非源於邏輯錯誤,而是誕生於資源共享的邊界。
這些問題難以追蹤,因為它們的出現充滿了不確定性。

主要心法:減少共享,擁抱原子,實現冪等。

設計策略:從源頭避免混亂

在動手處理鎖(Lock)之前,優先考慮從設計上避免問題:

  • 不可變性 (Immutability):盡量使用不可變資料。與其就地修改物件,不如回傳一個全新的物件。

  • 序列化存取 (Serialized Access):將所有對共享狀態的修改,集中到單一執行緒或透過佇列(Queue)依序處理。

  • 冪等性 (Idempotency):確保外部 I/O 操作(如 API 請求、事件處理)可以安全地重試,而不會產生非預期的副作用。

替代方案:告別共享狀態

  • 訊息傳遞 (Message Passing):讓不同的執行單元透過訊息佇列傳遞「意圖」或「事件」,而不是直接存取共享記憶體。

  • 資料庫原子操作:善用資料庫提供的交易(Transaction)或原子更新指令(如 UPDATE counter SET value = value + 1 WHERE id = ?),將並行控制的複雜性交給更專業的底層系統。

測試策略

並行問題的測試極具挑戰性,需要特定策略:

  • 壓力測試:模擬高併發場景,長時間運行以機率性地觸發競爭條件,並驗證最終狀態的統計一致性。

  • 模擬重試:在測試中刻意重複呼叫同一個 API 或事件處理器,驗證系統是否具備冪等性。

  • 模擬超時:測試當鎖競爭激烈或發生死鎖時,系統的回退與錯誤處理機制是否正常運作。

檢視你的程式碼

  1. 是否存在多個執行緒可能同時修改的「共享可變狀態」?

  2. 對於共享資源的存取,是否已透過鎖、交易或原子操作保護?

  3. 對外的 API 或事件處理,是否設計成冪等的,能安全重試?

  4. 在與外部系統互動時,是否有去重或重試保護機制?

今日重點

  • 首選無共享:從設計上根除競爭的土壤。

  • 保護臨界區:若必須共享,就必須上鎖或使用原子操作。

  • 冪等保平安:讓你的系統能從容應對重試與網路的不確定性。

將並行問題從「隨機的災難」變為「可控的設計」,正是消除程式碼壞味道的進階修行。


上一篇
Day 26- 防禦性設計:處理外部例外狀況
系列文
消除你程式碼的臭味27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言