C#使用讀寫鎖三行程式碼簡單解決多執行緒並發寫入檔案時執行緒同步的問題

C#使用讀寫鎖三行程式碼簡單解決多執行緒並發寫入檔案時執行緒同步的問題

讀寫鎖是以 ReaderWriterLockSlim 物件作為鎖管理資源的,不同的 ReaderWriterLockSlim 物件中鎖定同一個檔案也會被視為不同的鎖進行管理

最後更新 2022/4/25 下午8:41
Walter_lee2008
預計閱讀 8 分鐘
分類
.NET
標籤
.NET C#

1. 知識儲備

在開發程式的過程中,難免少不了寫入錯誤記錄這個關鍵功能。實現這個功能,可以選擇使用第三方記錄外掛程式,也可以選擇使用資料庫,還可以自己寫個簡單的方法把錯誤資訊記錄到記錄檔。

選擇最後一種方法實現的時候,若對檔案操作與執行緒同步不熟悉,問題就有可能出現了,因為同一個檔案並不允許多個執行緒同時寫入,否則會提示「檔案正在由另一個處理序使用,因此該處理序無法存取此檔案」

這是檔案的並行寫入問題,就需要用到執行緒同步。而微軟也給執行緒同步提供了一些相關的類別可以達到這樣的目的,本文使用到的 System.Threading.ReaderWriterLockSlim 便是其中之一。

該類別用於管理資源存取的鎖定狀態,可實作多執行緒讀取或進行獨佔式寫入存取。利用這個類別,我們就可以避免在同一時間段內多執行緒同時寫入一個檔案而導致的並行寫入問題。

讀寫鎖是以 ReaderWriterLockSlim 物件作為鎖管理資源的,不同的 ReaderWriterLockSlim 物件中鎖定同一個檔案也會被視為不同的鎖進行管理,這種差異可能會再次導致檔案的並行寫入問題,所以 ReaderWriterLockSlim 應盡量定義為唯讀的靜態物件

ReaderWriterLockSlim 有幾個關鍵的方法,本文僅討論寫入鎖:

  • 呼叫 EnterWriteLock 方法 進入寫入狀態,在呼叫執行緒進入鎖定狀態之前一直處於封鎖狀態,因此可能永遠都不會傳回。

  • 呼叫 TryEnterWriteLock 方法 進入寫入狀態,可指定封鎖的間隔時間,如果呼叫執行緒在此間隔期間並未進入寫入模式,將傳回 false。

  • 呼叫 ExitWriteLock 方法 結束寫入狀態,應使用 finally 區塊執行 ExitWriteLock 方法,從而確保呼叫方結束寫入模式

2. 多執行緒同時寫入檔案

class Program
{
    static int LogCount = 100;
    static int WritedCount = 0;
    static int FailedCount = 0;

    static void Main(string[] args)
    {
        //反覆運算執行寫入記錄,由於多個執行緒同時寫入同一個檔案將會導致錯誤
        Parallel.For(0, LogCount, e =>
        {
            WriteLog();
        });

        Console.WriteLine(string.Format("\r\nLog Count:{0}.\t\tWrited Count:{1}.\tFailed Count:{2}.", LogCount.ToString(), WritedCount.ToString(), FailedCount.ToString()));
        Console.Read();
    }

    static void WriteLog()
    {
        try
        {
            var logFilePath = "log.txt";
            var now = DateTime.Now;
            var logContent = string.Format("Tid: {0}{1} {2}.{3}\r\n", Thread.CurrentThread.ManagedThreadId.ToString().PadRight(4), now.ToLongDateString(), now.ToLongTimeString(), now.Millisecond.ToString());
            File.AppendAllText(logFilePath, logContent);
            WritedCount++;
        }
        catch (Exception ex)
        {
            FailedCount++;
            Console.WriteLine(ex.Message);
        }
    }
}

3. 多執行緒使用讀寫鎖同步寫入檔案

