標籤:
鎖實現互斥的訪問,用於確保在同一時刻只有一個線程可以進入特殊的程式碼片段,考慮下面的類:
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一個普遍模式——靜態成員是安全執行緒的,而一個執行個體成員則不是。從這個模式也能在寫自訂類型時得到一些體會,不要建立一個不能安全執行緒的難題!
當寫公用群組件的時候,好的習慣是不要忘記了安全執行緒,這意味著要單獨小心處理那些在其中或公用的靜態成員。
C#多線程實踐——鎖和安全執行緒