讓複用變得容易,拒絕重複。
上一節說到,std::mutex並不能完全解決保護數據的問題。存在好幾種情況,即使我們已經使用了互斥量,數據還是被破壞了。
- 將被保護數據暴露到互斥量作用域之外
- 被保護數據的訪問接口本身就存在競態條件(條件競爭)
不要暴露你的數據
來看下面例子:
struct protected_data
{
char data[100];
}
class MutexTest
{
public:
template<typename Function>
void process(Function func)
{
std::lock_guard<std::mutex> guard<m_dataMutex>;
func(m_data);
}
private:
std::mutex m_dataMutex;
struct protected_data m_data;
}
struct protected_data *pData;
void inject(Data &data)
{
pData = &data;
}
// 即使process沒有顯式傳出,但是還是被inject傳出
// process執行完後,pData能在無鎖的情況下訪問數據
void Test()
{
process(inject);
for(int i = 0; i < 100; ++i)
{
pData.data[i] = i;
}
}
std::thread mutexTestThread1(Test);
std::thread mutexTestThread2(Test);
我想不到比較好的例子來說明這個問題,上面的例子是基於C++併發編程上面改編的例子,其也能說明問題:在上鎖後執行了用戶定義的函數,將被保護數據傳遞到互斥鎖作用域之外。
這個場景,mutexTestThread1
解鎖,mutexTestThread2
上鎖後,mutexTestThread2
仍然無法獨佔被保護數據。pData總是獲取到了被保護的數據,並在mutexTestThread2
訪問數據時修改該數據。
這種代碼看起來很正常,也很不容易被發現,但是背後的錯誤邏輯是致命的,數據常常被莫名修改,奔潰也有可能隨之而來。
切勿將受保護數據的指針或引用傳遞到互斥鎖作用域之外,無論是函數返回值,還是存儲在外部可見內存,亦或是以參數的形式傳遞到用戶提供的函數中去。
謹慎的設計你的數據接口
來看下面例子:
std::deque<int> intDeque(1, 10);
std::stack<int> intStack(intDeque);
void Process()
{
if(!intStack.empty())
{
const int value = intStack.top();
intStack.pop();
}
}
std::thread t1(Process);
std::thread t2(Process);
即使top()操作和pop()操作都已經上鎖,也無法解決條件競爭的問題。
假設棧的實現中對數據的訪問已加鎖,在單線程情況下,上面程序可以無誤執行,但是在多線程情況下,就有可能出現異常。調用空stack的top()是未定義行爲。在多線程情況下,intStack.empty()操作獲取的結果是不可靠的。
上述例子中intStack棧只有一個元素,如果線程t1和t2的執行順序如下,就會出現未定義行爲:
// example 1
t1: intStack.empty() // one element in intStack
t1: intStack.top() // one element in intStack
t2: intStack.empty() // one element in intStack
t1: intStack.pop() // no element in intStack
t2: intStack.top() // undefined behavior, intStack is empty()
即使不出現未定義行爲,也有可能出現非預期行爲——處理同一份數據多次:
// example 2
t1: intStack.empty() // one element in intStack
t2: intStack.empty() // one element in intStack
t1: intStack.top() // handle this data
t2: intStack.top() // handle this data again
要解決上述問題,就需要接口設計上有較大的改動,最好的操作是重新設計接口
- 1、重新設計接口實現:top()接口內提供異常機制,當棧大小爲零時,拋出異常
- 2、重新設計接口功能:將pop()和top()操作合併
第1種方案並不能解決example 2,所以推薦重新設計接口功能。一個線程安全的棧類定義如下:
template<typename T>
class Stack
{
private:
std::stack<T> m_data;
mutable std::mutex m_mutex;
public:
Stack(): m_data(std::stack<int>()){}
Stack(const Stack& other)
{
std::lock_guard<std::mutex> lock(other.m);
data = other.data;
}
Stack& operator=(const Stack&) = delete;
void push(T new_value)
{
std::lock_guard<std::mutex> lock(m);
data.push(new_value);
}
std::shared_ptr<T> pop()
{
std::lock_guard<std::mutex> lock(m);
if(data.empty()) nullptr;
const std::shared_ptr<T> res(std::make_shared<T>(data.top()));
data.pop();
return res;
}
void pop(T& value)
{
std::lock_guard<std::mutex> lock(m);
if(data.empty()) return nullptr;
value=data.top();
data.pop();
}
bool empty() const
{
std::lock_guard<std::mutex> lock(m);
return data.empty();
}
};
棧操作爲什麼需要先top()後pop(),而不直接pop()時返回數據?這是爲了防止pop()時的拷貝操作失敗,導致數據丟失。
如果不重新設計接口,在使用的時候加鎖也能解決這個問題:
std::mutex stackMutex;
void Process()
{
std::lock_gurad<std::mutex> guard(statckMutex);
if(!intStack.empty())
{
const int value = intStack.top();
intStack.pop();
}
}
上述兩種可能導致加鎖失效的競態條件場景,需要我們在組織代碼或設計接口時精雕細琢,在很多場景下,提供線程安全的代碼是很有必要的。
下一節,我們會死鎖問題,即使沒有加鎖,也是有可能出現死鎖,必須要按照一定的規範來涉及代碼,纔能有效的避免死鎖問題。