Item 16:讓 const 成員函式具備執行緒安全 (Make const Member Functions Thread Safe)

Overview Table

重點 說明
核心原則 const 成員函式代表「讀取操作」,多執行緒會不加同步地並行呼叫,因此必須是 thread safe
問題來源 mutable 成員(快取、計數器等)讓「概念上 const」的函式實際上會寫入資料成員 → data raceundefined behavior
單一變數同步 std::atomic——通常比 mutex 便宜
兩個以上變數需一起操作 std::mutex(如 std::lock_guard 上鎖)——多個 atomic 無法保證整體一致性
副作用 std::mutexstd::atomic 都是 move-only,加入後類別失去可複製性(仍可 move)
唯一例外 確定該物件絕不會被多執行緒同時使用時,才可省去同步成本

const ≠ Thread Safe:mutable 快取引發 data race

概念上不修改物件的函式宣告成 const 完全正確,但若內部透過 mutable 成員做快取,兩個執行緒同時呼叫就會同時讀寫同一塊記憶體:

class Polynomial {
public:
  using RootsType = std::vector<double>;   // 存放多項式根的容器(Item 9 的 alias)

  RootsType roots() const                  // 概念上是唯讀 → 宣告 const 合理
  {
    if (!rootsAreValid) {                  // 快取失效才計算
      // ... 計算根並存入 rootVals(昂貴運算)
      rootsAreValid = true;                // 寫入 mutable 成員!
    }
    return rootVals;
  }

private:
  mutable bool rootsAreValid{ false };     // mutable:const 函式內仍可修改
  mutable RootsType rootVals{};
};
Thread 1: p.roots()                Thread 2: p.roots()
    |                                  |
    v                                  v
 讀 rootsAreValid ──┐          ┌── 讀 rootsAreValid
                    ├── 同一記憶體 ──┤
 寫 rootVals ───────┘          └── 寫 rootsAreValid
                    |
                    v
        無同步的並行讀寫 = data race = undefined behavior

修正:加入 mutable std::mutex,以 std::lock_guard 涵蓋整個「檢查—計算—寫入」流程:

class Polynomial {
public:
  using RootsType = std::vector<double>;

  RootsType roots() const
  {
    std::lock_guard<std::mutex> g(m);      // 上鎖(離開作用域自動解鎖)
    if (!rootsAreValid) {
      // ... 計算並存入 rootVals
      rootsAreValid = true;
    }
    return rootVals;
  }

private:
  mutable std::mutex m;                    // mutable:lock/unlock 是 non-const 操作
  mutable bool rootsAreValid{ false };
  mutable RootsType rootVals{};
};
Important

std::mutex 必須宣告 mutablelock()/unlock() 是 non-const 成員函式,而在 const 成員函式中 m 會被視為 const 物件。

Warning

加入 std::mutex(或 std::atomic)的副作用:兩者皆為 move-only type(可 move、不可 copy),所以類別會喪失可複製性,只剩可移動性。

單一變數用 std::atomic:更輕量的選擇

只需同步單一變數或記憶體位置(如呼叫次數計數器)時,std::atomic 通常比 mutex 便宜(見 Item 40):

class Point {                                    // 2D 點
public:
  double distanceFromOrigin() const noexcept     // 不拋例外 → noexcept(Item 14)
  {
    ++callCount;                                 // 原子遞增,不需 mutex
    return std::sqrt((x * x) + (y * y));
  }

private:
  mutable std::atomic<unsigned> callCount{ 0 };  // move-only → Point 也變 move-only
  double x, y;
};
Warning

「atomic 比 mutex 便宜」並非絕對:實際效能取決於硬體與標準庫中 mutex 的實作方式。

兩個以上變數:一對 atomic 是陷阱,該用 mutex

把「快取值 + 快取有效旗標」拆成兩個 std::atomic,無論寫入順序如何都有問題:

class Widget {
public:
  int magicValue() const
  {
    if (cacheValid) return cachedValue;
    else {
      auto val1 = expensiveComputation1();
      auto val2 = expensiveComputation2();
      cachedValue = val1 + val2;           // uh oh, part 1
      cacheValid = true;                   // uh oh, part 2
      return cachedValue;
    }
  }

private:
  mutable std::atomic<bool> cacheValid{ false };
  mutable std::atomic<int> cachedValue;
};
賦值順序 競態情境 後果
cachedValuecacheValid Thread 1 剛算完、尚未設旗標,Thread 2(可能多個)看到 cacheValid == false 重複昂貴計算——只是效率差,結果仍正確
cacheValidcachedValue Thread 2 看到旗標為 true,但 Thread 1 還沒寫入 cachedValue 回傳未初始化的錯誤值——更糟

教訓:需要作為一個單位操作的兩個以上變數/記憶體位置,就該用 mutex:

class Widget {
public:
  int magicValue() const
  {
    std::lock_guard<std::mutex> guard(m);  // 上鎖,整段臨界區受保護
    if (cacheValid) return cachedValue;
    else {
      auto val1 = expensiveComputation1();
      auto val2 = expensiveComputation2();
      cachedValue = val1 + val2;
      cacheValid = true;
      return cachedValue;
    }
  }                                        // 解鎖

private:
  mutable std::mutex m;
  mutable int cachedValue;                 // 不再需要 atomic
  mutable bool cacheValid{ false };        // 不再需要 atomic
};
Tip

選擇準則一句話:一個變數 → std::atomic;一個單位內多個變數 → std::mutex

Warning

本 Item 的前提是物件可能被多執行緒同時執行 const 成員函式。若能保證絕不發生(如專為單執行緒設計的類別),thread safety 就無關緊要,可省下 mutex/atomic 的成本與 move-only 副作用。但這種「保證無並行」的情境越來越罕見——保守的做法仍是讓 const 成員函式 thread safe。

Exam/Test Patterns

情境關鍵字 答案
const 成員函式 + mutable 快取 + 多執行緒同時呼叫 data race → undefined behavior;const 不保證 thread safe
std::mutex 放在 const 成員函式中要怎麼宣告 mutable std::mutex(lock/unlock 是 non-const 操作)
加了 std::mutex / std::atomic 後類別不能複製 兩者是 move-only type,含有它們的類別跟著變 move-only
只需同步一個計數器/單一變數 std::atomic,通常比 mutex 便宜
兩個以上變數需一起更新(值 + 有效旗標) mutex;一對 atomic 會造成重複計算或回傳錯誤值
先設 cacheValid = true 再寫 cachedValue 其他執行緒可能讀到尚未賦值的 cachedValue(最糟情境)
什麼時候可以不做 thread safe 確定永遠不會在並行情境中使用該函式時(越來越罕見)