条款16:保证const成员函数的线程安全性
1 举个栗子
- 设计一个多项式类,roots函数被声明为const以确保不会修改多项式:
class Polynomial {
public:using RootsType = std::vector<double>; // 用于存储根的数据结构 … RootsType roots() const; // 用于计算并返回多项式的根(即使得多项式为零的解)…
};
为了避免不必要的计算和重复计算,可以采用缓存机制:
逻辑上,roots函数不会改变它操作的多项式对象,但是在缓存过程中,它可能需要修改rootVals和rootsAreValid。
mutable关键字用于允许在常量函数(如roots)中修改类的某些成员变量。
class Polynomial {
public:using RootsType = std::vector<double>;RootsType roots() const{if (!rootsAreValid) { // 检查缓存是否有效… // 如果无效,执行计算根的操作,并将结果存储在rootVals中rootsAreValid = true;}return rootVals;}
private:mutable bool rootsAreValid{ false }; mutable RootsType rootVals{};
};
由于mutable成员的存在,虽然roots被声明为const函数,但可能在没有同步的情况下读写相同的内存,引发数据竞争,导致未定义的行为。
Polynomial p;
…
/*----- 线程 1 ----- */ /*------- 线程 2 ------- */
auto rootsOfP = p.roots(); auto valsGivingZero = p.roots();
解决这个问题最简单的方法是:使用互斥锁。
由于std::mutex是一种仅可移动类型,将m添加到Polynomial,将导致Polynomial失去了被复制的能力。
class Polynomial {
public:using RootsType = std::vector<double>;RootsType roots() const{std::lock_guard<std::mutex> g(m); // 锁定mutexif (!rootsAreValid) { // 如果缓存无效… // 计算/存储 rootValsrootsAreValid = true;}return rootVals;} // 解锁mutex
private:
//std::mutex m被声明为mutable,否则在roots函数内部,将被视为const对象,无法锁定和解锁mutable std::mutex m;mutable bool rootsAreValid{ false };mutable RootsType rootVals{};
};
- 在某些情况下,可能没必要使用互斥锁。
例如,如果只是计算成员函数被调用的次数,使用std::atomic 变量通常是一种成本较低的方法。
与std::mutex一样,std::atomic也是仅可移动类型,因此Point中存在callCount意味着Point也是仅可移动的。
class Point { // 2D 点
public:...double distanceFromOrigin() const noexcept {++callCount; // 原子递增return std::sqrt((x * x) + (y * y));}
private:mutable std::atomic<unsigned> callCount{ 0 };double x, y;
};
由于对std::atomic变量的操作通常比互斥锁开销更低,可能会导致过度依赖std::atomic:
class Widget {
public:...int magicValue() const{if (cacheValid) return cachedValue;else {auto val1 = expensiveComputation1();auto val2 = expensiveComputation2();cachedValue = val1 + val2; // 糟糕cacheValid = true; // 糟糕return cachedValue;}}
private:mutable std::atomic<bool> cacheValid{ false };mutable std::atomic<int> cachedValue;
};
这种方式有效,但请考虑以下情况:
1)一个线程调用Widget::magicValue,发现cacheValid为假,执行两个昂贵的计算。
2)此时,第二个线程调用Widget::magicValue,也看到cacheValid为假,因此执行了与第一个线程刚刚完成的相同的昂贵计算。(这个“第二个线程”实际上可能是其他几个线程。)
这种行为与缓存的目标背道而驰。
交换对cachedValue和cacheValid的赋值顺序可以消除这个问题,但结果更糟:
class Widget {
public:…int magicValue() const{if (cacheValid) return cachedValue;else {auto val1 = expensiveComputation1();auto val2 = expensiveComputation2();cacheValid = true; // 糟糕return cachedValue = val1 + val2; // 糟糕}}…
};
想象一下,cacheValid为假,然后:
1)一个线程调用Widget::magicValue并将cacheValid设置为true。
2)就在那一刻,第二个线程调用Widget::magicValue并检查cacheValid。看到它为true,线程返回cachedValue,即使第一个线程尚未对其进行赋值。因此,返回的值是不正确的。
- 对单个变量或内存位置,使用std::atomic;涉及到更多需要作为一个整体操作的变量或内存位置,使用互斥锁:
class Widget {
public:…int magicValue() const{std::lock_guard<std::mutex> guard(m); // 锁定mif (cacheValid) return cachedValue;else {auto val1 = expensiveComputation1();auto val2 = expensiveComputation2();cachedValue = val1 + val2;cacheValid = true;return cachedValue;}} // 解锁m…
private:mutable std::mutex m;mutable int cachedValue; // no longer atomicmutable bool cacheValid{ false }; // no longer atomic
};
2 要点速记
- 除非确定它们永远不会在并发环境中使用,否则需要确保const成员函数的线程安全。
- 使用 std::atomic变量可能比使用互斥锁提供更好的性能,但是它只适合操作单个变量或内存位置。