安全的線程同步

來源:互聯網
上載者:User
    到目前為止,線程同步最為普遍的用途是確保多線程對共用資源的互斥訪問。對於同步單個進程中的多個線程以實現互斥訪問,Win32 API中的CRITICAL_SECTION結構以及與它相關的函數提供了最為快速和高效的途徑,Microsoft .NET Framework並沒有提供CRITICAL_SECTION結構,但提供了一個類似的機制——該機制通過System.Threading.Monitor類和SyncBlock得以實現。
在這個專欄裡,我會解釋.NET Framework如何支援線程同步的這個普遍用途。另外,我還要解釋SyncBlock和Monitor被設計成現在這個樣子的動機以及它們是如何工作的。最後我還要解釋為什麼這個設計是糟糕的,以及如何用正確和安全的方式去使用該機制。

絕妙的主意 

.NET Framework採用了OOP式的結構。這就意味著:開發人員構造對象,然後調用類型的成員來操縱它。然而有時候這些對象也會被多個線程所操縱,因此,為了確保對象的狀態不被破壞,必須進行線程同步。在設計.NET Framework時,Microsoft的設計師們決定創造一個新的機制來讓開發人員們輕易地同步一個對象。
基本想法是這樣的:堆上的每個對象都關聯著一個可以被用於線程同步的資料結構(非常類似於Win32的CRITICAL_SECTION)。然後,FCL(framework class library,架構類庫)再提供一些方法(method)——當你將對象的引用傳遞過去時——使用這個資料結構來同步線程。
如果將這個設計運用在Win32下的非託管的C++類上,則該類看起來像這樣:

. A CRITICAL_SECTION for Every Object
class SomeType {
private:
   // 為每個對象關聯一個私人的CRITICAL_SECTION欄位
   CRITICAL_SECTION m_csObject;

public:
   SomeType() {
      // 在建構函式中初始化CRITICAL_SECTION欄位
      InitializeCriticalSection(&m_csObject);
   }

   ~SomeType() {
      // 在解構函式中delete CRITICAL_SECTION欄位
      DeleteCriticalSection(&m_csObject);
   }

   void SomeMethod() {
      // 在該函數中,我們使用了對象的CRITICAL_SECTION欄位
      // 來同步多個線程對該對象的訪問
EnterCriticalSection(&m_csObject);
      // 在這裡可以執行安全執行緒的代碼了...
      LeaveCriticalSection(&m_csObject);
   }

   void AnotherMethod() {
      // 在該函數中,我們使用了對象的CRITICAL_SECTION欄位
      // 來同步多個線程對該對象的訪問
      EnterCriticalSection(&m_csObject);
      // 在這裡可以執行安全執行緒的代碼了...
      LeaveCriticalSection(&m_csObject);
   }
};

本質上,.NET Framework為每個對象關聯一個類似CRITICAL_SECTION的欄位,並且負責對它進行初始化和刪除。開發人員要做的唯一一件事情是:在需要線程同步的方法中加入一些代碼以進入(Enter)和離開(Leave)該欄位。

實現絕妙的主意

現在,很明顯,為堆上的每個對象都關聯一個CRITICAL_SECTION欄位是一件很浪費的事情,特別是由於大多數對象從不需要安全執行緒的訪問。因此,.NET Framework小組設計了一個更高效的途徑來提供前面描述的功能。下面就來說說它是如何工作的:
當CLR(通用語言執行平台)初始化時,它分配一個SyncBlock的緩衝區。一個SyncBlock就是一個可以根據需要關聯到任何對象的記憶體塊。SyncBlock具有與Win32 CRITICAL_SECTION相同的欄位。
堆上的每個對象在建立時都關聯了兩個額外的欄位——第一個是MethodTablePointer(方法表指標),包含了該類型的方法表的地址。基本上,這個指標使你能夠擷取堆上的每個對象的類型資訊。事實上,當你調用System.Object的GetType方法時,該方法通過對象的MethodTablePointer欄位來確定對象的類型。另一個額外的欄位——稱為SyncBlockIndex——包含一個32位的有符號整數,在SyncBlock緩衝區中索引一個SyncBlock。
當一個對象被構造時,它的SyncBlockIndex欄位會被初始化為一個負值——表示根本不指向任何SyncBlock。然後,當某個方法被調用以同步對該對象的訪問時,CLR在SyncBlock緩衝區中尋找一個閒置SyncBlock,並且讓該對象的SyncBlockIndex欄位指向它。換句話說,SyncBlock只在某個對象需要同步欄位時才會被關聯到該對象。而當不再有任何線程對該對象進行同步訪問時,該對象的SyncBlockIndex欄位就會被重設為一個負值,並且相應的SyncBlock也被釋放,以便在將來可以被重新關聯到其它對象。
圖 2. .NET 中的線程同步


圖2是這個思想的具體表現形式。在該圖的CLR Data Structures部分,你可以看到,系統所知道的每個類型都有一個對應的資料結構。你也可以看到SyncBlock結構的集合。在該圖的Managed Heap部分你可以看到有三個對象(ObjectA,ObjectB,ObjectC)被建立了。每個對象的MethodTablePointer欄位都指向相應類型的方法表。通過方法表,你可以獲得每個對象的類型。所以,我們可以很容易的看到,ObjectA和ObjectB都是SomeType類型的執行個體,而ObjectC是AnotherType類型的執行個體。
ObjectA的SyncBlockIndex欄位被設為0,這表示SyncBlock #0目前正被ObjectA使用。另一方面,ObjectB的SyncBlockIndex被設為-1,表示並沒有任何SyncBlock與它關聯。最後,ObjectC的SyncBlockIndex為2,表明它正在使用SyncBlock #2。在本例中,SyncBlock #1沒有被使用,但是將來可能會與某個對象關聯。
因此,從邏輯上說,堆上的每個對象都關聯著一個SyncBlock——它可以被用於快速互斥的線程同步。然而,從物理上說,SyncBlock僅當被需要時才會與某個對象關聯,並且在對象不再需要它時從對象上脫離。這意味著記憶體的使用是有效率的。另外,如果有必要,SyncBlock緩衝可以建立更多的SyncBlock,因此你不用擔心它會由於同一時刻同步對象過多而被用盡。

使用Monitor來操縱SyncBlock

既然你已經理解了SyncBlock,就讓我們來看看如何鎖定一個對象。要鎖定或解鎖一個對象,你可以使用System.Threading.Monitor類。該類型的所有方法都是靜態。調用下面的方法可以鎖定一個對象:

public static void Enter(object obj);

當你調用Enter方法時,它首先檢查指定的對象的SyncBlockIndex是否為負值,如果是的,則該方法從SyncBlock緩衝中找一個閒置SyncBlock並且將它的索引儲存到對象的SyncBlockIndex中。一旦對象上已經關聯了SyncBlock,則Enter方法就會檢查對象的SyncBlock,看看當前是否有另一個線程正擁有這個SyncBlock。如果當前沒有任何進程擁有它,則當前調用Enter的線程成為該對象的所有者。另一方面,如果另一個線程已經擁有了該SyncBlock,則當前調用Enter的線程會掛起,直到那個線程釋放對SyncBlock的所有權。
如果你想寫更具防禦性的代碼,則可以調用下面的TryEnter方法之一:

public static Boolean TryEnter(object obj);

public static Boolean TryEnter(object obj,
    int millisecondsTimeout);

public static Boolean TryEnter(object obj,
    TimeSpan timeout);

第一個TryEnter只是簡單的檢查調用它的線程能否獲得對象的SyncBlock的所有權,如果成功則返回true。另外兩個方法則允許你指定一個等待時間,表示你允許線程等待所有權多久。如果不能獲得所有權,則三個方法都會返回false。
一旦獲得了所有權,代碼就可以安全地訪問對象的欄位了。訪問完畢之後,線程還應該調用Exit來釋放SyncBlock:

public static void Exit(object obj);

如果線程並沒有獲得指定對象的SyncBlock的所有權,則調用Exit會拋出一個SynchronizationLockException。同時還要注意,線程可以遞迴式擷取一個SyncBlock的所有權,每次成功的Enter必須對應一個Exit,這樣最終SyncBlock才會被釋放(這一點與Win32的CRITICAL_SECTION是一樣的——譯者)。

Synchronizing the Microsoft Way

現在讓我們看看圖3的範例程式碼,它展示了如何使用Monitor的Enter和Exit方法來鎖定和解鎖一個對象。注意,LastTransaction屬性(property)的實現需要調用Enter,Exit以及一個臨時變數dt。這是非常重要的,這樣可以避免返回一個已經被破壞的值——如果一個線程調用PerformTransaction時另一個線程在訪問該屬性就會發生這種情況。

用C#的Lock語句來簡化代碼

因為這種“調用Enter——訪問受保護資源——調用Exit”的模式是如此普遍,所以C#乾脆提供了特殊的文法來簡化這種代碼。圖4的兩段C#代碼片斷功能相同,但是後者更為簡潔。使用C#的lock語句,你可以將Transaction類充分簡化。特別要注意圖5中所展示的改進後的LastTransaction屬性,它不再需要臨時變數了。
除了能夠簡化代碼外,lock語句還確保了Monitor.Exit一定會被調用,從而即使在try塊中產生了一個異常,SyncBlock也會被釋放。你應該始終將異常處理與同步機制結合起來使用,以確保鎖被正確釋放。然而,如果你使用C#的lock語句,則編譯器會自動幫你產生正確的代碼。另外,Visual Basic .NET也有一個類似於C#的lock語句的SyncLock語句,它們做同樣的事情。

Synchronizing Static Members the Microsoft Way

Transaction類示範了如何同步訪問一個對象的執行個體欄位。但是,如果你的類型定義了一些靜態欄位以及訪問這些欄位的靜態函數又當如何呢?在這種情況下,堆上並沒有該類型的執行個體,因而也就沒有可用的SyncBlock或者傳遞給Monitor.Enter和Monitor.Exit方法的對象引用。
事實上,包含某個類型的類型描述元的記憶體塊是位於堆上的對象。圖2中並沒有表現出這一點,但是SomeType的類型描述元和AnotherType的類型描述元所佔的記憶體塊其實本身都是對象,並且同樣也有MethodTablePointer欄位和SyncBlockIndex欄位。這就意味著SyncBlock可以被關聯到一個類型,並且類型對象(type object,指“描述一個類型”的對象)的引用可以被傳遞給Monitor的Enter和Exit方法。在圖6所示的Transaction類中,所有的成員都改成了靜態,並且PerformTransaction方法和LastTransaction屬性也作了改動以展示Microsoft希望開發人員如何同步對靜態成員的訪問。
在PerformTransaction方法和LastTransaction屬性中,你不再會看到this關鍵字,因為在靜態成員中不能使用它。我將類型的類型描述元對象的引用傳給lock語句。這個引用是通過C#的typeof操作符得到的,typeof操作符返回指定對象的對象描述符的引用。在Visual Basic .NET中,GetType操作符具有同樣的功能。

為什麼絕妙的主意並不那麼絕妙

如你所見,使堆上的每個對象邏輯上關聯一個用於同步的資料結構的主意聽起來很不錯。但是實際上這是一個糟糕的主意。聽我解釋原因。還記得在本文開頭展示的非託管的C++代碼嗎?如果由你來寫,你會將CRITICAL_SECTION欄位的存取權限設為public嗎?當然不會——那簡直是荒謬的。將這個欄位設為public會允許程式中的任何代碼操縱該CRITICAL_SECTION結構,這樣惡意代碼很容易就能夠將使用該類型的執行個體的任何線程死結住。
呃...猜猜看發生了什麼——SyncBlock正如一個public欄位一樣!任何一段代碼在任何時候都可以將任何對象的引用傳給Monitor的Enter和Exit方法。事實上,任何類型描述元的引用也同樣可以被傳給Monitor的方法。
圖7的代碼顯示了這種情況是多麼糟糕。這裡,Main方法中建立了一個App對象,然後鎖定該對象,並在某個時刻發生一次垃圾收集(在這段代碼中,強制一次垃圾收集),當App的Finalize方法被調用時,它也會試圖鎖定該對象,但是由於程式的主線程已經鎖定了該對象,所以CLR的Finalize線程無法鎖定它。這就導致CLR的Finalize線程停止了——於是在當前進程(可能包含多個AppDomain)中再也不會有其它對象可以被finalize,也不再有其它可finalize的對象在堆上的記憶體會被回收。
幸運的是有一個解決方案,只不過那意味著你得拋開Microsoft的設計和建議。取而代之的是定義一個private的System.Object欄位作為你的類型的成員,構造它,然後將其引用傳遞給C#的lock語句或Visual Basic .NET的SyncLock語句。圖8展示了如何重寫Transaction類以便讓用於同步的對象成為該類的私人成員。同樣地,圖9展示了當Transaction類的成員全為靜態時如何去重寫它。
看起來,僅僅為了同步而構造一個System.Object對象是比較怪異的。我感覺Microsoft對Monitor類的設計並不恰當。應該讓你為每個想要同步的類型(原文為type,疑為object)構造一個Monitor類型的執行個體。這樣,靜態方法(Monitor類的靜態方法)就會成為不需要System.Object參數的執行個體方法。這將解決所有的問題,並且充分簡化開發人員的編程模型。
另外,如果你建立具有許多欄位的複雜類型,則在任何時候,你的方法和屬性可能只需要鎖定這些欄位的一個子集。你始終可以通過將指定欄位的引用傳給lock或Monitor.Enter來鎖定它。當然,如果欄位為私人(我始終建議如此),則我只會考慮這樣做。如果你想要將幾個欄位一起鎖定,那麼你可以始終將其中的一個傳給lock或Enter。或者,你還可以構造一個System.Object對象——它的唯一意圖就是用於鎖定一集欄位。lock段(臨界段)越細化,代碼的效能和可測性就越好[1]
[1] 譯註:作者的意思是,應該保持lock(臨界)段的短小,換句話說,一個lock(臨界)段應該執行盡量少的代碼,這樣才能保證其它線程在lock(臨界)區上的等待時間盡量短,並且死結的可能性也更小。

