鎖實現互斥的訪問,用於確保在同一時刻只有一個線程可以進入特殊的代碼片段,考慮下面的類:
class ThreadUnsafe { static int val1, val2; static void Go() { if (val2 != 0) Console.WriteLine (val1 / val2); val2 = 0; } }
這不是線程安全的:如果Go方法被兩個線程同時調用,可能會得到在某個線程中除數為零的錯誤,因為val2可能被一個線程設置為零,而另一個線程剛好執行到if和Console.WriteLine語句。
下面用c#中的lock來修正這個問題:
class ThreadSafe { static object locker = new object(); static int val1, val2; static void Go() { lock (locker) { if (val2 != 0) Console.WriteLine (val1 / val2); val2 = 0; } } }
在同一時刻只有一個線程可以鎖定同步對象(在這裡是locker),任何競爭的的其它線程都將被阻止,直到這個鎖被釋放。如果有大於一個的線程競爭這個鎖,那麼他們將形成稱為“就緒隊列”的隊列,以先到先得的方式授權鎖。因為一個線程的訪問不能與另一個重疊,互斥鎖有時被稱之對由鎖所保護的內容強迫串行化訪問。在這個例子中,保護了Go方法的邏輯,以及val1 和val2字段的邏輯。一個等候競爭鎖的線程被阻止將在ThreadState上為WaitSleepJoin狀態。稍後將討論一個線程通過另一個線程調用Interrupt或Abort方法來強制地被釋放。這是用於結束工作線程一個相當高效率的技術。C#的lock 語句實際上是調用Monitor.Enter和Monitor.Exit,中間夾雜try-finally語句的簡略版,下面是實際發生在之前例子中的Go方法:
Monitor.Enter (locker); try { if (val2 != 0) Console.WriteLine (val1 / val2); val2 = 0; } finally {
Monitor.Exit (locker); }
在同一個對象上,在調用第一個Monitor.Ente之前卻先調用了Monitor.Exit將引發異常。Monitor 也提供了TryEnter方法來實現一個超時功能——也用毫秒或TimeSpan,如果獲得了鎖返回true,反之沒有獲得返回false。TryEnter也可以沒有超時參數,“測試”一下鎖,如果鎖不能被獲取的話就立刻超時。
選擇同步對象
任何對所有有關系的線程都可見的對象都可以作為同步對象,但要滿足一個硬性規定:它必須是引用類型。建議同步對象最好私有在類裡面(比如一個私有實例字段)防止無意間從外部鎖定相同的對象。滿足這些規則,則同步對象可以兼對象和保護兩種作用。比如下面List :
class ThreadSafe { List <string> list = new List <string>(); void Test() { lock (list) { list.Add ("Item 1"); ...
一個專門字段(如在例子中的locker)是常用的方式 , 因為它可以精確控制鎖的范圍和粒度。用對象或類本身的類型作為一個同步對象,即:
lock (this) { ... }
或:
lock (typeof (Widget)) { ... } // 保護訪問靜態
的方式是不好的,因為存在可以在公共范圍訪問這些對象的潛在風險。
鎖並沒有以任何方式阻止對同步對象本身的訪問,換言之,x.ToString()不會由於另一個線程調用lock(x) 而被阻止。
嵌套鎖定
線程可以重復鎖定相同的對象,可以通過多次調用Monitor.Enter或lock語句來實現。當對應編號的Monitor.Exit被調用或最外面的lock語句完成後,對象那一刻即被解鎖。這就允許最簡單的語法實現一個方法的鎖調用另一個鎖:
static object x = new object(); static void Main() { lock (x) { Console.WriteLine ("I have the lock"); Nest(); Console.WriteLine ("I still have the lock"); } //在這鎖被釋放 } static void Nest() { lock (x) { ... } // 釋放了鎖?沒有完全釋放! }
線程只能在最開始的鎖或最外面的鎖時被阻止。
何時進行鎖定
作為一項基本規則,任何和多線程有關的會進行讀和寫的字段都應當加鎖。甚至是極平常的事情——單一字段的賦值操作,都必須考慮到同步問題。在下面的例子中Increment和Assign 都不是線程安全的:
class ThreadUnsafe { static int x; static void Increment() { x++; } static void Assign() { x = 123; } }
下面是Increment 和 Assign 線程安全的版本:
class ThreadUnsafe { static object locker = new object(); static int x; static void Increment() { lock (locker) x++; } static void Assign() { lock (locker) x = 123; } }
作為加鎖的另一個選擇,在一些簡單的情況下,也可以使用非阻止同步,將在後面討論即使像這樣的語句需要同步的原因。
鎖和原子操作
如果有很多變量在一些鎖中總是進行讀和寫的操作,那麼你可以稱之為原子操作。我們假設x 和 y不停地讀和賦值,他們在鎖內通過locker鎖定:
lock (locker) { if (x != 0) y /= x; }
你可以認為x 和 y 通過原子的方式訪問,因為代碼段沒有被其它的線程分開 或 搶占,別的線程改變x 和 y是無效的輸出,你永遠不會得到除數為零的錯誤,保證了x 和 y總是被相同的排他鎖訪問。
性能考量
鎖本身是非常快的,一個鎖在沒有堵塞的情況下一般只需幾十納秒(十億分之一秒)。如果發生堵塞,任務切換帶來的開銷接近於數微秒(百萬分之一秒)的范圍內,盡管在線程重組實際的安排時間之前它可能花費數毫秒(千分之一秒)。相反,該使用鎖而沒使用的會帶來更長的時間開銷。如果發生了死鎖和競爭鎖,鎖就會帶來反作用,由於太多的代碼被放置到鎖語句中了,引起其它線程不必要的被阻止。死鎖是兩線程彼此等待被鎖定的內容,導致兩者都無法繼續下去。爭用鎖是兩個線程任一個都可以鎖定某個內容,如果“錯誤”的線程獲取了鎖,則導致程序錯誤。
對於太多的同步對象死鎖是非常容易出現的症狀,一個好的規則是開始於較少的鎖,在一個可信的情況下涉及過多的阻止出現時,增加鎖的粒度。
線程安全
線程安全的代碼是指在面對任何多線程情況下,這代碼都沒有不確定的因素。線程安全首先完成鎖,然後減少在線程間交互的可能性。
一個線程安全的方法,在任何情況下可以可重入式調用。通用類型很少是線程安全的,原因如下:
因此線程安全經常只在需要實現的地方來實現,為了處理一個特定的多線程情況。不過,有一些方法來“欺騙”,有龐大和復雜的類安全地運行在多線程環境中。一種是犧牲粒度包含大段的代碼——甚至在排他鎖中訪問全局對象,迫使在更高的級別上實現串行化訪問。這一策略也很關鍵,讓非線程安全的對象用於線程安全代碼中,避免了相同的互斥鎖被用於保護對在非線程安全對象的所有的屬性、方法和字段的訪問。原始類型除外,很少的.NET framework類型實例相比於並發的只讀訪問,是線程安全的。責任在開放人員實現線程安全代表性地使用互斥鎖。另一個方式欺騙是通過最小化共享數據來最小化線程交互。這是一個很好的途徑,被暗中地用於“弱狀態”的中間層程序和web服務器。自多個客戶端請求同時到達,每個請求來自它自己的線程(效力於ASP.NET,Web服務器或者遠程體系結構),這意味著它們調用的方法一定是線程安全的。弱狀態設計(因伸縮性好而流行)本質上限制了交互的能力,因此類不能夠在每個請求間持久保留數據。線程交互僅限於可以被選擇創建的靜態字段,多半是在內存裡緩存常用數據和提供基礎設施服務,例如認證和審核。
線程安全與.NET Framework類型
鎖定可被用於將非線程安全的代碼轉換成線程安全的代碼。比較好的例子是在.NET framework方面,幾乎所有非基本類型的實例都不是線程安全的,而如果所有的訪問給定的對象都通過鎖進行了保護的話,他們可以被用於多線程代碼中。看這個例子,兩個線程同時為相同的List增加條目,然後枚舉它:
class ThreadSafe { static List <string> list = new List <string>(); static void Main() { new Thread (AddItems).Start(); new Thread (AddItems).Start(); } static void AddItems() { for (int i = 0; i < 100; i++) lock (list)list.Add ("Item " + list.Count); string[] items; lock (list) items = list.ToArray(); foreach (string s in items) Console.WriteLine (s); } }
在這種情況下,我們鎖定了list對象本身,這個簡單的方案是很好的。如果我們有兩個相關的list,也許我們就要鎖定一個共同的目標——單獨的一個字段,如果沒有其它的list出現,顯然鎖定它自己是明智的選擇。枚舉.NET的集合也不是線程安全的,在枚舉的時候另一個線程改動list的話,會拋出異常。為了不直接鎖定枚舉過程,在這個例子中,我們首先將項目復制到數組當中,這就避免了固定住鎖因為我們在枚舉過程中有潛在的耗時。
這裡的一個有趣的假設:想象如果List實際上為線程安全的,如何解決呢?代碼會很少!舉例說明,我們說我們要增加一個項目到我們假象的線程安全的list裡,如下:
if (!myList.Contains (newItem)) myList.Add (newItem);
無論與否list是否為線程安全的,這個語句顯然不是!(因此,可以說完全線程安全的通用集合類是基本不存在的。.net4.0中,微軟提供了一組線程安全的並行集合類,但是都是特殊的經過處理過的,訪問方式都經過了限定。),上面的語句要實現線程安全,整個if語句必須放到一個鎖中,用來保護搶占在判斷有無和增加新的之間。上述的鎖需要用於任何我們需要修改list的地方,比如下面的語句需要被同樣的鎖包括住:
myList.Clear();
來保證它沒有搶占之前的語句,換言之,我們必須鎖定差不多所有非線程安全的集合類們。內置的線程安全,顯而易見是浪費時間!
在寫自定義組件的時候,你可能會反對這個觀點——為什麼建造線程安全讓它容易的結果會變的多余呢 ?
有一個爭論:在一個對象包上自定義的鎖僅在所有並行的線程知道、並使用這個鎖的時候才能工作,而如果鎖對象在更大的范圍內的時候,這個鎖對象可能不在這個鎖范圍內。最糟糕的情況是靜態成員在公共類型中出現了,比如,想象靜態結構在DateTime上,DateTime.Now不是線程安全的,當有2個並發的調用可帶來錯亂的輸出或異常,補救方式是在其外進行鎖定,可能鎖定它的類型本身—— lock(typeof(DateTime))來圈住調用DateTime.Now,這會工作的,但只有所有的程序員同意這樣做的時候。然而這並靠不住,鎖定一個類型被認為是一件非常不好的事情。由於這些理由,DateTime上的靜態成員是保證線程安全的,這是一個遍及.NET framework一個普遍模式——靜態成員是線程安全的,而一個實例成員則不是。從這個模式也能在寫自定義類型時得到一些體會,不要創建一個不能線程安全的難題!
當寫公用組件的時候,好的習慣是不要忘記了線程安全,這意味著要單獨小心處理那些在其中或公共的靜態成員。