C++併發編程2——爲共享數據加鎖(二)

讓複用變得容易,拒絕重複。

上一節說到,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();
    }
}

上述兩種可能導致加鎖失效的競態條件場景,需要我們在組織代碼或設計接口時精雕細琢,在很多場景下,提供線程安全的代碼是很有必要的。

下一節,我們會死鎖問題,即使沒有加鎖,也是有可能出現死鎖,必須要按照一定的規範來涉及代碼,纔能有效的避免死鎖問題。


關注微信公衆號
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章