本篇的內容主要是介紹 ReaderWriterLockSlim 類,來實現多線程下的讀寫分離。
ReaderWriterLockSlim
ReaderWriterLock 類:定義支持單個寫線程和多個讀線程的鎖。
ReaderWriterLockSlim 類:表示用於管理資源訪問的鎖定狀態,可實現多線程讀取或進行獨占式寫入訪問。
兩者的 API 十分接近,而且 ReaderWriterLockSlim 相對 ReaderWriterLock 來說 更加安全。因此本文主要講解 ReaderWriterLockSlim 。
兩者都是實現多個線程可同時讀取、只允許一個線程寫入的類。
ReaderWriterLockSlim
老規矩,先大概了解一下 ReaderWriterLockSlim 常用的方法。
常用方法
方法 | 說明 |
---|---|
EnterReadLock() | 嘗試進入讀取模式鎖定狀態。 |
EnterUpgradeableReadLock() | 嘗試進入可升級模式鎖定狀態。 |
EnterWriteLock() | 嘗試進入寫入模式鎖定狀態。 |
ExitReadLock() | 減少讀取模式的遞歸計數,並在生成的計數為 0(零)時退出讀取模式。 |
ExitUpgradeableReadLock() | 減少可升級模式的遞歸計數,並在生成的計數為 0(零)時退出可升級模式。 |
ExitWriteLock() | 減少寫入模式的遞歸計數,並在生成的計數為 0(零)時退出寫入模式。 |
TryEnterReadLock(Int32) | 嘗試進入讀取模式鎖定狀態,可以選擇整數超時時間。 |
TryEnterReadLock(TimeSpan) | 嘗試進入讀取模式鎖定狀態,可以選擇超時時間。 |
TryEnterUpgradeableReadLock(Int32) | 嘗試進入可升級模式鎖定狀態,可以選擇超時時間。 |
TryEnterUpgradeableReadLock(TimeSpan) | 嘗試進入可升級模式鎖定狀態,可以選擇超時時間。 |
TryEnterWriteLock(Int32) | 嘗試進入寫入模式鎖定狀態,可以選擇超時時間。 |
TryEnterWriteLock(TimeSpan) | 嘗試進入寫入模式鎖定狀態,可以選擇超時時間。 |
ReaderWriterLockSlim 的讀、寫入鎖模板如下:
private static ReaderWriterLockSlim toolLock = new ReaderWriterLockSlim();
// 讀
private T Read()
{
try
{
toolLock.EnterReadLock(); // 獲取讀取鎖
return obj;
}
catch { }
finally
{
toolLock.ExitReadLock(); // 釋放讀取鎖
}
return default;
}
// 寫
public void Write(int key, int value)
{
try
{
toolLock.EnterUpgradeableReadLock();
try
{
toolLock.EnterWriteLock();
/*
*
*/
}
catch
{
}
finally
{
toolLock.ExitWriteLock();
}
}
catch { }
finally
{
toolLock.ExitUpgradeableReadLock();
}
}
訂單系統示例
這里來模擬一個簡單粗糙的訂單系統。
開始編寫代碼前,先來了解一些方法的具體使用。
EnterReadLock()
/ TryEnterReadLock
和 ExitReadLock()
成對出現。
EnterWriteLock()
/ TryEnterWriteLock()
和 ExitWriteLock()
成對出現。
EnterUpgradeableReadLock()
進入可升級的讀模式鎖定狀態。
EnterReadLock()
使用 EnterUpgradeableReadLock()
進入升級狀態,在恰當時間點 通過 EnterWriteLock()
進入寫模式。(也可以倒過來)
定義三個變量:
ReaderWriterLockSlim 多線程讀寫鎖;
MaxId 當前訂單 Id 的最大值;
orders 訂單表;
private static ReaderWriterLockSlim tool = new ReaderWriterLockSlim(); // 讀寫鎖
private static int MaxId = 1;
public static List<DoWorkModel> orders = new List<DoWorkModel>(); // 訂單表
// 訂單模型
public class DoWorkModel
{
public int Id { get; set; } // 訂單號
public string UserName { get; set; } // 客戶名稱
public DateTime DateTime { get; set; } // 創建時間
}
然后實現查詢和創建訂單的兩個方法。
分頁查詢訂單:
在讀取前使用 EnterReadLock()
獲取鎖;
讀取完畢后,使用 ExitReadLock()
釋放鎖。
這樣能夠在多線程環境下保證每次讀取都是最新的值。
// 分頁查詢訂單
private static DoWorkModel[] DoSelect(int pageNo, int pageSize)
{
try
{
DoWorkModel[] doWorks;
tool.EnterReadLock(); // 獲取讀取鎖
doWorks = orders.Skip((pageNo - 1) * pageSize).Take(pageSize).ToArray();
return doWorks;
}
catch { }
finally
{
tool.ExitReadLock(); // 釋放讀取鎖
}
return default;
}
創建訂單:
創建訂單的信息十分簡單,知道用戶名和創建時間就行。
訂單系統要保證的時每個 Id 都是唯一的(實際情況應該用Guid),這里為了演示讀寫鎖,設置為 數字。
在多線程環境下,我們不使用 Interlocked.Increment()
,而是直接使用 += 1
,因為有讀寫鎖的存在,所以操作也是原則性的。
// 創建訂單
private static DoWorkModel DoCreate(string userName, DateTime time)
{
try
{
tool.EnterUpgradeableReadLock(); // 升級
try
{
tool.EnterWriteLock(); // 獲取寫入鎖
// 寫入訂單
MaxId += 1; // Interlocked.Increment(ref MaxId);
DoWorkModel model = new DoWorkModel
{
Id = MaxId,
UserName = userName,
DateTime = time
};
orders.Add(model);
return model;
}
catch { }
finally
{
tool.ExitWriteLock(); // 釋放寫入鎖
}
}
catch { }
finally
{
tool.ExitUpgradeableReadLock(); // 降級
}
return default;
}
Main 方法中:
開 5 個線程,不斷地讀,開 2 個線程不斷地創建訂單。線程創建訂單時是沒有設置 Thread.Sleep()
的,因此運行速度十分快。
Main 方法里面的代碼沒有什么意義。
static void Main(string[] args)
{
// 5個線程讀
for (int i = 0; i < 5; i++)
{
new Thread(() =>
{
while (true)
{
var result = DoSelect(1, MaxId);
if (result is null)
{
Console.WriteLine("獲取失敗");
continue;
}
foreach (var item in result)
{
Console.Write($"{item.Id}|");
}
Console.WriteLine("\n");
Thread.Sleep(1000);
}
}).Start();
}
for (int i = 0; i < 2; i++)
{
new Thread(() =>
{
while(true)
{
var result = DoCreate((new Random().Next(0, 100)).ToString(), DateTime.Now); // 模擬生成訂單
if (result is null)
Console.WriteLine("創建失敗");
else Console.WriteLine("創建成功");
}
}).Start();
}
}
在 ASP.NET Core 中,則可以利用讀寫鎖,解決多用戶同時發送 HTTP 請求帶來的數據庫讀寫問題。
這里就不做示例了。
如果另一個線程發生問題,導致遲遲不能交出寫入鎖,那么可能會導致其它線程無限等待。
那么可以使用 TryEnterWriteLock()
並且設置等待時間,避免阻塞時間過長。
bool isGet = tool.TryEnterWriteLock(500);
並發字典寫示例
因為理論的東西,筆者這里不會說太多,主要就是先掌握一些 API(方法、屬性) 的使用,然后簡單寫出示例,后面再慢慢深入了解底層原理。
這里來寫一個多線程共享使用字典(Dictionary)的使用示例。
增加兩個靜態變量:
private static ReaderWriterLockSlim toolLock = new ReaderWriterLockSlim();
private static Dictionary<int, int> dict = new Dictionary<int, int>();
實現一個寫操作:
public static void Write(int key, int value)
{
try
{
// 升級狀態
toolLock.EnterUpgradeableReadLock();
// 讀,檢查是否存在
if (dict.ContainsKey(key))
return;
try
{
// 進入寫狀態
toolLock.EnterWriteLock();
dict.Add(key,value);
}
finally
{
toolLock.ExitWriteLock();
}
}
finally
{
toolLock.ExitUpgradeableReadLock();
}
}
上面沒有 catch { }
是為了更好觀察代碼,因為使用了讀寫鎖,理論上不應該出現問題的。
模擬五個線程同時寫入字典,由於不是原子操作,所以 sum 的值有些時候會出現重復值。
原子操作請參考:https://www.cnblogs.com/whuanle/p/12724371.html#1,出現問題
private static int sum = 0;
public static void AddOne()
{
for (int i = 0; i < 100_0000; i++)
{
sum += 1;
Write(sum,sum);
}
}
static void Main(string[] args)
{
for (int i = 0; i < 5; i++)
new Thread(() => { AddOne(); }).Start();
Console.ReadKey();
}
ReaderWriterLock
大多數情況下都是推薦 ReaderWriterLockSlim 的,而且兩者的使用方法十分接近。
例如 AcquireReaderLock 是獲取讀鎖,AcquireWriterLock 獲取寫鎖。使用對應的方法即可替換 ReaderWriterLockSlim 中的示例。
這里就不對 ReaderWriterLock 進行贅述了。
ReaderWriterLock 的常用方法如下:
方法 | 說明 |
---|---|
AcquireReaderLock(Int32) | 使用一個 Int32 超時值獲取讀線程鎖。 |
AcquireReaderLock(TimeSpan) | 使用一個 TimeSpan 超時值獲取讀線程鎖。 |
AcquireWriterLock(Int32) | 使用一個 Int32 超時值獲取寫線程鎖。 |
AcquireWriterLock(TimeSpan) | 使用一個 TimeSpan 超時值獲取寫線程鎖。 |
AnyWritersSince(Int32) | 指示獲取序列號之后是否已將寫線程鎖授予某個線程。 |
DowngradeFromWriterLock(LockCookie) | 將線程的鎖狀態還原為調用 UpgradeToWriterLock(Int32) 前的狀態。 |
ReleaseLock() | 釋放鎖,不管線程獲取鎖的次數如何。 |
ReleaseReaderLock() | 減少鎖計數。 |
ReleaseWriterLock() | 減少寫線程鎖上的鎖計數。 |
RestoreLock(LockCookie) | 將線程的鎖狀態還原為調用 ReleaseLock() 前的狀態。 |
UpgradeToWriterLock(Int32) | 使用一個 Int32 超時值將讀線程鎖升級為寫線程鎖。 |
UpgradeToWriterLock(TimeSpan) | 使用一個 TimeSpan 超時值將讀線程鎖升級為寫線程鎖。 |
官方示例可以看: