C#多線程系列之讀寫鎖

本篇的內容主要是介紹 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.jb51.net/article/237310.htm

        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 超時值將讀線程鎖升級為寫線程鎖。

官方示例可以看:

https://docs.microsoft.com/zh-cn/dotnet/api/system.threading.readerwriterlock?view=netcore-3.1#examples

到此這篇關於C#多線程系列之讀寫鎖的文章就介紹到這瞭。希望對大傢的學習有所幫助,也希望大傢多多支持WalkonNet。

推薦閱讀: