程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> .NET網頁編程 >> 關於.NET >> 並發事件: ReaderWriterGate鎖

並發事件: ReaderWriterGate鎖

編輯:關於.NET

有一天,我正在忙於一個咨詢項目,忽然碰到了一個以前從未碰到過的線程同步問題。該公司正在創 建一個 Web 服務,並且服務器收到的客戶端請求幾乎均需要以只讀方式存取某些共享數據。 有時,還會 收到需要修改共享數據的請求,同時我們需要使這些數據保持同步。這種情況聽起來非常適合采用讀寫鎖

然而,經過對項目的更深入研究,我了解到該公司的要求有一個不尋常的地方:當服務器接收到 Web 服務請求並且鎖的狀態為寫時,該鎖持續極長的一段時間。而當寫線程修改共享數據時,要讀這些數據的 其他 Web 服務請求需要進入服務器。這裡的問題是池中的線程正在被喚醒以處理讀請求,但是由於要等 待寫線程完成其任務,因而所有讀線程會很快轉入休眠狀態。

創建成百上千的池線程沒有花費很 長的時間,並且所有這些線程在操作系統內核內部都處於等待狀態。此時許多線程都無法運行,而只是白 白浪費資源(如每個線程的堆棧以及關聯的線程內核對象)。這很糟糕。而且越來越糟!當寫線程完成共 享數據的寫操作時,它會解鎖。然後鎖看到有成百上千的讀線程在等待著它,它便釋放所有處於等待狀態 的讀線程。服務器計算機有四個 CPU,並且這四個小 CPU 必須處理所有這些可運行的線程的工作。 Windows® 會使用其正常的輪詢調度技術來調度所有這些線程,但將 CPU 不斷地從一個線程切換到另 一個線程的開銷會大大降低性能、可伸縮性和吞吐量。

這一問題困擾了我好幾個月,直到最後我 設計了我自己的線程同步鎖 ReaderWriterGate,這一問題才得到解決。該鎖非常高效,並且可以非常好 地用於多種應用程序中。ReaderWriterGate 的有趣之處在於調用線程幾乎從不中斷,即使中斷了,也能 保證它們只中斷極短的一段時間。而且在內部,由於 ReaderWriterGate 使用的是我的 ResourceLock 派 生類之一(前面提到的 6 月的那個專欄中對此進行了討論),我只更改一行代碼即可讓 ReaderWriterGate 在內部使用旋轉鎖,從而在 Windows 內核中,線程從不中斷(我在 2005 年 10 月的 “並發事件”專欄中對此進行了介紹)。這樣可以提高性能,不過為了公平起見,它會要求使 用 ReaderWriterGate 的所有線程都具有相同的線程優先級並禁用優先級提升功能。這將進一步提高性能 。此外,ReaderWriterGate 只使用少數幾個線程便能夠完成非常多的工作。因此,不管向該 Gate 發 出多少請求,所需的資源都非常少。

我用我自己的方法實現了這一功能,並將其作為 Power Threading 庫的一部分免費提供,讀者可從 www.wintellect.com 下載。如果您要利用我的實現方法,請 遵守該庫附帶的《最終用戶許可協議》(EULA)。更多詳細信息請參閱 EULA。

接下來我將對 ReaderWriterGate 背後的原理進行介紹,探討一種可能的方法,並提供一種研究線程處理和線程同步的 新方式。我希望您能夠看到在您的現有代碼中可以應用此類思路的地方,從而盡可能少地重新設計架構, 您可以結合其中的某些理念,提高應用程序的性能和可伸縮性。

ReaderWriterGate 對象模型

要使用 ReaderWriterGate 類,只需了解以下幾個方法:

public sealed class 

ReaderWriterGate
{
  public ReaderWriterGate();
  public void QueueWrite(
   ReaderWriterGateCallback callback, Object state);
  public void QueueRead(
   ReaderWriterGateCallback callback, Object state);
  ... // Some methods not shown to simplify the discussion
}

可以看到,這是一個極其簡單的對象模型(比大多數鎖都簡單)。若要執行需要只讀/共享存取某 一資源的任務,只需調用 QueueRead 方法,而若要執行需要寫/獨占存取某一資源的任務,則只需調用 QueueWrite 方法。這兩種方法都有兩個參數。第一個參數是 ReaderWriterGateCallback 的一個實例, 用於標識所寫的方法;該方法執行實際的讀寫操作。ReaderWriterGateCallback 是一種委托類型,其定 義如下:

delegate void ReaderWriterGateCallback(
  ReaderWriterGateReleaser releaser);

傳遞給 QueueRead 和 QueueWrite 的第二個參數 是對任一對象的引用。此引用將傳遞給執行實際讀寫操作的回調方法。對於要完成任務並將任務提供給線 程的方法或執行實際任務的方法,這是從這些方法中獲取數據的一種簡單方式。熟悉 .NET ThreadPool 及其 QueueUserWorkItem 方法的人對此模型可能會稍微熟悉一些。

請注意,ReaderWriterGateCallback 委托要求回調方法只有一個 ReaderWriterGateReleaser 類型的 參數。下面給出了此類型的有關代碼:

public sealed class ReaderWriterGateReleaser : 

IDisposable
{
  // Returns second argument passed to QueueXxx
  public object State { get; }
  // Returns reference to Gate
  public ReaderWriterGate Gate { get; }  
  public void Release();  // Calls Dispose internally
  public void Dispose();  // Useful with C#'s using statement
}

在回調方法中,您可以通過查詢“狀態”屬性來檢索作為第二個參數傳遞給 QueueRead/QueueWrite 方法的對象引用。回調方法還可以通過查詢 Gate 屬性來檢索對 ReaderWriterGate 的引用(用於協調對方法的調用)。此方法不一定總是查詢該屬性,除非它要基於相 同的資源(受相同的 ReaderWriterGate 對象守護)啟動另一個任務。

ReaderWriterGateReleaser 對象的主要用途是給回調方法提供一種途徑,以在繼續執行過程中指示它已不再持有資源 Gate。接下來 我將通過一個例子來說明使用這種途徑的原因以及正確使用這種途徑的方法。事實上,我猜想大多數回調 方法都會完全忽略 ReaderWriterGateReleaser 參數。如果忽略此參數,那麼當回調方法返回時該 Gate  將自動釋放。

使用 ReaderWriterGate

假設您正在實施一個用於接受來自客戶的分類訂 單的 Web 服務。此外,該服務還接受來自公司員工的更新類別的請求。服務初始化時,它會為一個假定 的 CatalogOrderSystem 類構造一個實例(如圖 1 所示)。CatalogOrderSystem 對象構造完成之後,它 會初始化一個私有字段來引用 ReaderWriterGate 對象。該對象將用於調度讀/寫 CatalogOrderSystem 對象的線程。

Figure 1 CatalogOrderSystem Class

internal sealed class CatalogOrderSystem
{
  // The gate used to protect access to the Catalog Order System’s data
  private ReaderWriterGate m_gate = new ReaderWriterGate();
  public void UpdateCatalogWS(CatalogEntry[] catalogEntries)
  {
   ... // Perform any validation/pre-processing on catalogEntries...
   // Updating the catalog requires exclusive access to it.
   m_gate.QueueWrite(UpdateCatalog, catalogEntries);
  }
  // The code in this method has exclusive access to the catalog.
  private void UpdateCatalog(ReaderWriterGateReleaser r)
  {
   CatalogEntry[] catalogEntries = (CatalogEntry[]) r.State;
   
   ... // Update the catalog with the new entries...
  } // When this method returns, exclusive access is relinquished.
  public void BuyCatalogProductsWS(CatIDAndQuantity[] items)
  {
   // Buying products requires read access to the catalog.
   m_gate.QueueRead(BuyCatalogProducts, items);
  }
  // The code in this method has shared read access to the catalog.
  private void BuyCatalogProducts(ReaderWriterGateReleaser r)
  {
   using (r)
   {
     CatIDAndQuantity[] items = (CatIDAndQuantity[])r.State;
     foreach (CatIDAndQuantity item in items)
     {
      ... // Process each catalog item to build customer’s order...
     }
   } // When r is Disposed, read access is relinquished.
   ... // Save customer’s order to database
   ... // Send customer e-mail confirming order
  }
}

