本節書摘來自異步社群出版社《c++程式設計規範:101條規則、準則與最佳實踐》一書中的第2章,第2.8節,作者:【加】herb sutter , 【羅】andrei,更多章節内容可以通路雲栖社群“異步社群”公衆号檢視。
摘要
安線全程地[4]:如果應用程式使用了多個線程或者程序,應該知道如何盡量減少共享對象(見第10條),以及如何安全地共享必須共享的對象。
讨論
線程處理是一個大課題。之是以撰寫本條,是因為這個課題很重要,需要明确地予以闡述,但是單憑一個條款顯然無法做出公允的評價,是以我們隻簡單地概述幾個要點。更多的細節和具體技術,參閱本條的參考文獻。其中最重要的問題是避免死鎖、活鎖(livelock)[5]和惡性的競争條件(包括加鎖不足導緻的崩潰)。
c++标準關于線程未置一詞。然而,c++經常而且廣泛地用于編寫可靠的多線程代碼。如果應用程式需要跨線程共享資料,請如下安全行事。
參考目标平台的文檔,了解該平台的同步化原語。典型的原語包括從輕量級的原子整數操作到記憶體障栅(memory barrier)[6],再到程序内和跨程序的互斥體。
最好将平台的原語用自己設計的抽象包裝起來。在需要跨平台移植性的時候,這樣做尤其有益。或者,也可以使用程式庫(比如pthreads [butenhof 97])為我們代勞。
確定正在使用的類型在多線程程式中使用是安全的。說得具體一些,就是類型必須至少做到以下兩個方面。
保證非共享的對象獨立。兩個線程能夠自由地使用不同的對象,無需調用者的任何特殊操作。
記載調用者在不同線程中使用該類型的同一個對象需要做什麼。許多類型要求對這種共享對象進行串行通路,但是有些類型卻不要求這樣。後者通常要麼從設計中去掉加鎖需求,要麼自己進行内部加鎖,無論哪種情況,仍然需要留意内部加鎖粒度的局限。
請注意,無論類型是字元串類型,還是stl容器比如vector,或者任何其他類型,上面的原則都适用。(我們留意到有些書的作者曾經給出建議,暗示标準容器有特殊性。其實并非如此,容器也隻不過是一種對象而已。)說得具體一些,如果要在多線程程式中使用标準庫元件(例如string,容器),如前所述,應該參考标準庫實作的文檔,了解是否支援多線程。
在自己編寫可用于多線程程式的類型時,也必須完成兩項任務。首先,必須保證不同線程能夠不加鎖地使用該類型的不同對象(注意:具有可修改的靜态資料的類型通常不能保證這一點)。其次,必須在文檔中說明使用者在不同線程中使用該類型的同一個對象需要做什麼,基本的設計問題是如何在類及其客戶之間配置設定正确執行(即無競争和無死鎖地執行)的職責。主要的選擇有下列幾個方面。
外部加鎖:調用者負責加鎖。在這種選擇下,由使用對象的代碼負責了解是否跨線程共享了對象,如果是,還要負責串行化所有對該對象的使用。例如,字元串類型通常使用外部加鎖(或者不變性,見第三種選擇)。
内部加鎖:每個對象将所有對自己的通路串行化,通常采用為每個公用成員函數加鎖的方法來實作,這樣調用者就可以不用串行化對象的使用了。例如,生産者/消費者隊列通常使用内部加鎖,因為它們存在的目的就是被跨線程共享,而且它們的接口就是為了在單獨的成員函數調用(push, pop)期間能夠進行适當的層次加鎖而設計的。更一般的情況下,需要注意,隻有在知道了以下兩件事情之後這個選項才适用。
第一,必須事先知道該類型的對象幾乎總是要被跨線程共享的,否則到頭來隻不過進行了無效加鎖。請注意大多數類型都不會遇到這種情況,即使是在多線程處理分量很重的程式中,大多數對象也不會被跨線程共享(這是好現象,見第10條)。
第二,必須事先知道成員函數級加鎖的粒度是合适的,而且能滿足大多數調用者的需要。具體而言,類型接口的設計應該有利于粗粒度的、自給自足的操作。如果調用者總是需要對多個而不是一個操作加鎖,那麼就不能滿足需要了,隻能通過增加更多的(外部)鎖,将單獨加鎖的函數組裝成一個更大規模的已加鎖工作機關。例如一個容器類型,如果它傳回一個疊代器,則疊代器可能在用到之前就失效了;如果它提供find之類的能傳回正确答案的成員算法,那麼答案可能在用到之前就出錯了;如果它的使用者想要編寫這樣的代碼:if( c.empty() ) c.push_back(x);,同樣會出現問題。(更多的例子,參閱 [sutter02]。)在這些情況下,調用者需要進行外部加鎖,以獲得生存期能夠跨越多個單獨成員函數調用的鎖,這樣一來每個成員函數的内部加鎖就毫無用武之地了。是以,内部加鎖是綁定于類型的公用接口的:在類型的各個單獨操作本身都完整時,内部加鎖才适用;換句話說,類型的抽象級别不僅提升了,而且表達和封裝得更加精确了(比如,以生産者-消費者隊列的形式,而不是普通的vector)。将多個原語操作結合起來,形成粒度更粗的公開操作,不僅可以確定函數調用有意義,而且可以確定調用簡單。如果原語的結合是不能确定的,而且也無法将合理的使用場景集合集中到一個命名操作中,那麼有兩種選擇:一是使用基于回調的模型(即讓調用者調用一個單獨的成員函數,但是以一個指令或者函數對象的形式傳入它們想要執行的任務,見第87條到第89條);二是在接口中以某種方式暴露加鎖。
不加鎖的設計,包括不變性(隻讀對象):無需加鎖。将類型設計得根本無需加鎖是可能的(參閱本條的參考文獻)。常見的例子是不變對象,它無需加鎖,因為它從不發生變化。例如,對于一個不變的字元串類型而言,字元串對象一旦建立就不會改變,每個字元串操作都會建立新的字元串。
請注意,調用代碼應該不需要知道你的類型的實作細節(見第11條)。如果類型使用了底層資料共享技術[如寫時複制(copy-on-write)],那麼你就不需要為所有可能的線程安全性問題負責了,但是必須負責恢複“恰到好處的”線程安全,以確定調用代碼在履行其通常職責時仍是正确的:類型必須能夠盡可能地安全使用,如果它沒有使用隐蔽的實作共享(見[sutter04c])。前面已經提到,所有正确編寫的類型都必須允許在不同線程中無需同步便可操作不同的可見對象。
如果編寫的是一個将要廣泛使用的程式庫,那麼尤其要考慮保證對象能夠在前面叙述的多線程程式中安全使用,而且又不會增加單線程程式的開銷。例如,如果你正在編寫的程式庫包含一個使用了寫時複制的類型,并且因而必須至少進行某種内部加鎖,那麼最好安排加鎖在程式庫的單線程編譯版本中消失[#ifdef和空操作(no-op)實作是常見的政策]。
在擷取多個鎖時,通過安排所有擷取同樣的鎖的代碼以相同的順序擷取鎖,可以避免死鎖情況的發生。(釋放鎖則可以按照任意順序進行。)解決方案之一,是按記憶體位址的升序擷取鎖,位址恰好提供了一個友善、唯一而且是應用程式範圍的排序。
參考文獻
[alexandrescu02a] ● [alexandrescu04] ● [butenhof97] ● [henney00] ● [henney01] ● [meyers04] ● [schmidt01] ● [stroustrup00] §14.9 ● [sutter02] §16 ● [sutter04c]