改善C#程式建議之“避免鎖定不恰當的同步物件”

hzbook2008發表於2011-10-08

建議73:避免鎖定不恰當的同步物件

在C#中,讓執行緒同步的另一種編碼方式就是使用執行緒鎖。執行緒鎖的原理,就是鎖住一個資源,使得應用程式在此刻只有一個執行緒訪問該資源。通俗地講,就是讓多執行緒變成單執行緒。在C#中,可以將被鎖定的資源理解成new出來的普通CLR物件。

既然需要鎖定的資源就是C#中的一個物件,我們就該仔細思考,到底什麼樣的物件能夠成為一個鎖物件(也叫同步物件)?在選擇同步物件的時候,應當始終注意以下幾點:

  1. 同步物件在需要同步的多個執行緒中是可見的同一個物件。
  2. 在非靜態方法中,靜態變數不應作為同步物件。
  3. 值型別物件不能作為同步物件。
  4. 避免將字串作為同步物件。
  5. 降低同步物件的可見性。

下面分別詳細介紹以上五個注意事項。

第一個注意事項:需要鎖定的物件在多個執行緒中是可見的,而且是同一個物件。“可見的”這是顯而易見的,如果物件不可見,就不能被鎖定。“同一個物件”,這也 很容易理解,如果鎖定的不是同一個物件,那又如何來同步兩個物件呢?雖然理解起來簡單,但不見得我們在這上面就不會犯錯誤。為了幫助大家理解本建議的內 容,我們先模擬一個必須使用到鎖的場景:在遍歷一個集合的過程中,同時在另外一個執行緒中刪除集合中的某項。下面這個例子中,如果沒有lock語句,將會拋 出異常InvalidOperationException:“集合已修改;可能無法執行列舉”:

  1. public partial class FormMain : Form
  2. {
  3.     public FormMain()
  4.     {
  5.         InitializeComponent();
  6.     }

  7.     AutoResetEvent autoSet = new AutoResetEvent(false);
  8.     List<string> tempList = new List<string>() { "init0", "init1", "init2" };

  9.     private void buttonStartThreads_Click(object sender, EventArgs e)
  10.     {
  11.         object syncObj = new object();

  12.         Thread t1 = new Thread(() =>
  13.         {
  14.             //確保等待t2開始之後才執行下面的程式碼
  15.             autoSet.WaitOne();
  16.             lock (syncObj)
  17.             {
  18.                 foreach (var item in tempList)
  19.                 {
  20.                     Thread.Sleep(1000);
  21.                 }
  22.             }
  23.         });
  24.         t1.IsBackground = true;
  25.         t1.Start();

  26.         Thread t2 = new Thread(() =>
  27.         {
  28.             //通知t1可以執行程式碼
  29.             autoSet.Set();
  30.             //沉睡1秒是為了確保刪除操作在t1的迭代過程中
  31.             Thread.Sleep(1000);
  32.             lock (syncObj)
  33.             {
  34.                 tempList.RemoveAt(1);
  35.             }
  36.         });
  37.         t2.IsBackground = true;
  38.         t2.Start();
  39.     }
  40. }

這是一個Winform窗體應用程式,需要演示的功能在按鈕的單擊事件中。物件syncObj對於執行緒t1和t2來說,在CLR中肯定是同一個物件。所以,上面的示例執行是沒有問題的。