假設有一個請求要更新訂單類別;進行更新時會調用 UpdateCatalogWS 方法,並將對 CatalogEntry 對象數組的引用傳遞給該方法。在 UpdateCatalogWS 內部,首先是對該數組的對象進行一 些必要的驗證或預處理。這項工作是在未獲得 ReaderWriterGate 時進行的,這樣該線程便可與當前可能 正在存取 CatalogOrderSystem 對象的其他線程同時運行。如果一切順利,接下來就要用新的數據項實際 地修改訂單類別了,因而需要以獨占方式存取類別訂單數據。因此會調用 QueueWrite,並且會將 UpdateCatalog(實際更新類別的方法)和新 catalogEntries 的數組傳遞給 QueueWrite。

在內 部,當且僅當能夠將獨占存取權限授予該方法中的代碼執行操作時,ReaderWriterGate 才會有線程池線 程調用 UpdateCatalog。在調用 UpdateCatalog 時,它會假定沒有其他線程在讀或寫 OrderCatalogSystem 對象,並會更新類別項或做它需要做的事情。當 UpdateCatalog 方法返回時,Gate 將自動釋放並將允許調用隊列中的任何方法,從而使方法的代碼能夠操作 OrderCatalogSystem 對象。

現在,假設有一個要下訂單的請求;這意味著將調用 BuyCatalogProductsWS 方法,並將對 CatIDAndQuantity 對象數組的引用傳遞給該方法。在 BuyCatalogProductsWS 內部,它需要讀取類別信 息才能完成客戶的訂購請求。要確保能夠安全地讀取類別,必須具有讀權限。因此,將調用 QueueRead, 並傳遞 BuyCatalogProducts(實際完成客戶訂購的方法)和貨物數組。

當且僅當能夠將讀存取權 限授予該方法中的代碼執行操作時,ReaderWriterGate 才會調用 BuyCatalogProducts。在調用 BuyCatalogProducts 時,它會假定沒有其他線程在寫 OrderCatalogSystem 對象(即使其他線程可能在 讀取它)並會做它需要做的事情。BuyCatalogProducts 方法執行時,它首先檢查類別中的類別項;這需 要具有對類別的讀存取權限。然後,在檢查完所有項後將不再需要存取類別,但需要完成更多的工作才能 完成客戶訂單處理。通常,ReaderWriterGate 是在回調方法返回時釋放的。但為了提高性能和可伸縮性 ,BuyCatalogProducts 方法需要及早釋放 Gate ;在它不再執行用於讀取類別的代碼時就釋放 Gate  。

為了在該方法返回以前釋放 Gate ,我使用了 C# 的 using 語句。在該語句的右大括號處, C# 編譯器發出一個 finally 塊,該塊包含用於調用 ReaderWriterGateReleaser 的 Dispose 方法的代 碼。該 finally 塊將通知 ReaderWriterGate 線程已讀完資源並且該鎖可能允許調用其他排隊的方法。 此時,BuyCatalogProducts 方法不會返回,而是會繼續處理客戶的請求。當 BuyCatalogProducts 方法 返回時,ReaderWriterGate 將正常釋放。不過在這種情況下,ReaderWriterGate 內部的調用 BuyCatalogProducts 的代碼知道 ReaderWriterGate 已經釋放,並且不會再次釋放。事實上,按照 .NET Framework 實施 IDisposable 的指導原則,對一個 ReaderWriterGateReleaser 對象多次調用 Dispose (或 Release)時,實際上只有第一個調用會釋放 Gate ;其他調用都不起作用並且只是返回。

ReaderWriterGate 的實現方法

既然您已知道了如何使用 ReaderWriterGate 類,讓我們 來看一看如何實現它。ReaderWriterGate 對象包含私有實例字段,如圖 2 所示。

Figure 2 ReaderWriterGate Private Instance Fields

public sealed class ReaderWriterGate
{
  // Used for thread-safe access to other fields
  private ResourceLock m_syncLock = new MonitorResourceLock();
  // The current state of the gate; see the ReaderWriteGateStates enum
  private ReaderWriterGateStates m_state = ReaderWriterGateStates.Free;
  // The number of methods desiring shared access currently executing
  private Int32 m_numReaders = 0;
  // A FIFO queue of methods desiring exclusive access
  private Queue<ReaderWriterGateReleaser> m_qWriteRequests =
   new Queue<ReaderWriterGateReleaser>();
  // A FIFO queue of methods desiring shared access
  private Queue<ReaderWriterGateReleaser> m_qReadRequests =
   new Queue<ReaderWriterGateReleaser>();
}
private enum ReaderWriterGateStates
{
  // No methods are desiring access
  Free,
  // One or more methods desiring shared access are executing
  // and no writer methods are queued up desiring access
  OwnedByReaders,
  // One or more methods desiring shared access are executing
  // and one or more writer methods are queued up desiring access
  OwnedByReadersAndWriterPending,
  // One method desiring exclusive access is executing
  OwnedByWriter
}

當某個線程調用 QueueWrite 時,此方法會立即構造一個 ReaderWriterGateReleaser 對象, 將實際寫入資源的方法委托、ReaderWriterGate 對象引用、指示回調方法將寫入資源的 Boolean 值 true 以及作為 QueueWrite 第二個參數傳遞的狀態對象傳遞給構造函數。調用 QueueWrite 的線程會檢 查 m_state 字段以確定如何操作。圖 3 顯示了在 QueueWrite 中實現的基本邏輯。

Figure 3 Basic Logic of QueueWrite

1. If m_state is Free
  1.1 Set m_state to OwnedByWriter
  1.2 Invoke callback method via ThreadPool’s QueueUserWorkItem
  1.3 Return to caller
2. If m_state is OwnedByReaders or OwnedByReadersAndWriterPending
  2.1 Set m_state to OwnedByReadersAndWriterPending
  2.2 Add ReaderWriterGateReleaser object to m_qWriteRequests
  2.3 Return to caller
3. If m_state is OwnedByWriter
  3.1 Do not change m_state
  3.2 Add ReaderWriterGateReleaser object to m_qWriteRequests
  3.3 Return to caller

請注意,QueueWrite 始終是立即返回的;它從不等待回調方法執行 完畢。這種行為不同於常規線程的同步鎖,如 Monitor 或 ReaderWriterLock,對於它們來說,在允許調 用線程存取之前,這些線程會被中斷。對於 ReaderWriterGate,調用線程要麼讓線程池線程執行回調方 法,要麼向某隊列中添加一項;調用線程從不等待獲得受 Gate 保護的資源的存取權。這是一個極其重要 的區別。調用 QueueWrite 的線程無法確定 QueueWrite 返回時回調方法是否已執行。

當某個線程調用 QueueRead 時,此方法會立即構造一個 ReaderWriterGateReleaser 對象,將實際讀 取資源的方法委托、ReaderWriterGate 對象引用、指示回調方法將讀取資源的 Boolean 值 false 以及 作為 QueueRead 第二個參數傳遞的狀態對象傳遞給構造函數。圖 4 顯示了在 QueueRead 中實現的基本 邏輯。

Figure 4 Pseudocode for QueueRead

1. If m_state is Free or OwnedByReaders
  1.1 Set m_state to OwnedByReaders
  1.2 Add 1 to m_numReaders
  1.3 Invoke callback method via ThreadPool’s QueueUserWorkItem
  1.4 Return to caller
2. If m_state is OwnedByWriter or OwnedByReadersAndWriterPending
  2.1 Do not change m_state
  2.2 Add ReaderWriterGateReleaser object to m_qReadRequests
  2.3 Return to caller

和 QueueWrite 一樣,QueueRead 始終是立即返回的,它從不等待 回調方法執行完畢。調用 QueueRead 時,它要麼讓回調方法在線程池中排隊,要麼將 ReaderWriterGateReleaser 對象添加到 m_qReadRequests 隊列,然後該方法返回。調用 QueueRead 的 線程無法確定 QueueRead 返回時回調方法是否已執行。

