1. 程式人生 > >關於STL容器的執行緒安全特性

關於STL容器的執行緒安全特性

標準C++的世界是相當保守和精簡的。在這個純潔的世界,所有可執行檔案都是靜態連結的。不存在記憶體對映檔案和共享記憶體。沒有視窗系統,沒有網路,沒有資料庫,沒有程序。在這種情況下,當發現標準沒有提到任何關於執行緒的東西時你不該感到驚訝。你對STL的執行緒安全有的第一個想法應該是這取決於STL的實現。

當然,多執行緒程式是很普遍的,所以大部分STL廠商努力使他們的實現線上程環境中可以正常工作。但是,即使他們做得很好,大部分負擔仍在你肩上,而理解為什麼會這樣是很重要的。STL廠商只能為你做一些可以減少你多執行緒的痛苦的事情,你需要知道他們做了什麼。

在STL容器(和大多數廠商的願望)裡對多執行緒支援的黃金規則已經由SGI定義,並且在它們的STL網站

[21]上釋出。大體上說,你能從實現裡確定的最多是下列內容:

  • 多個讀取者是安全的。多執行緒可能同時讀取一個容器的內容,這將正確地執行。當然,在讀取時不能有任何寫入者操作這個容器。

  • 對不同容器的多個寫入者是安全的。多執行緒可以同時寫不同的容器。

就這些了。STL的有些實現提供這些保證,但是有些不能。

寫多執行緒的程式碼很難,很多程式設計師希望STL實現是完全執行緒安全的。如果是那樣,程式設計師可以不再需要自己做同步控制。毫無疑問這會給使用者帶來很多便利,但是實現起來卻並不容易。一個庫可能試圖以下列方式實現這樣完全執行緒安全的容器:

  • 在每次呼叫容器的成員函式期間都要鎖定該容器。

  • 在每個容器返回的迭代器(例如通過呼叫begin或end)的生存期之內都要鎖定該容器。

  • 在每個在容器上呼叫的演算法執行期間鎖定該容器。(這事實上沒有意義,因為,正如條款32所解釋的,演算法沒有辦法識別出它們正在操作的容器。不過,我們將在這裡檢驗這個選項,因為它的教育意義在於看看為什麼即使是可能的它也不能工作。)

現在考慮下列程式碼。它搜尋一個vector<int>中第一次出現5這個值的地方,而且,如果它找到了,就把這個值改為0。

vector<int> v;

vector<int>::iterator first5(find(v.begin(), v.end(), 5));    // 行1

if (first5 != v.end()){                    // 行2

    *first5 = 0;                    // 行3

}

在多執行緒環境裡,另一個執行緒可能在行1完成之後立刻修改v中的資料。如果是那樣,行2對first5和v.end的檢測將是無意義的,因為v的值可能和它們在行1結束時的值不同。實際上,這樣的檢測會產生未定義的結果,因為另一執行緒可能插在行1和行2之間,使first5失效,或許通過進行一次插入操作造成vector重新分配它的內在記憶體。(那將使vector全部的迭代器失效。關於重新分配行為的細節,參見條款14。)類似的,行3中對*first5的賦值是不安全的,因為另一個執行緒可能在行2和行3之間執行,並以某種方式使first5失效,可能通過刪除它指向(或至少曾經指向)的元素。

在上面列舉的同步都不能避免這些問題。行1中begin和end呼叫都返回得很快,以至於不能提供任何幫助,它們產生的迭代器只持續到這行的結束,而且find也在那行返回。

要讓上面的程式碼成為執行緒安全的,v必須從行1到行3保持鎖定,很難想象STL實現怎麼能自動推斷出這個。記住同步原語(例如,訊號燈,互斥量,等等)通常開銷很大,更難想象實現怎麼在程式沒有明顯效能損失的情況下做到前面所說的——以這樣的一種方式設計——讓最多一個執行緒在1-3行的過程中能訪問v。

這樣的考慮解釋了為什麼你不能期望任何STL實現讓你的執行緒悲痛消失。取而代之的是,你必須手工對付這些情況中的同步控制。 在這個例子裡,你可以像這樣做:

vector<int> v;

...

getMutexFor(v);

vector<int>::iterator first5(find(v.begin(), v.end(), 5));

if (first5 != v.end()) {                        // 這裡現在安全了

    *first5 = 0;                        // 這裡也是

}

releaseMutexFor(v);

一個更面向物件的解決方案是建立一個Lock類,在它的建構函式裡獲得互斥量並在它的解構函式裡釋放它,這樣使getMutexFor和releaseMutexFor的呼叫不匹配的機會減到最小。這樣的一個類(其實是一個類模板)基本是這樣的:

template<typename Container>                // 獲取和釋放容器的互斥量

class Lock {                        // 的類的模板核心;

public:                            // 忽略了很多細節

    Lock(const Containers container)

            : c(container)

    {

        getMutexFor(c);                // 在建構函式獲取互斥量

    }

    ~Lock()

    {

        releaseMutexFor(c);            // 在解構函式裡釋放它

    }

private:

    const Container& c;

};

使用一個類(像Lock)來管理資源的生存期(例如互斥量)的辦法通常稱為資源獲得即初始化,你應該能在任何全面的C++教材裡讀到它。一個好的開端是Stroustrup的《The C++ Programming Language》,因為Stroustrup推薦了這個慣用法,但你也可以轉到《More Effective C++》的條款9。不管你參考了什麼來源,記住上述Lock是最基本的實現。一個工業強度的版本需要很多改進,但是那樣的擴充與STL無關。而且這個最小化的Lock已經足夠看出我們可以怎麼把它用於我們一直考慮的例子:

vector<int> v;

...

{                                // 建立新塊;

    Lock<vector<int> > lock(v);                    // 獲取互斥量

    vector<int>::iterator first5(find(v.begin(), v.end(), 5));

    if (first5 != v.end()) {

        *first5 = 0;

    }

}                                // 關閉塊,自動

                                // 釋放互斥量

因為Lock物件在Lock的解構函式裡釋放容器的的互斥量,所以在互斥量需要釋放是就銷燬Lock是很重要的。為了讓這件事發生,我們建立一個裡面定義了Lock的新塊,而且當我們不再需要互斥量時就關閉那個塊。這聽上去像我們只是用關閉新塊的需要換取了呼叫releaseMutexFor的需要,但是這是錯誤的評價。如果我們忘記為Lock建立一個新塊,互斥量一樣會釋放,但是它可能發生得比它應該的更晚——當控制到達封閉塊的末端。如果我們忘記呼叫releaseMutexFor,我們將不會釋放互斥量。

而且,這種基於Lock的方法在有異常的情況下是穩健的。C++保證如果丟擲了異常,區域性物件就會被銷燬,所以即使當我們正在使用Lock物件時有異常丟擲,Lock也將釋放它的互斥量。如果我們依賴手工呼叫getMutexFor和releaseMutexFor,那麼在呼叫getMutexFor之後releaseMutexFor之前如果有異常丟擲,我們將不會釋放互斥量。

異常和資源管理是重要的,但是它們不是本條款的主題。本條款是關於STL裡的執行緒安全。當涉及到執行緒安全和STL容器時,你最好對STL抱有任何的期望。你需要自己來控制多執行緒控制的方方面面。執行緒安全從來就不是STL的專長。

java裡很多容器都被實現為執行緒安全的,因為java在語言層面有對多執行緒的支援。而c++沒有。當你在c++裡使用多執行緒時,你所依賴的只有作業系統了,有很多庫幫你封裝了這些,比如說boost的thread庫。建議你可以去看看。