現在,我們將此示例重構。將實際的工作程式碼移到一個型別SampleClass中,該示例要在多個SampleClass例項間操作一個靜態欄位,如下所示:

  1. private void buttonStartThreads_Click(object sender, EventArgs e)
  2. {
  3.     SampleClass sample1 = new SampleClass();
  4.     SampleClass sample2 = new SampleClass();
  5.     sample1.StartT1();
  6.     sample2.StartT2();
  7. }

  8. class SampleClass
  9. {
  10.     public static List<string> TempList = new List<string>() { "init0",
  11.         "init1", "init2" };
  12.     static AutoResetEvent autoSet = new AutoResetEvent(false);
  13.     object syncObj = new object();
  14.         
  15.     public void StartT1()
  16.     {
  17.         Thread t1 = new Thread(() =>
  18.         {
  19.             //確保等待t2開始之後才執行下面的程式碼
  20.             autoSet.WaitOne();
  21.             lock (syncObj)
  22.             {
  23.                 foreach (var item in TempList)
  24.                 {
  25.                     Thread.Sleep(1000);
  26.                 }
  27.             }
  28.         });
  29.         t1.IsBackground = true;
  30.         t1.Start();
  31.     }

  32.     public void StartT2()
  33.     {
  34.         Thread t2 = new Thread(() =>
  35.         {
  36.             //通知t1可以執行程式碼
  37.             autoSet.Set();
  38.             //沉睡1秒是為了確保刪除操作在t1的迭代過程中
  39.             Thread.Sleep(1000);
  40.             lock (syncObj)
  41.             {
  42.                 TempList.RemoveAt(1);
  43.             }
  44.         });
  45.         t2.IsBackground = true;
  46.         t2.Start();
  47.     }
  48. }

該示例執行起來會丟擲異常InvalidOperationException:“集合已修改;可能無法執行列舉。

檢視型別SampleClass的方法StartT1和StartT2,方法內部鎖定的是SampleClass的例項變數syncObject。例項變數 意味著,每建立一個SampleClass的例項都會生成一個syncObject物件。在本例中,呼叫者一共建立了兩個SampleClass例項,繼 而分別呼叫:

  1. sample1.StartT1();
  2. sample2.StartT2();

也就是說,以上程式碼鎖定的是兩個不同的syncObject,這等於完全沒有達到兩個執行緒鎖定同一個物件的目的。要修正以上錯誤,只要將syncObject變成static就可以了。

另外,思考一下lock(this),我們同樣不建議在程式碼中編寫這樣的程式碼。如果兩個物件的例項分別執行了鎖定的程式碼,實際鎖定的也就會是兩個物件,完全不能達到同步的目的。

第二個注意事項:在非靜態方法中,靜態變數不應作為同步物件。也許有讀者會問,前面曾說到,要修正第一個注意事項中的示例問題,需要將syncObject 變成static。這似乎和本注意事項有矛盾。事實上,第一個注意事項中的示例程式碼僅僅出於演示的目的,在實際應用中,我們強烈建議不要編寫此類程式碼。在 編寫多執行緒程式碼時,要遵循這樣的一個原則:型別的靜態方法應當保證執行緒安全,非靜態方法不需實現執行緒安全。

FCL 中的絕大部分類都遵循了這個原則。像上一個示例中,如果將syncObject變成static,就相當於讓非靜態方法具備了執行緒安全性,這帶來的一個問 題是,如果應用程式中該型別存在多個例項,在遇到這個鎖的時候,它們都會產生同步,而這可能不是開發者所願意看到的。第二個注意事項實際也可以歸納到第一 個注意事項中。

第三個注意事項:值型別物件不能作為同步物件。值型別在傳遞到另一個執行緒的時候,會建立一個副本,這相當於每個執行緒鎖定的也是兩個物件。因此,值型別物件不能作為同步物件。

第四個注意事項:鎖定字串是完全沒有必要的,而且相當危險。這整個過程看上去和值型別正好相反。字串在CLR中會被暫存到記憶體裡,如果有兩個變數被分配 了相同內容的字串,那麼這兩個引用會被指向同一塊記憶體。所以,如果有兩個地方同時使用了lock(“abc”),那麼它們實際鎖定的是同一個物件,這會 導致整個應用程式被阻滯。

第五個注意事項:降低同步物件的可見性。可見範圍最廣的一種同步物件是typeof(SampleClass)。 typeof方法所返回的結果(也就是型別的type)是SampleClass的所有例項所共有的,即:所有例項的type都指向typeof方法的結 果。這樣一來,如果我們lock(typeof(SampleClass)),當前應用程式中所有SampleClass的例項執行緒將會全部被同步。這樣 編碼完全沒有必要,而且這樣的同步物件太開放了。

