加鎖、解鎖(同步/互斥)是多線程中非常基本的操作,但我卻看到不少的代碼對它們處理的很不好。簡單說來有三類問題,一是加鎖範圍太大,雖然避免了邏輯錯誤,但鎖了不該鎖的東西,難免降低程式的效率;二是該鎖的不鎖,導致各種莫名其妙的錯誤;三是加鎖方式不合適,該用臨界區的用核心對象等,也會降低程式的效率。
要正確的運用鎖操作,首先要弄清楚什麼時候需要加鎖。很多書上都說在可能“同時發生多個寫操作”或“同時發生讀寫操作”時,應該加鎖。這固然沒什麼錯,但我認為它沒有說到問題的根上,更準確的表述應該是:如果不加鎖會導致不可容忍的資料不一致,那麼就應該加鎖。據此,我在下表中列出了多線程中應該加鎖和無需加鎖的條件,其中的“單一資料型別”是指cpu可以在一條指令中完成操作的資料類型,一般整形和所有比整形小的資料類型都是,除此之外的類型都屬於“複雜資料類型”,例如你自己定義的結構體等。
| |
操作的結果與初值無關 |
操作的結果與初值相關 |
| 寫單一資料型別 |
不需要加鎖① |
需要加鎖② |
| 寫複雜資料類型 |
需要加鎖③ |
需要加鎖④ |
| 讀單一資料型別 |
不需要加鎖⑤ |
不需要加鎖⑥ |
| 讀複雜資料類型 |
需要加鎖⑦ |
需要加鎖⑧ |
大家可能注意到,在第1、5、6種情況下,我認為可以不加鎖,粗看起來,這與書上的說法有些矛盾。其實卻不然,因為這些操作可以在一條指令內完成,所以它們具有天然的“原子性”,我們可以認為cpu已經給它們加鎖了,我們沒必要再畫蛇添足。如果這個理由還不夠的話,你不妨想一下我們再加一次鎖是否有用,看下面的代碼(以第1種情況為例):
Lock(); // ① n = 10; // ② Unlock(); // ③ int x = n; // ④
|
看出來了嗎?不管語句①③是否存在,這段代碼執行完畢後,我們都無法保證x的值是10。也許你會想如果把③④兩條語句的位置換一下,x就肯定是10了。可是在這個例子中,想讓x是10,為什麼不把語句④直接換成“int x = 10;”呢?既省了加鎖,有減少了鍵盤的磨損,何樂而不為?!而且,我的這個例子並不是刻意構造的,在多線程,這種情況比比皆是。
第2種情況的典型代表是“i++;”,需要對它加鎖是因為它表面上雖然只有一條語句,卻要執行至少兩個操作,一是讀出i的初始, 二是把加一後的結果寫回去,兩個操作就沒有“原子性”了,所以需要加鎖。
另外,上表中判斷是否需要加鎖的依據是“是否可能造成資料不一致”。實際上,有些情況下資料不一致是可以容忍的,如果它發生機率極低、造成的不良後果可以忽略、並能很快自動回復,那它可能就是可以容忍的。對這種資料不一致,我們可以不加鎖。不過對它的判定與程式的實際情況關聯太大,我們在這裡就不討論了。
加鎖的方法也可分為三類,臨界區、核心對象和互鎖函數。相比前兩類,互鎖函數的知名度要低不少,但它卻是我用的最多的方法,因為它有一個最大的優點:快!有不少書上比較臨界區和核心對象時都說臨界區的優點是不會進入核心模式,速度快。不過這是不全面的,如果沒有衝突(實際發生衝突的機率一般很低),臨界區確實不會進核心模式,但如果發生了衝突要進行等待,它就要依靠核心對象了。而互鎖函數則絕不會進核心模式,所以互鎖函數是最快的(臨界區在沒有衝突時的行為是依靠互鎖函數實現的)。互鎖函數的缺點是只能處理相對簡單的資料類型(不要和我前面說的“單一資料型別”等價起來),但另一方面,對加鎖需求最高的也往往是這些類型的資料。
實際開發中,還有一種鎖比較常用,這就是單寫多讀鎖,《windows核心編程》上有一個單寫多讀鎖的實現,我的blog上有另一個實現。前者適用於需加鎖的對象數量較少(例如如只有一個),存取違規機率相對較高的情況。後者適用於需加鎖的對象很多,存取違規機率很低的情況(對象多了, 單個對象的存取違規自然就少了)。兩個實現的共同缺點是不支援重入,即同一個線程中,解鎖前不能再次加鎖。臨界區在這方面有優勢,它支援重入。使用TLS(線程局部儲存)技術進行改進應該能讓它們支援重入,不過這樣做了以後我那個實現應該就算不上輕量級了:)。
最後,還有其它的一些不用鎖的方法也可以保證多線程中的資料一致性,其中最常用的就是迴圈。例如下面的例子:
struct bar { volatile unsigned version; // 一個額外的版本號碼欄位 int field1; char field2; char field3; ...... }; bar g_bar = { 0 };
// 寫線程 ++g_bar.version; // 加1, version是奇數, 表示正在更新 g_bar.field1 = 10; ...... ++g_bar.version; // 再加1, version是偶數, 表示更新完畢
// 讀線程 void ReadGlobalBar( bar* p ) { unsigned ver; do { ver = g_bar.version; if( ver % 2 != 0 ) // 正在更新 { Sleep( 0 ); // 等待 continue; } p->field1 = g_bar.field1; ...... } while( ver != g_bar.version );
}
|
然而這種方法真的沒用鎖嗎?看你怎麼理解了,那個version欄位其實就可以看做是鎖的。不過它只是半個鎖,因為它只鎖了讀操作,而沒鎖寫操作,也就是說寫操作可以隨時進行而無需等待。如果讀操作非常多,但寫操作較少,並且你不希望寫操作經常被打斷,那它正好滿足你的要求。它的缺點是你要保證系統中某個時刻最多有一個“writer”,“writer”一多,它就的無能為力了(這時一般應該用單寫多讀鎖)。