[料理佳餚] 用 SemaphoreSlim 來做 async/await 的鎖定

在 C# 應用程式內部要做鎖定,第一時間我們一定是先想到 lock 陳述式,但是 lock 陳述式無法在 async/await 的場景下使用,程式編譯不會通過,我們會得到一個錯誤訊息 - 無法在 lock 陳述式的主體中等候

即使我們使用 Monitor.Enter()Monitor.Exit() 騙過編譯器,最終也會在 Runtime 的時候得到一個 Exception:

System.Threading.SynchronizationLockException: 'Object synchronization method was called from an unsynchronized block of code.'

原因都是因為 await 之後的程式碼,不保證會在同一個 Thread 執行,這導致鎖被破壞了,因此在這種非同步的環境底下要使用鎖定,除了尋求外部的資源進行鎖定之外,在 C# 中還有一個 SemaphoreSlim 類別可以協助我們來完成跨執行緒的鎖定。

SemaphoreSlim 的運作概念

SemaphoreSlim 從 .NET Framework 4.0 開始出現,是 Semaphore 的輕量化版本,使用起來更為簡便,它的運作概念是這樣的,可以想像有一個工具箱,一開始被放置了 n 把工具,整個工具箱最多可以放置 m 把工具,每個工人需要在工作之前從工具箱取走至少 1 把工具才能進行作業,直到工具箱沒有工具時,之後的工人就必須等待,等到有工人完成作業將工具歸還到工具箱,才能繼續從工具箱取走工具進行作業,而總工具數量也不是一成不變的,可以視整體的工作情況進行增減,但要注意工具箱的可容納上限,整個概念對照 SemaphoreSlim 實際的屬性跟方法如下:

  • 一個工具箱:一個 SemaphoreSlim 實例
  • 一開始被放置的 n 把工具:SemaphoreSlim 建構式的 initialCount
  • 最多可以放置 m 把工具:SemaphoreSlim 建構式的 maxCount,預設值為 Int32.MaxValue。
  • 工人:執行程式的 Thread
  • 取走工具:SemaphoreSlim 的 Wait() 方法
  • 歸還工具:SemaphoreSlim 的 Release() 方法
  • 增減總工具數量:Wait() 少、Release() 多;Wait() 多、Release() 少。

與 async/await 結合

SemaphoreSlim 能用在 async/await 上最主要的是,它並沒有強制必須由同一個 Thread 來 Wait() 和 Release(),這讓我們在非同步的情境下也能夠使用鎖定,使用方法如下範例程式碼:

internal class Program
{
    private static readonly SemaphoreSlim Locker = new SemaphoreSlim(1, 1);

    private static void Main(string[] args)
    {
        DoWork();
        DoWork();

        Console.Read();
    }

    private static async Task DoWork()
    {
        await Locker.WaitAsync();

        await Task.Run(
            () =>
                {
                    Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss}_Do work.");

                    Thread.Sleep(3000);
                });

        Locker.Release();

        Console.WriteLine("Done.");
    }
}

可以看到 DoWork() 方法是依序執行的,說明了 SemaphoreSlim 的鎖定發揮了效果。

參考資料

C# 指南 ASP.NET 教學 ASP.NET MVC 指引
Azure SQL Database 教學 SQL Server 教學 Xamarin.Forms 教學