一般來說,同步物件也不應該是一個公共變數或屬性。在FCL的早期版本中,一些常用的集合 型別(如ArrayList)提供了公共屬性SyncRoot,讓我們鎖定以便進行一些執行緒安全的操作。所以你一定會覺得我們剛才的結論不正確。其實不 然,ArrayList操作的大部分應用場景不涉及多執行緒同步,所以它的方法更多的是單執行緒應用場景。執行緒同步是一個非常耗時(低效)的操作。若 ArrayList的所有非靜態方法都要考慮執行緒安全,那麼ArrayList完全可以將這個SyncRoot變成靜態私有的。現在它將SyncRoot 變為公開的,是讓呼叫者自己去決定操作是否需要執行緒安全。我們在編寫程式碼時,除非有這樣的要求,否則就應該始終考慮降低同步物件的可見性,將同步物件藏起 來,只開放給自己或自己的子類就夠了(需要開放給子類的情況其實也不多)。


改善C#程式建議之“避免鎖定不恰當的同步物件”

本文節選自《編寫高質量程式碼:改善C#程式的157個建議》,該書從語言本身、程式的設計和架構、編碼規範和程式設計習慣等三大方面對C#程式設計師遇到的經典問題給出了經驗性的解決方案,為C#程式設計師如何編寫更高質量的C#程式碼提供了157條極為寶貴的建議。

作者:陸敏技,資深軟體工程師、專案經理和架構師,從事軟體開發工作近10年。尤其精通微軟技術,對C#、WPF、WCF、ASP.NET和.NET技術有十分深入的研究,曾參與和主導了大量的相關專案的架構和開發工作,積累了豐富的經驗。作者部落格地址:http://space.itpub.net/12758868/