未裝箱的(unboxed)實值型別執行個體

    在結束這個專欄之前,我想要指出一個有關同步的bug,我第一次遇到它時花了好幾個小時來跟蹤。下面的代碼片斷示範了這個問題:

class AnotherType {

   // 一個 未裝箱的(unboxed) Boolean 實值型別執行個體
   private Boolean flag = false;
   public Boolean Flag {
      set {
         Monitor.Enter(flag); // 將flag裝箱並鎖定裝箱後的對象
         flag = value;          // 而實際的值卻未受保護
         Monitor.Exit(flag);   // 將flag裝箱並試圖unlock裝箱後的對象
      }
   }
}

你可能會驚訝於在這段代碼中並沒有發生任何線程同步!原因是:flag是個未裝箱的實值型別執行個體,而並非一個參考型別。未裝箱的實值型別執行個體並沒有MethodTablePointer和SyncBlockIndex這兩個額外的欄位。這就意味著一個未裝箱的實值型別執行個體不可能有一個與它關聯的SyncBlock。
Monitor的Enter和Exit方法要求一個指向堆上的對象的引用。當C#,Visual Basic .NET和許多其它編譯器看到一段代碼試圖將未裝箱的實值型別執行個體傳給需要對象引用的方法時,它們會自動產生代碼來將該實值型別的執行個體裝箱(box)。裝箱後的執行個體(位於堆上)將會擁有一個MethodTablePointer和一個SyncBlockIndex,因而可以被用於線程同步。然而,每調用這樣的函數一次就會進行一次新的裝箱,即產生一個新的裝箱後的執行個體,這個執行個體與以前裝箱的執行個體都不相同,也就是說,我們每次lock和unlock的都是不同的對象。
例如,在上面代碼片斷中,當Flag屬性的set方法被調用時,它調用了Monitor的Enter方法。Enter需要一個參考型別,因此flag被裝箱,並且裝箱後的對象的引用被傳遞給Enter,該對象的SyncBlock現在歸調用線程所有。如果另一個線程現在也要訪問這個屬性,那麼flag將會被再次裝箱,產生一個新的對象,它擁有自己的SyncBlock。另外,對Exit的調用也會導致一次裝箱操作。
正如我前面所說,我花了好幾個小時才發現問題所在。如果你想要同步對一個未裝箱的實值型別執行個體的訪問,那麼你必須分配一個System.Object對象,並利用它來進行同步。圖10中的代碼是正確的。
另外,如果你使用C#的lock語句來代替Monitor.Enter與Monitor.Exit,那麼C#編譯器會幫你避免意外地試圖去lock一個實值型別。當你將一個未裝箱的實值型別執行個體傳給lock語句時,C#編譯器會報錯。例如,如果你試圖將一個Boolean(C#中的bool)傳給lock語句,那麼你將看到如下的錯誤:“error CS0185:'bool' is not a reference type as required by the lock statement”。而在Visual Basic .NET中,如果你對SyncLock語句使用未裝箱的實值型別執行個體,編譯器也會報錯:“error BC30582: 'SyncLock' operand cannot be of type 'Boolean' because 'Boolean' is not a reference type”。


圖 3. Using Enter and Exit Methods
class Transaction {

   // Private field holding the time of
   // the last transaction performed
   private DateTime timeOfLastTransaction;

   public void PerformTransaction() {
      // Lock this object
      Monitor.Enter(this);

      // Perform the transaction...

      // Record time of the most recent transaction
      timeOfLastTransaction = DateTime.Now;

      // Unlock this object
      Monitor.Exit(this);
   }

