在多線程編程時,開發人員經常會遭遇多個線程讀寫某個資源的情況。這就需要進行線程同步來保證 線程安全。一般情況下,我們的同步措施是使用鎖機制。但是,假如線程只對資源進行讀取操作,那麼根 本不需要使用鎖;反之,假如線程只對資源進行寫入操作,則應當使用互斥鎖(比如使用 Monitor 類等) 。還有一種情況,就是存在多個線程對資源進行讀取操作,同時每次只有一個線程對資源進行獨占寫入操 作。這正是本文主題--讀寫鎖的用武之地。ReaderWriterLock 類
.NET Framework BCL 在 1.1 版本時,給我們提供了一個 ReaderWriterLock 類來面對此種情景。但 是很遺憾,Microsoft 官方不推薦使用該類。Jeffrey Richter 也在他的《CLR via C#》一書中對它進行 了嚴厲的批判。下面是該類不受歡迎的主要原因:
性能。這個類實在是太慢了。比如它的 AcquireReaderLock 方法比 Monitor 類的 Enter 方法要慢 5 倍左右,而等待爭奪寫鎖甚至比 Monitor 類慢 6 倍。
策略。假如某個線程完成寫入操作後,同時面臨讀線程和寫線程等待處理。ReaderWriterLock 會優先 釋放讀線程,而讓寫線程繼續等待。但我們使用讀寫鎖是因為存在大量的讀線程和非常少的寫線程,這樣 寫線程很可能必須長時間地等待,造成寫線程饑餓,不能及時更新數據。更槽糕的情況是,假如寫線程一 直等待,就會造成活鎖。反之,我們讓 ReaderWriterLock 采取寫線程優先的策略。如果存在多個寫線程 ,而讀線程數量稀少,也會造成讀線程饑餓。幸運的是,現實實踐中,這種情況很少出現。一旦發生這種 情況,我們可以采取互斥鎖的辦法。
遞歸。ReaderWriterLock 類支持鎖遞歸。這就意味著該鎖清楚的知道目前哪個線程擁有它。假如擁有 該鎖的線程遞歸嘗試獲得該讀寫鎖,遞歸算法允許該線程獲得該讀寫鎖,並且增加獲得該鎖的計數。然而 該線程必須釋放該鎖相同的次數以便線程不再擁有該鎖。盡管這看起來是個很好的特性,但是實現這個“ 特性”代價太高。首先,因為多個讀線程可以同時擁有該讀寫鎖,這必須讓該鎖為每個線程保持計數。此 外,還需要額外的內存空間和時間來更新計數。這個特性對 ReaderWriterLock 類可憐的性能貢獻極大。 其次,有些良好的設計需要一個線程在此處獲得該鎖,然後在別處釋放該鎖(比如 .NET 的異步編程架構) 。因為這個遞歸特性,ReaderWriterLock 不支持這種編程架構。
資源洩漏。在 .NET 2.0 之前的版本中, ReaderWriterLock 類會造成內核對象洩露。這些對象只有 在進程終止後才能再次回收。幸運的是,.NET 2.0 修正了這個 Bug 。
此外,ReaderWriterLock 還有個令人擔心的危險的非原子性操作。它就是 UpgradeToWriteLock 方法 。這個方法實際上在更新到寫鎖前先釋放了讀鎖。這就讓其他線程有機會在此期間乘虛而入,從而獲得讀 寫鎖且改變狀態。如果先更新到寫鎖,然後釋放讀鎖。假如兩個線程同時更新將會導致另外一個線程死鎖 。
所以 Microsoft 決定構建一個新類來一次性解決上述所有問題,這就是 ReaderWriterLockSlim 類。 本來可以在原有的 ReaderWriterLock 類上修正錯誤,但是考慮到兼容性和已存在的 API ,Microsoft 放棄了這種做法。當然也可以標記 ReaderWriterLock 類為 Obsolete,但是由於某些原因,這個類還有 存在的必要。
ReaderWriterLockSlim 類
新的 ReaderWriterLockSlim 類支持三種鎖定模式:Read,Write,UpgradeableRead。這三種模式對 應的方法分別是 EnterReadLock,EnterWriteLock,EnterUpgradeableReadLock 。再就是與此對應的 TryEnterReadLock,TryEnterWriteLock,TryEnterUpgradeableReadLock,ExitReadLock, ExitWriteLock,ExitUpgradeableReadLock。Read 和 Writer 鎖定模式比較簡單易懂:Read 模式是典型 的共享鎖定模式,任意數量的線程都可以在該模式下同時獲得鎖;Writer 模式則是互斥模式,在該模式 下只允許一個線程進入該鎖。UpgradeableRead 鎖定模式可能對於大多數人來說比較新鮮,但是在數據庫 領域卻眾所周知。
這個新的讀寫鎖類性能跟 Monitor 類大致相當,大概在 Monitor 類的 2 倍之內。而且新鎖優先讓寫 線程獲得鎖,因為寫操作的頻率遠小於讀操作。通常這會導致更好的可伸縮性。起初, ReaderWriterLockSlim 類在設計時考慮到相當多的情況。比如在早期 CTP 的代碼還提供了 PrefersReaders, PrefersWritersAndUpgrades 和 Fifo 等競爭策略。但是這些策略雖然添加起來非常 簡單,但是會導致情況非常的復雜。所以 Microsoft 最後決定提供一個能夠在大多數情況下良好工作的 簡單模型。
ReaderWriterLockSlim 的更新鎖
現在讓我們更加深入的討論一下更新模型。UpgradeableRead 鎖定模式允許安全的從 Read 或 Write 模式下更新。還記得先前 ReaderWriterLock 的更新是非原子性,危險的操作嗎(尤其是大多數人根本沒 有意識到這點)?現在提供的新讀寫鎖既不會破壞原子性,也不會導致死鎖。新鎖一次只允許一個線程處 在 UpgradeableRead 模式下。
一旦該讀寫鎖處在 UpgradeableRead 模式下,線程就能讀取某些狀態值來決定是否降級到 Read 模式 或升級到 Write 模式。注意應當盡可能快的作出這個決定:持有 UpgradeableRead 鎖會強制任何新的讀 請求等待,盡管已存在的讀取操作仍然活躍。遺憾的是,CLR 團隊移除了 DowngradeToRead 和 UpgradeToWrite 兩個方法。如果要降級到讀鎖,只要簡單的在 ExitUpgradeableReadLock 方法後緊跟著 調用 EnterReadLock 方法即可:這可以讓其他的 Read 和 UpgradeableRead 獲得完成先前應當持有卻被 UpgradeableRead 鎖持有的操作。如果要升級到寫鎖,只要簡單調用 EnterWriteLock 方法即可:這可能 要等待,直到不再有任何線程在 Read 模式下持有鎖。不像降級到讀鎖,必須調用 ExitUpgradeableReadLock。在 Write 模式下不必非得調用 ExitUpgradeableReadLock。但是為了形式統 一,最好還是調用它。比如下面的代碼:
using System;
using System.Linq;
using System.Threading;
namespace Lucifer.CSharp.Sample
{
class Program
{
private ReaderWriterLockSlim rwLock = new ReaderWriterLockSlim();
void Sample()
{
bool isUpdated = true;
rwLock.EnterUpgradeableReadLock();
try
{
if (/* … 讀取狀態值來決定是否更新 … */)
{
rwLock.EnterWriteLock();
try
{
//… 寫入狀態值 …
}
finally
{
rwLock.ExitWriteLock();
}
}
else
{
rwLock.EnterReadLock();
rwLock.ExitUpgradeableReadLock();
isUpdated = false;
try
{
//… 讀取狀態值 …
}
finally
{
rwLock.ExitReadLock();
}
}
}
finally
{
if (isUpdated)
rwLock.ExitUpgradeableReadLock();
}
}
}
}
ReaderWriterLockSlim 的遞歸策略
新的讀寫鎖還有一個有意思的特性就是它的遞歸策略。默認情況下,除已提及的降級到讀鎖和升級到 寫鎖之外,所有的遞歸請求都不允許。這意味著你不能連續兩次調用 EnterReadLock,其他模式下也類似 。如果你這麼做,CLR 將會拋出 LockRecursionException 異常。當然,你可以使用 LockRecursionPolicy.SupportsRecursion 的構造函數參數讓該讀寫鎖支持遞歸鎖定。但不建議對新的開 發使用遞歸,因為遞歸會帶來不必要的復雜情況,從而使你的代碼更容易出現死鎖現象。
有一種特殊的情況永遠也不被允許,無論你采取什麼樣的遞歸策略。這就是當線程持有讀鎖時請求寫 鎖。Microsoft 曾經考慮提供這樣的支持,但是這種情況太容易導致死鎖。所以 Microsoft 最終放棄了 這個方案。
此外,這個新的讀寫鎖還提供了很多對應的屬性來確定線程是否在指定模型下持有該鎖。比如 IsReadLockHeld, IsWriteLockHeld 和 IsUpgradeableReadLockHeld 。你也可以通過 WaitingReadCount,WaitingWriteCount 和 WaitingUpgradeCount 等屬性來查看有多少線程正在等待持 有特定模式下的鎖。CurrentReadCount 屬性則告知目前有多少並發讀線程。RecursiveReadCount, RecursiveWriteCount 和 RecursiveUpgradeCount 則告知目前線程進入特定模式鎖定狀態下的次數。
小結
這篇文章分析了 .NET 中提供的兩個讀寫鎖類。然而 .NET 3.5 提供的新讀寫鎖 ReaderWriterLockSlim 類消除了 ReaderWriterLock 類存在的主要問題。與 ReaderWriterLock 相比, 性能有了極大提高。更新具有原子性,也可以極大避免死鎖。更有清晰的遞歸策略。在任何情況下,我們 都應該使用 ReaderWriterLockSlim 來代替 ReaderWriterLock 類。
Update 於 2008-12-07 0:06
Windows Vista 及其以後的版本新增了一個 SRWLock 原語。它以 Windows 內核事件機制為基礎而構 建。它的設計比較有意思。
SRW 鎖不支持遞歸。Windows Kernel 團隊認為支持遞歸會造成額外系統開銷,原因是為了維持准確性 需進行逐線程的計數。SRW 鎖也不支持從共享訪問升級到獨占訪問,同時也不支持從獨占訪問降級到共享 訪問。支持升級能力可能會造成難以接受的復雜性和額外系統開銷,這種開銷甚至會影響鎖內共享和獨占 獲得代碼的常見情況。它還要求定義關於如何選擇等待中的讀取器、等待中的寫入器和等待升級的讀取器 的策略,這又將與無偏向的基本設計目標相抵觸。我對其進行了 .NET 封裝。代碼如下:
using System;
using System.Threading;
using System.Runtime.InteropServices;
namespace Lucifer.Threading.Lock
{
/// <summary>
/// Windows NT 6.0 才支持的讀寫鎖。
/// </summary>
/// <remarks>請注意,這個類只能在 NT 6.0 及以後的版本中才能使用。 </remarks>
public sealed class SRWLock
{
private IntPtr rwLock;
/// <summary>
/// 該鎖不支持遞歸。
/// </summary>
public SRWLock()
{
InitializeSRWLock(out rwLock);
}
/// <summary>
/// 獲得讀鎖。
/// </summary>
public void EnterReadLock()
{
AcquireSRWLockShared(ref rwLock);
}
/// <summary>
/// 獲得寫鎖。
/// </summary>
public void EnterWriteLock()
{
AcquireSRWLockExclusive(ref rwLock);
}
/// <summary>
/// 釋放讀鎖。
/// </summary>
public void ExitReadLock()
{
ReleaseSRWLockShared(ref rwLock);
}
/// <summary>
/// 釋放寫鎖。
/// </summary>
public void ExitWriteLock()
{
ReleaseSRWLockExclusive(ref rwLock);
}
[DllImport("Kernel32", CallingConvention = CallingConvention.Winapi, ExactSpelling = true)]
private static extern void InitializeSRWLock(out IntPtr rwLock);
[DllImport("Kernel32", CallingConvention = CallingConvention.Winapi, ExactSpelling = true)]
private static extern void AcquireSRWLockExclusive(ref IntPtr rwLock);
[DllImport("Kernel32", CallingConvention = CallingConvention.Winapi, ExactSpelling = true)]
private static extern void AcquireSRWLockShared(ref IntPtr rwLock);
[DllImport("Kernel32", CallingConvention = CallingConvention.Winapi, ExactSpelling = true)]
private static extern void ReleaseSRWLockExclusive(ref IntPtr rwLock);
[DllImport("Kernel32", CallingConvention = CallingConvention.Winapi, ExactSpelling = true)]
private static extern void ReleaseSRWLockShared(ref IntPtr rwLock);
}
}
此外,在其他平台也有一些有意思的讀寫鎖。比如 Linux 內核中的讀寫鎖和 Java 中 的讀寫鎖。感興趣的同學可以自己研究一番。
讀寫鎖有個很常用的場景就是在緩存設計中。因為緩存中經常有些很穩定,不太長更新的內容。MSDN 的代碼示例就很經典,我原版拷貝一下,呵呵。代碼示例如下:
using System;
using System.Threading;
namespace Lucifer.CSharp.Sample
{
public class SynchronizedCache
{
private ReaderWriterLockSlim cacheLock = new ReaderWriterLockSlim();
private Dictionary<int, string> innerCache = new Dictionary<int, string>();
public string Read(int key)
{
cacheLock.EnterReadLock();
try
{
return innerCache[key];
}
finally
{
cacheLock.ExitReadLock();
}
}
public void Add(int key, string value)
{
cacheLock.EnterWriteLock();
try
{
innerCache.Add(key, value);
}
finally
{
cacheLock.ExitWriteLock();
}
}
public bool AddWithTimeout(int key, string value, int timeout)
{
if (cacheLock.TryEnterWriteLock(timeout))
{
try
{
innerCache.Add(key, value);
}
finally
{
cacheLock.ExitWriteLock();
}
return true;
}
else
{
return false;
}
}
public AddOrUpdateStatus AddOrUpdate(int key, string value)
{
cacheLock.EnterUpgradeableReadLock();
try
{
string result = null;
if (innerCache.TryGetValue(key, out result))
{
if (result == value)
{
return AddOrUpdateStatus.Unchanged;
}
else
{
cacheLock.EnterWriteLock();
try
{
innerCache[key] = value;
}
finally
{
cacheLock.ExitWriteLock();
}
return AddOrUpdateStatus.Updated;
}
}
else
{
cacheLock.EnterWriteLock();
try
{
innerCache.Add(key, value);
}
finally
{
cacheLock.ExitWriteLock();
}
return AddOrUpdateStatus.Added;
}
}
finally
{
cacheLock.ExitUpgradeableReadLock();
}
}
public void Delete(int key)
{
cacheLock.EnterWriteLock();
try
{
innerCache.Remove(key);
}
finally
{
cacheLock.ExitWriteLock();
}
}
public enum AddOrUpdateStatus
{
Added,
Updated,
Unchanged
};
}
}
再次 Update 於 2008-12-07 0:47
如果應用場景要求性能十分苛刻,可以考慮采用 lock-free 方案。但是 lock-free 有著固有缺陷: 極難編碼,極難證明其正確性。讀寫鎖方案的應用范圍更加廣泛一些。