第一部分 語言篇
第1章 基本語言要素 / 2
建議1:正確操作字串 / 2
建議2:使用預設轉型方法 / 6
建議3:區別對待強制轉型與as和is / 9
建議4:TryParse比Parse好 / 12
建議5:使用int?來確保值型別也可以為null / 15
建議6:區別readonly和const的使用方法 / 16
建議7:將0值作為列舉的預設值 / 19
建議8:避免給列舉型別的元素提供顯式的值 / 20
建議9:習慣過載運算子 / 22
建議10:建立物件時需要考慮是否實現比較器 / 23
建議11:區別對待==和Equals / 27
建議12:重寫Equals時也要重寫GetHashCode / 29
建議13:為型別輸出格式化字串 / 32
建議14:正確實現淺拷貝和深拷貝 / 36
建議15:使用dynamic來簡化反射實現 / 40
第2章 集合和LINQ / 43
建議16:元素數量可變的情況下不應使用陣列 / 43
建議17:多數情況下使用foreach進行迴圈遍歷 / 45
建議18:foreach不能代替for / 51
建議19:使用更有效的物件和集合初始化 / 53
建議20:使用泛型集合代替非泛型集合 / 54
建議21:選擇正確的集合 / 57
建議22:確保集合的執行緒安全 / 61
建議23:避免將List作為自定義集合類的基類 / 64
建議24:迭代器應該是隻讀的 / 67
建議25:謹慎集合屬性的可寫操作 / 68
建議26:使用匿名型別儲存LINQ查詢結果 / 70
建議27:在查詢中使用Lambda表示式 / 73
建議28:理解延遲求值和主動求值之間的區別 / 75
建議29:區別LINQ查詢中的IEnumerable和IQueryable / 78
建議30:使用LINQ取代集合中的比較器和迭代器 / 80
建議31:在LINQ查詢中避免不必要的迭代 / 83
第3章 泛型、委託和事件 / 86
建議32:總是優先考慮泛型 / 86
建議33:避免在泛型型別中宣告靜態成員 / 88
建議34:為泛型引數設定約束 / 90
建議35:使用default為泛型型別變數指定初始值 / 92
建議36:使用FCL中的委託宣告 / 94
建議37:使用Lambda表示式代替方法和匿名方法 / 96
建議38:小心閉包中的陷阱 / 99
建議39:瞭解委託的實質 / 103
建議40:使用event關鍵字為委託施加保護 / 106
建議41:實現標準的事件模型 / 108
建議42:使用泛型引數相容泛型介面的不可變性 / 109
建議43:讓介面中的泛型引數支援協變 / 111
建議44:理解委託中的協變 / 112
建議45:為泛型型別引數指定逆變 / 114
第4章 資源管理和序列化 / 116
建議46:顯式釋放資源需繼承介面IDisposable / 116
建議47:即使提供了顯式釋放方法,也應該在終結器中提供隱式清理 / 119
建議48:Dispose方法應允許被多次呼叫 / 120
建議49:在Dispose模式中應提取一個受保護的虛方法 / 121
建議50:在Dispose模式中應區別對待託管資源和非託管資源 / 123
建議51:具有可釋放欄位的型別或擁有本機資源的型別應該是可釋放的 / 124
建議52:及時釋放資源 / 125
建議53:必要時應將不再使用的物件引用賦值為null / 127
建議54:為無用欄位標註不可序列化 / 131
建議55:利用定製特性減少可序列化的欄位 / 136
建議56:使用繼承ISerializable介面更靈活地控制序列化過程 / 137
建議57:實現ISerializable的子型別應負責父類的序列化 / 140
第5章 異常與自定義異常 / 144
建議58:用丟擲異常代替返回錯誤程式碼 / 144
建議59:不要在不恰當的場合下引發異常 / 147
建議60:重新引發異常時使用Inner Exception  / 150
建議61:避免在finally內撰寫無效程式碼 / 151
建議62:避免巢狀異常 / 157
建議63:避免“吃掉”異常 / 160
建議64:為迴圈增加Tester-Doer模式而不是將try-catch置於迴圈內 / 161
建議65:總是處理未捕獲的異常 / 162
建議66:正確捕獲多執行緒中的異常 / 166
建議67:慎用自定義異常 / 168
建議68:從System.Exception或其他常見的基本異常中派生異常 / 170
建議69:應使用finally避免資源洩漏 / 172
建議70:避免在呼叫棧較低的位置記錄異常 / 175
第6章 非同步、多執行緒、任務和並行 / 177
建議71:區分非同步和多執行緒應用場景 / 177
建議72:線上程同步中使用訊號量 / 180
建議73:避免鎖定不恰當的同步物件 / 184
建議74:警惕執行緒的IsBackground / 188
建議75:警惕執行緒不會立即啟動 / 189
建議76:警惕執行緒的優先順序 / 191
建議77:正確停止執行緒 / 193
建議78:應避免執行緒數量過多 / 194
建議79:使用ThreadPool或BackgroundWorker代替Thread / 196
建議80:用Task代替ThreadPool / 198
建議81:使用Parallel簡化同步狀態下Task的使用 / 202
建議82:Parallel簡化但不等同於Task預設行為 / 204
建議83:小心Parallel中的陷阱 / 205
建議84:使用PLINQ / 208
建議85:Task中的異常處理 / 209
建議86:Parallel中的異常處理 / 214
建議87:區分WPF和WinForm的執行緒模型 / 216
建議88:並行並不總是速度更快 / 220
建議89:在並行方法體中謹慎使用鎖 / 222
第二部分 架構篇
第7章 成員設計 / 226
建議90:不要為抽象類提供公開的構造方法 / 226
建議91:可見欄位應該重構為屬性 / 226
建議92:謹慎將陣列或集合作為屬性 / 227
建議93:構造方法應初始化主要屬性和欄位 / 228
建議94:區別對待override和new / 229
建議95:避免在構造方法中呼叫虛成員 / 235
建議96:成員應優先考慮公開基型別或介面 / 236
建議97:優先考慮將基型別或介面作為引數傳遞 / 237
建議98:用params減少重複引數 / 237
建議99:重寫時不應使用子類引數 / 238
建議100:靜態方法和例項方法沒有區別 / 239
建議101:使用擴充套件方法,向現有型別“新增”方法 / 240
第8章 型別設計 / 243
建議102:區分介面和抽象類的應用場合 / 243
建議103:區分組合和繼承的應用場合 / 245
建議104:用多型代替條件語句 / 248
建議105:使用私有建構函式強化單例 / 251
建議106:為靜態類新增靜態建構函式 / 253
建議107:區分靜態類和單例 / 255
建議108:將型別標識為sealed / 255
建議109:謹慎使用巢狀類 / 256
建議110:用類來代替enum / 257
建議111:避免雙向耦合 / 260
建議112:將現實世界中的物件抽象為類,將可複用物件圈起來就是名稱空間 / 262
第9章 安全性設計 / 264
建議113:宣告變數前考慮最大值 / 264
建議114:MD5不再安全 / 265
建議115:通過HASH來驗證檔案是否被篡改 / 268
建議116:避免用非對稱演算法加密檔案 / 269
建議117:使用SSL確保通訊中的資料安全 / 273
建議118:使用SecureString儲存金鑰等機密字串 / 284
建議119:不要使用自己的加密演算法 / 289
建議120:為程式集指定強名稱 / 289
建議121:為應用程式設定執行許可權 / 291
第三部分 編碼規範及習慣
第10章 命名規範 / 296
建議122:以.為名稱空間命名 / 296
建議123:程式集不必與名稱空間同名 / 296
建議124:考慮在名稱空間中使用複數 / 297
建議125:避免用FCL的型別名稱命名自己的型別 /  / 297
建議126:用名詞和名片語給型別命名 / 298
建議127:用形容片語給介面命名 / 299
建議128:考慮讓派生類的名字以基類名字作為字尾 / 300
建議129:泛型型別引數要以T作為字首 / 300
建議130:以複數命名列舉型別,以單數命名列舉元素 / 301
建議131:用PascalCasing命名公開元素 / 302
建議132:考慮用類名作為屬性名 / 302
建議133:用camelCasing命名私有欄位和區域性變數  / 303
建議134:有條件地使用字首  / 304
建議135: 考慮使用肯定性的短語命名布林屬性 / 305
建議136:優先使用字尾表示已有型別的新版本 / 306
建議137:委託和事件型別應新增上級字尾 / 307
建議138:事件和委託變數使用動詞或形容詞短語命名 / 308
建議139:事件處理器命名採用組合方式 / 309
第11章 程式碼整潔 / 311
建議140:使用預設的訪問修飾符 / 311
建議141:不知道該不該用大括號時,就用 / 312
建議142:總是提供有意義的命名 / 314
建議143:方法抽象級別應在同一層次 / 315
建議144:一個方法只做一件事 / 316
建議145:避免過長的方法和過長的類 / 317
建議146:只對外公佈必要的操作 / 318
建議147:重構多個相關屬性為一個類 / 319
建議148:不重複程式碼 / 320
建議149:使用表驅動法避免過長的if和switch分支 / 321
建議150:使用匿名方法、Lambda表示式代替方法 / 324
建議151:使用事件訪問器替換公開的事件成員變數  / 325
建議152:最少,甚至是不要註釋 / 326
建議153:若丟擲異常,則必須要註釋 / 326
第12章 規範開發行為 / 327
建議154:不要過度設計,在敏捷中體會重構的樂趣 / 327
建議155:隨生產程式碼一起提交單元測試程式碼 / 336
建議156:利用特性為應用程式提供多個版本 / 342
建議157:從寫第一個介面開始,就進行自動化測試  / 344

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/16502878/viewspace-708780/,如需轉載,請註明出處,否則將追究法律責任。

相關文章