Item 16: 讓const成員函數做到線程安全


本文翻譯自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變量能提供更好的性能,但是它只適合處理單一的變量或內存單元


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM