CodeGym /課程 /C# SELF /使用 ReaderWriterLockSlim

使用 ReaderWriterLockSlim 做優化

C# SELF
等級 56, 課堂 4
開放

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

lock
ReaderWriterLockSlim
同時讀取 不行 可以
同時寫入 不行 不行
支援可升級讀取 不支援 支援 (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 更簡單且可能更快。

2
任務
C# SELF, 等級 56, 課堂 4
上鎖
使用 ReaderWriterLockSlim 來實現讀取操作
使用 ReaderWriterLockSlim 來實現讀取操作
1
問卷/小測驗
執行緒同步,等級 56,課堂 4
未開放
執行緒同步
共用資源的問題
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION