【C#】Lock 對單一實體進行多執行續時,確保結果正確的方法

  • 2165
  • 0
  • C#
  • 2022-04-06

原本在研究設計模式的Singleton,其中提到為了確保唯一性,會在程式內使用關鍵字lock,避免多執行續存取造成非預期的結果。
備註:建議使用英文閱讀MSDN,比較能理解真正的意思。機翻或人工翻譯還是會有不準確的狀況。

Lock

創造一個互斥的程式碼區塊,確保只有同時只有一個執行續在執行lock 內的邏輯。
當其他執行續執行到這個區段時會持續等候,直到lock 被釋放,並在進入lock 區段的同時防止其他Thread 存取。
而lock 所需要監聽的對象,需要是參考型別(記憶體區共用),且極限限制它存取範圍,例如 private static readonly 的物件。

以下為程式由微軟範例修改以更明確展現及解釋lock 的作用:建立一個Account 類別,並對它進行Multi-Thread 的存取,但又能同時確保Thread-Safe,顯示出正確的結果:

    public class Account
    {
        private readonly object balanceLock = new object();
        private decimal _balance;    // 餘額

        public Account(decimal initialValue) => _balance = initialValue;

        // 提款
        public decimal Debit(int threadId, decimal amount)
        {
            if (amount < 0)
                throw new ArgumentOutOfRangeException(nameof(amount), "提款金額必須大於0");

            decimal appliedAmount = 0;
            lock (balanceLock)
            {
                if (_balance >= amount)
                {
                    Console.WriteLine($"第 {threadId} 個執行續正在進行提款 {amount}");
                    _balance -= amount;
                    appliedAmount = amount;
                    Console.WriteLine($"第 {threadId} 個執行續提款 結束");
                }
            }

            return appliedAmount;
        }

        // 存款
        public void Credit(int threadId, decimal amount)
        {
            if (amount < 0)
                throw new ArgumentOutOfRangeException(nameof(amount), "存款金額必須大於0");

            lock (balanceLock)
            {
                Console.WriteLine($"第 {threadId} 個執行續正在進行存款 {amount}");
                _balance += amount;
                Console.WriteLine($"第 {threadId} 個執行續存款 結束");
            }
        }

        // 查詢餘額
        public decimal GetBalance()
        {
            lock (balanceLock)
            {
                return _balance;
            }
        }
    }

再來撰寫測試程式:

        static async Task Main()
        {
            // 一開始初始化金額為1000
            var account = new Account(1000);

            // 同時以3 個執行續進行存提款動作,3 * 2 = 總共六次
            var tasks = new Task[3];
            for (int i = 0; i < tasks.Length; i++)
            {
                var threadId = i + 1;
                tasks[i] = Task.Run(() => Update(threadId, account));
            }
            await Task.WhenAll(tasks);

            // 每一次Update,最後金額會+100
            // 故最後總金額:1000 + ( 100 * 3 ) = 1300
            Console.WriteLine($"總金額:{account.GetBalance()}");
            Console.ReadLine();
        }

        static void Update(int threadId, Account account)
        {
            // 連續進行存提款動作
            decimal[] amounts = { 150, -50 };
            foreach (var amount in amounts)
            {
                if (amount >= 0)
                    account.Credit(threadId, amount);
                else
                    account.Debit(threadId, Math.Abs(amount));
            }

            // 做完總共 +100
        }

以下解說執行結果:

Update 裡面是連續進行"存款"和"提款"兩個動作,Row 1, 2 存款執行完後應該要立刻進行Row 7, 8 的提款動作。
但是因為有使用到lock,準備要進行提款時發現已經被lock 住,所以一定要等到前面的thread 結束釋放lock 之後,下一個thread 才會進行動作。

那如果把lock 拿掉呢?

是不是發現金額不對了
關鍵在於當Row1, 2 同時進行卻沒有lock 金額,造成以下狀況:

兩條時間線同時進行

1:Thread 2 讀出目前金額 1000
2:Thread 1 讀出目前金額 1000(x)
Row 2 的thread 1 沒有等到thread 2 完成存款動作,就直接拿現有的金額1000 來處理

3:Thread 2 存款150 -> 把金額變 1000    + 150 = 1150
4:Thread 1 存款150 -> 把金額變 1000(x) + 150 = 1150(x)
又因為沒有lock,Row 4 thread 1不管 thread 2 的結果,直接把金額變成1150

你的存款就硬生生地少了150 喔,這個範例體現了Singleton 單一實體時,lock 的影響是多巨大了。
故在這種特定屬性或物件,一次只能有一個人存的情況時,可以採用lock

 參考資料:
https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/statements/lock
https://createps.pixnet.net/blog/post/32352527-c%23-lock-record