本文翻譯自modern effective C++,由於水平有限,故無法保證翻譯完全正確,歡迎指出錯誤。謝謝!
博客已經遷移到這里啦
如果我們在數學領域里工作,我們可能會發現用一個類來表示多項式會很方便。在這個類中,如果有一個函數能計算多選式的根(也就是,多項式等於0時,各個未知量的值)將變得很方便。這個函數不會改變多項式,所以很自然就想到把它聲明為const:
class Polynomial{
public:
using RootsType = //一個存放多項式的根的數據結構
std::vector<double>; //(using的信息請看Item 9)
...
RootsType roots() const;
...
};
計算多項式的根代價可能很高,所以如果不必計算的話,我們就不想計算。如果我們必須要計算,那么我們肯定不想多次計算。因此,當我們必須要計算的時候,我們將計算后的多項式的根緩存起來,並且讓roots函數返回緩存的根。這里給出最基本的方法:
class Polynomial{
public:
using RootsType = std::vector<double>;
RottsType roots() const
{
if(!rootsAreValid){ //如果緩存不可用
... //計算根,把它們存在rootVals中
rootsAreValid = true;
}
return rootVals;
}
private:
mutable bool rootsAreValid{ false }; //初始化的信息看Item 7
mutable RootsType rootVals{};
};
概念上來說,roots的操作不會改變Polynomial對象,但是,對於它的緩存行為來說,它可能需要修改rootVals和rootsAreValid。這就是mutable很經典的使用情景,這也就是為什么這些成員變量的聲明帶有mutable。
現在想象一下有兩個線程同時調用同一個Polynomial對象的roots:
Polynomuial p;
...
/*-------- 線程1 -------- */ /*-------- 線程2 -------- */
auto rootsOfP = p.roots(); auto valsGivingZero = p.roots();
客戶代碼是完全合理的,roots是const成員函數,這就意味着,它表示一個讀操作。在多線程中非同步地執行一個讀操作是安全的。至少客戶是這么假設的。但是在這種情況下,卻不是這樣,因為在roots中,這兩個線程中的一個或兩個都可能嘗試去修改成員變量rootsAreValid和rootVals。這意味着這段代碼在沒有同步的情況下,兩個不同的線程讀寫同一段內存,這其實就是data race的定義。所以這段代碼會有未定義的行為。
現在的問題是roots被聲明為const,但是它卻不是線程安全的。這樣的const聲明在C++11和C++98中都是正確的(取多項式的根不會改變多項式的值),所以我們需要更正的地方是線程安全的缺失。
解決這個問題最簡單的方式就是最常用的辦法:使用一個mutex:
class Polynomial{
public:
using RootsType = std::vector<double>;
RottsType roots() const
{
std::lock_guard<std::mutex> g(m); //鎖上互斥鎖
if(!rootsAreValid){ //如果緩存不可用
...
rootsAreValid = true;
}
return rootVals;
} //解開互斥鎖
private:
mutable std::mutex m;
mutable bool rootsAreValid{ false };
mutable RootsType rootVals{};
};
std::mutex m被聲明為mutable,因為對它加鎖和解鎖調用的都不是const成員函數,在roots(一個const成員函數)中,如果不這么聲明,m將被視為const對象。
值得注意的是,因為std::mutex是一個move-only類型(也就是,這個類型的對象只能move不能copy),所以把m添加到Polynomial中,會讓Polynomial失去copy的能力,但是它還是能被move的。
在一些情況下,一個mutex是負擔過重的。舉個例子,如果你想做的事情只是計算一個成員函數被調用了多少次,一個std::atomic計數器(也就是,其它的線程保證看着它的(counter的)操作不中斷地做完,看Item 40)常常是達到這個目的的更廉價的方式。(事實上是不是更廉價,依賴於你跑代碼的硬件和標准庫中mutex的實現)這里給出怎么使用std::atomic來計算調用次數的例子:
class Point {
public:
...
double distanceFromOrigin() const noexcept //noexcept的信息請看Item 14
{
++callCount; //原子操作的自增
return std::sqrt((x * x) + (y * y));
}
private:
mutable std::atomic<unsigned> callCount{ 0 };
};
和std::mutex相似,std::atomic也是move-only類型,所以由於callCount的存在,Point也是move-only的。
因為比起mutex的加鎖和解鎖,對std::atomic變量的操作常常更廉價,所以你可能會過度傾向於std::atomic。舉個例子,在一個類中,緩存一個“計算昂貴”的int,你可能會嘗試使用一對std::atomic變量來代替一個mutex:
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;
};
這能工作,但是有時候它會工作得很辛苦,考慮一下:
- 一個線程調用Widget::magicValue,看到cacheValid是false的,執行了兩個昂貴的計算,並且把它們的和賦給cachedValue。
- 在這個時間點,第二個線程調用Widget::magicValue,也看到cacheValid是false的,因此同樣進行了昂貴的計算(這個計算第一個線程已經完成了)。(這個“第二個線程”事實上可能是一系列線程,也就會不斷地進行這昂貴的計算)
這樣的行為和我們使用緩存的目的是相違背的。換一下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是false的情況:
- 一個線程調用Widget::magicValue,並且剛執行完:把cacheValid設置為true。
- 同時,第二個線程調用Widget::magicValue,然后檢查cacheValid,發現它是true,然后,即使第一個線程還沒有把計算結果緩存下來,它還是直接返回cachedValue。因此,返回的值是不正確的。
讓我們吸取教訓。對於單一的變量或者內存單元,它們需要同步時,使用std::atomic就足夠了,但是一旦你需要處理兩個或更多的變量或內存單元,並把它們視為一個整體,那么你就應該使用mutex。對於Widget::magicValue,看起來應該是這樣的:
class Widget {
public:
...
int magicValue() const
{
std::lock_guard<std::mutex> guard(m); //鎖住m
if (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; //不再是atomic了
mutable bool cacheValid { false };
};
現在,這個Item是基於“多線程可能同時執行一個對象的const成員函數”的假設。如果你要寫一個const成員函數,並且你能保證這里沒有多於一個的線程會執行這個對象的cosnt成員函數,那么函數的線程安全就不重要了。舉個例子,如果一個類的成員函數只是設計給單線程使用的,那么這個成員函數是不是線程安全就不重要了。在這種情況下,你能避免mutex和std::atomic造成的負擔。以及免受“包含它們的容器將變成move-only”的影響。然而,這樣的自由線程(threading-free)變得越來越不常見了,它們還將變得更加稀有。以后,const成員函數的多線程執行一定會成為主題,這就是為什么你需要確保你的const成員函數是線程安全的。
你要記住的事
- 讓const成員函數做到線程安全,除非你確保它們永遠不會用在多線程的環境下。
- 比起mutex,使用std::atomic變量能提供更好的性能,但是它只適合處理單一的變量或內存單元