class Program
{
    static int LogCount = 100;
    static int WritedCount = 0;
    static int FailedCount = 0;

    static void Main(string[] args)
    {
        //反覆運算執行寫入記錄
        Parallel.For(0, LogCount, e =>
        {
            WriteLog();
        });

        Console.WriteLine(string.Format("\r\nLog Count:{0}.\t\tWrited Count:{1}.\tFailed Count:{2}.", LogCount.ToString(), WritedCount.ToString(), FailedCount.ToString()));
        Console.Read();
    }

    //讀寫鎖,當資源處於寫入模式時,其他執行緒寫入需要等待本次寫入結束之後才能繼續寫入
    static ReaderWriterLockSlim LogWriteLock = new ReaderWriterLockSlim();
    static void WriteLog()
    {
        try
        {
            //設定讀寫鎖為寫入模式獨佔資源,其他寫入請求需要等待本次寫入結束之後才能繼續寫入
            //注意:長時間持有讀執行緒鎖或寫執行緒鎖會使其他執行緒發生飢餓 (starve)。 為了得到最好的效能,需要考慮重新建構應用程式以將寫入存取的持續時間減少到最小。
            //      從效能方面考慮,請求進入寫入模式應該緊跟在檔案操作之前,在此處進入寫入模式僅是為了降低程式碼複雜度
            //      因為進入與結束寫入模式應在同一個 try finally 陳述式區塊內,所以在請求進入寫入模式之前不能觸發例外,否則釋放次數大於請求次數將會觸發例外
            LogWriteLock.EnterWriteLock();

            var logFilePath = "log.txt";
            var now = DateTime.Now;
            var logContent = string.Format("Tid: {0}{1} {2}.{3}\r\n", Thread.CurrentThread.ManagedThreadId.ToString().PadRight(4), now.ToLongDateString(), now.ToLongTimeString(), now.Millisecond.ToString());

            File.AppendAllText(logFilePath, logContent);
            WritedCount++;
        }
        catch (Exception)
        {
            FailedCount++;
        }
        finally
        {
            //結束寫入模式,釋放資源佔用
            //注意:一次請求對應一次釋放
            //      若釋放次數大於請求次數將會觸發例外[寫入鎖定未經保持即被釋放]
            //      若請求處理完成後未釋放將會觸發例外[此模式不允許以遞迴方式取得寫入鎖定]
            LogWriteLock.ExitWriteLock();
        }
    }
}

使用讀寫鎖,全部記錄成功寫入了記錄檔。

4. 測試複雜多執行緒環境下使用讀寫鎖同步寫入檔案

class Program
{
    static int LogCount = 1000;
    static int SumLogCount = 0;
    static int WritedCount = 0;
    static int FailedCount = 0;

    static void Main(string[] args)
    {
        //往執行緒集區裡新增一個工作,反覆運算寫入N個記錄
        SumLogCount += LogCount;
        ThreadPool.QueueUserWorkItem((obj) =>
        {
            Parallel.For(0, LogCount, e =>
            {
                WriteLog();
            });
        });

        //在新的執行緒裡,新增N個寫入記錄的工作到執行緒集區
        SumLogCount += LogCount;
        var thread1 = new Thread(() =>
        {
            Parallel.For(0, LogCount, e =>
            {
                ThreadPool.QueueUserWorkItem((subObj) =>
                {
                    WriteLog();
                });
            });
        });
        thread1.IsBackground = false;
        thread1.Start();

        //新增N個寫入記錄的工作到執行緒集區
        SumLogCount += LogCount;
        Parallel.For(0, LogCount, e =>
        {
            ThreadPool.QueueUserWorkItem((obj) =>
            {
                WriteLog();
            });
        });

        //在新的執行緒裡,反覆運算寫入N個記錄
        SumLogCount += LogCount;
        var thread2 = new Thread(() =>
        {
            Parallel.For(0, LogCount, e =>
            {
                WriteLog();
            });
        });
        thread2.IsBackground = false;
        thread2.Start();

        //在目前執行緒裡,反覆運算寫入N個記錄
        SumLogCount += LogCount;
        Parallel.For(0, LogCount, e =>
        {
            WriteLog();
        });

        Console.WriteLine("Main Thread Processed.\r\n");
        while (true)
        {
            Console.WriteLine(string.Format("Sum Log Count:{0}.\t\tWrited Count:{1}.\tFailed Count:{2}.", SumLogCount.ToString(), WritedCount.ToString(), FailedCount.ToString()));
            Console.ReadLine();
        }
    }

    //讀寫鎖,當資源處於寫入模式時,其他執行緒寫入需要等待本次寫入結束之後才能繼續寫入
    static ReaderWriterLockSlim LogWriteLock = new ReaderWriterLockSlim();
    static void WriteLog()
    {
        try
        {
            //設定讀寫鎖為寫入模式獨佔資源,其他寫入請求需要等待本次寫入結束之後才能繼續寫入
            //注意:長時間持有讀執行緒鎖或寫執行緒鎖會使其他執行緒發生飢餓 (starve)。 為了得到最好的效能,需要考慮重新建構應用程式以將寫入存取的持續時間減少到最小。
            //      從效能方面考慮,請求進入寫入模式應該緊跟在檔案操作之前,在此處進入寫入模式僅是為了降低程式碼複雜度
            //      因為進入與結束寫入模式應在同一個 try finally 陳述式區塊內,所以在請求進入寫入模式之前不能觸發例外,否則釋放次數大於請求次數將會觸發例外
            LogWriteLock.EnterWriteLock();

            var logFilePath = "log.txt";
            var now = DateTime.Now;
            var logContent = string.Format("Tid: {0}{1} {2}.{3}\r\n", Thread.CurrentThread.ManagedThreadId.ToString().PadRight(4), now.ToLongDateString(), now.ToLongTimeString(), now.Millisecond.ToString());

            File.AppendAllText(logFilePath, logContent);
            WritedCount++;
        }
        catch (Exception)
        {
            FailedCount++;
        }
        finally
        {
            //結束寫入模式,釋放資源佔用
            //注意:一次請求對應一次釋放
            //      若釋放次數大於請求次數將會觸發例外[寫入鎖定未經保持即被釋放]
            //      若請求處理完成後未釋放將會觸發例外[此模式不下允許以遞迴方式取得寫入鎖定]
            LogWriteLock.ExitWriteLock();
        }
    }
}

複雜多執行緒環境下使用讀寫鎖,全部記錄成功寫入了記錄檔,由ThreadIdDateTime可以看出是由不同的執行緒同步寫入。

5. 後記

雖然讀寫 IO 有共用模式,也可以實現但不建議

static void WriteLog()
{
    try
    {
        var logFilePath = "log.txt";
        var now = DateTime.Now;
        var logContent = string.Format("Tid: {0}{1} {2}.{3}\r\n", Thread.CurrentThread.ManagedThreadId.ToString().PadRight(4), now.ToLongDateString(), now.ToLongTimeString(), now.Millisecond.ToString());
        //File.AppendAllText(logFilePath, logContent);

        var logContentBytes = Encoding.Default.GetBytes(logContent);
        using (FileStream logFile = new FileStream(logFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Write))
        {
            logFile.Seek(0, SeekOrigin.End);
            logFile.Write(logContentBytes, 0, logContentBytes.Length);
        }

        WritedCount++;
    }
    catch (Exception ex)
    {
        FailedCount++;
        Console.WriteLine(ex.Message);
    }
}
繼續探索

延伸閱讀

更多文章
同分類 / 同標籤 2026/2/7

AOT使用經驗總結

從專案建立伊始,就應養成良好的習慣,即只要添加了新功能或使用了較新的語法,就及時進行 AOT 發布測試。

繼續閱讀