1. 為什麼普通的 lock 可能不夠?
想想看:我們有一個共用資源——比如一個客戶列表,放在多執行緒的應用裡。大部分時間我們只是讀這個列表——像是把資料顯示給使用者、產生報表、做過濾或排序。只有偶爾有人會新增或刪除客戶。
如果用習慣的 lock,任何想讀取資料的執行緒都得等其他執行緒讀完或(更糟)寫完。如果讀操作佔了大約 99% 的話呢?結果就是我們白白把所有讀者都擋住了,而他們本來可以同時運作。
這時候就有專門的鎖類型派上用場—— Reader-Writer Lock。它的關鍵想法是:多個執行緒可以同時讀,但一旦有人想改變資料,他會拿到排他權,並阻止其他讀者。
2. 理論:什麼是 ReaderWriterLockSlim
簡單介紹類別
- ReaderWriterLock — 比較舊、比較慢,可能導致 deadlock。只有在你需要支援 .NET Framework 2.0 時才考慮用它。
- ReaderWriterLockSlim — 快速且現代的替代(Slim 的意思是「精簡」)。這個類別針對頻繁的讀寫競爭做了優化,建議在執行緒多且讀取遠多於寫入的應用中使用。
工作原理
ReaderWriterLockSlim 實作了三種鎖定模式:
- Read Lock(讀鎖):允許多個執行緒同時讀取。
- Write Lock(寫鎖):只有一個執行緒可以修改資料,並且其他人不能讀取。
- Upgradeable Read Lock(可升級的讀鎖):一次只有一個執行緒可以持有這種鎖;它可以先讀,必要時再「升級」成 write lock。
鎖相容表
| 讀者執行緒 | 寫者執行緒 | 持有 Upgradeable Read Lock 的執行緒 | |
|---|---|---|---|
| 讀者 | ✅ | ⛔ | ✅/⛔ |
| 寫者 | ⛔ | ⛔ | ⛔ |
| Upgradeable Read | ✅* | ⛔ | ⛔ |
✅ — 允許
⛔ — 被阻塞
* — 只有在 upgradeable read lock 沒有被升級成 write 時
什麼情況該(或不該)用 ReaderWriterLockSlim
建議使用:
- 很多同時執行緒,主要是讀共同資料(像是參考表、快取、設定)。
- 寫操作很少(例如每秒/每分鐘/每小時才發生一次)。
- 想最大化讀取效能,不想互相阻塞讀者。
不建議:
- 讀寫頻率相當(這種情況用普通的 lock 更好)。
- 資料經常變動(收益有限)。
3. 在實務中使用 ReaderWriterLockSlim
我們把教學範例擴充一下!之前我們做過一個簡單的客戶目錄,演示了對集合的多執行緒存取。現在把情境複雜一點:假設有一個客戶列表,多個執行緒會讀它(例如一個服務在通知所有客戶有新活動),同時另一個執行緒偶爾會新增客戶。
先描述客戶集合的型別:
// 客戶類別 — 你以前課程裡熟悉的老朋友
public class Client
{
public int Id { get; set; }
public string Name { get; set; }
}
現在我們做一個用 ReaderWriterLockSlim 包裝的執行緒安全存取:
using System;
using System.Collections.Generic;
using System.Threading;
public class ClientDirectory
{
private readonly List<Client> _clients = new List<Client>();
// 用於讀/寫的 Slim-lock
private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
// 新增客戶(write lock)
public void AddClient(Client client)
{
_lock.EnterWriteLock(); // 只有一個執行緒可以新增客戶
try
{
_clients.Add(client);
Console.WriteLine($"[執行緒 {Thread.CurrentThread.ManagedThreadId}] 已新增客戶 {client.Name}");
}
finally
{
_lock.ExitWriteLock();
}
}
// 取得客戶列表的複本(read lock)
public List<Client> GetClients()
{
_lock.EnterReadLock(); // 許多執行緒可以同時讀取
try
{
// 回傳複本,避免有人不小心修改到原始集合
return new List<Client>(_clients);
}
finally
{
_lock.ExitReadLock();
}
}
}
4. 多執行緒讀取與週期性寫入
假設有 5 個執行緒會週期性讀取所有客戶(「產生報表」或只是查看有誰),另外還有一個執行緒偶爾新增客戶。
using System.Threading;
class Program
{
static void Main()
{
var directory = new ClientDirectory();
// 新增客戶的執行緒
var writerThread = new Thread(() =>
{
for (int i = 1; i <= 5; i++)
{
directory.AddClient(new Client { Id = i, Name = $"客戶 {i}" });
Thread.Sleep(700); // 模擬耗時操作
}
});
// 幾個讀者
for (int j = 0; j < 5; j++)
{
int readerId = j + 1;
new Thread(() =>
{
for (int k = 0; k < 10; k++)
{
var clients = directory.GetClients();
Console.WriteLine($"[讀者 {readerId}][執行緒 {Thread.CurrentThread.ManagedThreadId}] 客戶總數: {clients.Count}");
Thread.Sleep(200); // 讀的頻率比寫的高
}
}).Start();
}
writerThread.Start();
writerThread.Join();
// 讓讀者把工作做完:
Thread.Sleep(3000);
}
}
幾乎總是多個執行緒可以同時讀取客戶列表(互不等待),但當有執行緒在新增客戶時——其他執行緒必須等寫操作完成。這讓系統能在不不必要地阻塞讀者的情況下服務大量讀取請求。
5. 可升級的讀鎖:UpgradeableReadLock
有時候情況是:執行緒先讀取資料,只有在某個條件成立時才會去修改。看起來好像可以先拿 read lock、檢查條件、釋放 read lock、再拿 write lock。但在「之間」的空檔,其他執行緒可能已經改了資料!這時 可升級的讀鎖 很有用。
用法很簡單:
public bool AddClientIfNotExists(string name)
{
_lock.EnterUpgradeableReadLock(); // 一次只有一個執行緒可以持有這種鎖
try
{
bool exists = _clients.Exists(c => c.Name == name);
if (!exists)
{
_lock.EnterWriteLock(); // 升級到 write lock
try
{
_clients.Add(new Client { Name = name });
return true;
}
finally
{
_lock.ExitWriteLock();
}
}
return false;
}
finally
{
_lock.ExitUpgradeableReadLock();
}
}
先以讀取方式檢查,若需要修改就升級鎖,這樣在升級過程中不會把資料讓給其他執行緒。
6. 有用的細節
比較:ReaderWriterLockSlim vs lock
|
|
|
|---|---|---|
| 同時讀取 | 不行 | 可以 |
| 同時寫入 | 不行 | 不行 |
| 支援可升級讀取 | 不支援 | 支援 (EnterUpgradeableReadLock) |
| 速度 | 非常快 | 在大量讀取時更快 |
| 記憶體使用 | 最少 | 稍微多一點 |
| 簡單性 | 容易 | 較複雜,有一些細節要注意 |
在真實專案中的樣子
在大型應用中,像是有龐大的表或快取資料被數百個執行緒讀取但很少更新,使用 ReaderWriterLockSlim 可以避免讀取被卡住。例如:
- 微服務共用的設定快取。
- 金融計算用的參考資料庫。
- 內部的訊息路由快取。
強調一下——大多數情況,尤其是你剛開始學多執行緒時,普通的 lock 更簡單也更安全。ReaderWriterLockSlim 是針對那些讀取頻繁且並行性要求高的場景的解法。
視覺化:存取流程圖
flowchart TD
A[讀者執行緒 1] --Read--> D[ReaderWriterLockSlim]
B[讀者執行緒 2] --Read--> D
C[寫者執行緒] --Write--> D
D --Read permitted--> E[讀取共用集合]
D --Write blocks read/write--> F[寫入共用集合]
只要都是讀,大家都能進;一旦要寫,其他人就得等寫完。
7. ReaderWriterLockSlim:細節與常見錯誤
Lock Recursion(鎖遞迴): 預設情況下 ReaderWriterLockSlim 不允許同一執行緒重複取得相同類型的鎖。也就是說,如果執行緒已經持有 read lock 再第二次嘗試取得會丟出例外。write lock 也是一樣。但 UpgradeableReadLock 可以在內部升級到 write lock。
別搞錯順序: 要先呼叫 EnterUpgradeableReadLock() → 在裡面再呼叫 EnterWriteLock()。反過來可不行!
例外狀況: 如果你不釋放鎖(例如沒呼叫 ExitWriteLock()),其他執行緒會永遠等下去。所以一定要用 try { ... } finally { ... } 的結構。
別鎖太久: 不要在鎖內做長時間的 IO 操作。執行緒持有鎖的時間越長,其他人等待的延遲就越大。盡量快速離開鎖區塊。
評估複雜度: 不要為了「每個小操作」都用 ReaderWriterLockSlim,那種場景普通的 lock 更簡單且可能更快。
GO TO FULL VERSION