為什麼 ReaderWriterGate 非常出色

我們再回到 Web 服務的例子。假設某個線程向服務器發出了一個寫請求。一開始,一個線程池線 程被喚醒並調用 QueueWrite。如果 Gate 處於“空閒”狀態,回調方法就會在線程池中排隊 ,同時 QueueWrite 方法立即返回,而此線程池線程則可能返回線程池。如果返回線程池,它會發現排隊 的回調方法並會調用該方法來執行工作。在這種情況下,一個線程會執行所有工作,並且沒有環境切換發 生 — 性能很出色!

現在,假設回調方法的執行要花很長一段時間。在執行過程中,其他請 求也可能正在傳入 Web 服務器。假設有 100 個請求要讀取數據。所有這些請求都在公共語言運行庫 (CLR) 的線程池中排隊。既然一個線程池線程在忙於執行先前排成隊列的寫方法,另一個線程池線程將被 喚醒並調用 Web 服務代碼。Web 服務代碼會調用 QueueRead,發現 Gate 不處於“空閒”狀 態,便只構造標識回叫的 ReaderWriterGateReleaser 對象,並將此對象添加到 m_qReadRequests 隊列 。然後,後一個線程池線程會從 QueueRead 返回,最終將返回到線程池。

請注意,此線程不中斷 。事實上,如果還有 99 個讀請求傳入該 Web 服務,則此線程會從線程池提取那 99 個讀請求(一次一 個),並只將一組 ReaderWriterGateReleaser 對象添加到 m_qReadRequests 隊列。此時,線程池只需 要兩個線程,這種情況下,一個線程在線程池中准備處理更多的傳入請求,另一個線程則仍在執行寫方法 。如果有寫請求傳入,則會構造 ReaderWriterGateReleaser 對象,然後添加到 m_qWriteRequests 隊列 ,而線程則再次立即返回線程池。這種情況下的可伸縮性極強,並且使用的系統資源也極少!

此 時,當第一個線程池線程完成寫方法的處理時,Gate 會自動釋放,之後 ReaderWriterGatemust 必須檢 查其隊列以確定接下來要調用的寫或讀方法。內部邏輯類似於圖 5 所示。

Figure 5 Pseudocode for Examining Queues

1. If a reader thread is releasing the gate, do steps 1.1 & 1.2;  
  else, go to step #2
  1.1. Subtract 1 from m_numReaders
  1.2. If m_numReaders is > 0, return to caller; else, goto step #2
2. If m_qWriteRequests.Count > 0
  2.1. Set m_state to OwnedByWriter
  2.2. Invoke 1 writer callback method via ThreadPool’s
     QueueUserWorkItem
  2.3. Return to caller
3. If m_qReadRequests.Count > 0
  3.1. Set m_state to OwnedByReaders
  3.2. Set m_numReaders = m_qReadRequests.Count
  3.3. Invoke all reader callback methods via ThreadPool’s
     QueueUserWorkItem
  3.4. Return to caller
4. Set m_state to Free
5. Return to caller

從該邏輯中可以看出,當讀回調方法返回時,Gate 會檢查它是否是最後 的讀程序,如果不是,將不調用其他任何方法,並且執行回調方法的線程池線程將返回線程池以作他用。

如果沒有其他讀程序執行讀操作,Gate 則會檢查 m_qWriteRequests 隊列中是否有寫程序方法在 排隊等候。如果有,則下一個寫程序的回調方法會被發送到 CLR 的線程池,從而喚醒一個線程池線程並 執行用於寫入資源的代碼。請注意,一次只能調用一個寫程序方法,因此可以確保以獨占方式存取受 Gate 保護的數據。還要注意,我的實現方法始終優先使用激活的寫程序,這可能會使讀程序一直等待, 但這是讀/寫鎖的通常情況。您也可以隨時根據需要,將策略改為優先使用讀程序。