   // Public read-only property returning
   // the time of the last transaction
   public DateTime LastTransaction {
      get {
         // Lock this object
         Monitor.Enter(this);

         // Save the time of the last transaction
         // in a temporary variable
         DateTime dt = timeOfLastTransaction;

         // Unlock this object
         Monitor.Exit(this);

         // Return the value in the temporary variable
         return(dt);
      }
   }
}

. Regular and Simple Lock and Unlock
// Regular function
public void SomeMethod() {
   // Lock the object
   Object oTemp = this;
   Monitor.Enter(oTemp);
   try {
          // Access the object
         ...
         // Unlock the object
   }
   finally {
     Monitor.Exit(oTemp);
   }
   // Return
}

// Simple function
public void SomeMethod() {
   // Lock the object
   lock (this) {

      // Access the object
      ...
     // Unlock the object
   }

   // Return
}

. Transaction Class
class Transaction {

   // Private field holding the time of
   // the last transaction performed
   private DateTime timeOfLastTransaction;

   public void PerformTransaction() {
      lock (this) {
         // Perform the transaction...

         // Record time of the most recent transaction
         timeOfLastTransaction = DateTime.Now;
      }
   }

   // Public read-only property returning
   // the time of the last transaction
   public DateTime LastTransaction {
      get {
         lock (this) {
            // Return the time of the last transaction
            return timeOfLastTransaction;
         }
      }
   }
}

. New Transaction Class
class Transaction {

   // Private field holding the time of
   // the last transaction performed
   private static DateTime timeOfLastTransaction;

   public static void PerformTransaction() {
      lock (typeof(Transaction)) {
         // Perform the transaction...

         // Record time of the most recent transaction
         timeOfLastTransaction = DateTime.Now;
      }
   }

   // Public read-only property returning
   // the time of the last transaction
   public static DateTime LastTransaction {
      get {
         lock (typeof(Transaction)) {
            // Return the time of the last transaction
            return timeOfLastTransaction;
         }
      }
   }
}

. Threads Banging Heads
using System;
using System.Threading;

class App {
   static void Main() {
      // Construct an instance of the App object
      App a = new App();

      // This malicious code enters a lock on
      // the object but never exits the lock
      Monitor.Enter(a);

      // For demonstration purposes, let's release the
      // root to this object and force a garbage collection
      a = null;
      GC.Collect();

      // For demonstration purposes, wait until all Finalize
      // methods have completed their execution - deadlock!
      GC.WaitForPendingFinalizers();

      // We never get to the line of code below!
      Console.WriteLine("Leaving Main");
   }

   // This is the App type's Finalize method
   ~App() {
      // For demonstration purposes, have the CLR's
      // Finalizer thread attempt to lock the object.
      // NOTE: Since the Main thread owns the lock,
      // the Finalizer thread is deadlocked!
      lock (this) {
         // Pretend to do something in here...
      }
   }
}

. Transaction with Private Object
class Transaction {

   // Private Object field used
   // purely for synchronization
   private Object objLock = new Object();

   // Private field holding the time of
   // the last transaction performed
   private DateTime timeOfLastTransaction;

   public void PerformTransaction() {
      lock (objLock) {
         // Perform the transaction...

         // Record time of the most recent transaction
         timeOfLastTransaction = DateTime.Now;
      }
   }

   // Public read-only property returning
   // the time of the last transaction
   public DateTime LastTransaction {
      get {
         lock (objLock) {
            // Return the time of the last transaction
            return timeOfLastTransaction;
         }
      }
   }
}

. Transaction with Static Members
class Transaction {

   // Private, static Object field
   // used purely for synchronization
   private static Object objLock = new Object();

   // Private field holding the time of
   // the last transaction performed
   private static DateTime timeOfLastTransaction;

   public static void PerformTransaction() {
      lock (objLock) {
         // Perform the transaction...

         // Record time of the most recent transaction
         timeOfLastTransaction = DateTime.Now;
      }
   }

   // Public read-only property returning
   // the time of the last transaction
   public static DateTime LastTransaction {
      get {
         lock (objLock) {
            // Return the time of the last transaction
            return timeOfLastTransaction;
         }
      }
   }
}

. Now There's Synchronization
class AnotherType {

   // An unboxed Boolean value type
   private Boolean flag = false;

   // A private Object field used to
   // synchronize access to the flag field
   private Object flagLock = new Object();

   public Boolean Flag {
      set {
         Monitor.Enter(flagLock);
         flag = value;
         Monitor.Exit(flagLock);
      }
   }
}

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.