這樣,如果有寫程序在排隊等候, Gate 則會檢查 m_qReadRequests 隊列中是否有讀程序方法在排隊 等候。如果有,所有讀回調方法都會被發送到 CLR 的線程池,理論上它們都將被立刻執行。不過,您可 能還記得我在前面已經提到,如果常規的讀/寫鎖釋放其所有處於等待狀態的讀線程,會出現一個嚴重的 性能問題:所有線程都是可運行的,並且操作系統必須完成許多環境切換,這會對性能產生負面影響。

對於 ReaderWriterGate,所有讀回調方法都會在線程池中排隊,線程池知道可以使用少數幾個線程一 次只調用少數幾個方法。理想的情形是,使用的線程數等於計算機中的 CPU 數,因此,不會發生環境切 換。這少數幾個線程會執行讀回調方法,然後返回線程池,再執行幾個。最終結果是使用極少的線程即可 完成許多工作並且完成工作的速度會更快,因為必需的環境切換減少了。性能更高,資源更少 — 何樂而 不為呢!

獲知回調完成時間

在我發布了我的 Power Threading Library(包含我的 ReaderWriterGate)的第一個版本之後不久, 就有人試圖將它用於 ASP.NET 應用程序。他的情況類似於我在前面所述的情況:客戶請求網頁,同時為 了獲得可伸縮性,網頁需要使用讀/寫的方法來存取共享數據。幸運的是,ASP.NET 2.0 可實現異步網頁 。有關這方面的詳細信息,建議閱讀 2005 年 10 月的 MSDN雜志 中 Jeff Prosise 關於 ASP.NET 2.0 中異步頁面的“超酷代碼”專欄。

要利用異步網頁,需要發出異步請求並返回給 ASP.NET 基礎結構一個 IAsyncResult 對象,以便它能 夠獲知異步操作完成的時間。您可以使用 ReaderWriterGate 類的 QueueWrite 和 QueueRead 方法來啟 動異步操作,但在我最初的實現方法中,我沒有說明如何獲知回調方法實際完成任務的時間。這意味著將 ReaderWriterGate 用於 ASP.NET Web 表單應用程序並非易事。

獲得該用戶的反饋之後,我意識到了這種應用會是多麼有用,並在 ReaderWriterGate 類中又添加了 幾個方法。以下就是這些新的方法:

public sealed class ReaderWriterGate
{
   public IAsyncResult BeginRead(ReaderWriterGateCallback callback,
      Object state, AsyncCallback asyncCallback, Object asyncState);
   public void EndRead(IAsyncResult ar);
   public IAsyncResult BeginWrite(ReaderWriterGateCallback callback,
      Object state, AsyncCallback asyncCallback, Object asyncState);
   public void EndWrite(IAsyncResult ar);
   ... // Other methods not shown
}

在編寫異步 ASP.NET 網頁或編寫需要知道回調方法何時執行完畢的任意類型的應用程序時,都應使用 BeginWrite/BeginRead 和 EndWrite/EndRead 方法,而不應使用 QueueWrite/QueueRead 方法。這些 BeginXxx 方法始終會立即返回,但現在我讓它們在返回時返回一個 IAsyncResult 對象,以標識排隊的 回調方法。我讓它返回的 IAsyncResult 對象與 CLR 異步編程模型的其余部分是吻合的,您可以像使用 其他任何 IAsyncResult 對象一樣地使用它。對於 ASP.NET 網頁,您可以將我的對象直接返回給 ASP.NET 基礎結構,而且它將知道在回調方法完成時完成頁面的處理。同樣,就像 CLR 的異步編程模型 一樣,當您調用 EndWrite/EndRead 時,我會拋出未經回調方法處理的任何異常。

總結

我對 ReaderWriterGate 的思路及我的實現方法感到特別自豪。我認為它可以滿足使用最少的資源開 發可伸縮應用程序和服務的現實需求。盡管線程處理已經存在許多年了,但我仍然相信可以新的方式來研 究它,繼而以新的理念和方式來設計應用程序和服務,從而充分地利用如今正在日益普及的多 CPU 機器 。

將您想向 Jeffrey 詢問的問題和提出的意見發送至 [email protected].

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved