.NET 同步與異步之鎖(Lock、Monitor)(七)


本隨筆續接:.NET同步與異步之相關背景知識(六)

 

在上一篇隨筆中已經提到、解決競爭條件的典型方式就是加鎖 ,那本篇隨筆就重點來說一說.NET提供的最常用的鎖 lock關鍵字 和 Monitor。

一、lock關鍵字Demo

        public object thisLock = new object();
        private long index;

        public void AddIndex()
        {
            lock (this.thisLock)
            {
                this.index++;

                if (this.index > long.MaxValue / 2)
                {
                    this.index = 0;
                }
         // 和 index 無關的大量操作 } }
public long GetIndex() { return this.index; }

 

這一組demo,代碼簡潔,邏輯簡單,一個 AddIndex 方法 保證字段 index 在 0到100之間,另外一個GetIndex方法用來獲取字段index的值。

但是,這一組Demo卻有不少問題,甚至可以說是錯誤,下面我將一一進行說明:

1、忘記同步——即讀寫操作都需要加鎖

  GetIndex方法, 由於該方法沒有加鎖,所以通過該方法在任何時刻都可以訪問字段index的值,也就是說會恰好在某個時間點獲取到 101 這個值,這一點是和初衷相違背的。

 

2、讀寫撕裂

  如果說讀寫撕裂這個問題,這個demo可能不是很直觀,但是Long類型確實存在讀寫撕裂。比如下面的例子:

        /// <summary>
        /// 測試原子性
        /// </summary>
        public void TestAtomicity()
        {
            long test = 0;

            long breakFlag = 0;
            int index = 0;
            Task.Run(() =>
            {
                base.PrintInfo("開始循環   寫數據");
                while (true)
                {
                    test = (index % 2 == 0) ? 0x0 : 0x1234567890abcdef;

                    index++;

                    if (Interlocked.Read(ref breakFlag) > 0)
                    {
                        break;
                    }
                }

                base.PrintInfo("退出循環   寫數據");
            });

            Task.Run(() =>
            {
                base.PrintInfo("開始循環   讀數據");
                while (true)
                {
                    long temp = test;

                    if (temp != 0 && temp != 0x1234567890abcdef)
                    {
                        Interlocked.Increment(ref breakFlag);
                        base.PrintInfo($"讀寫撕裂:   { Convert.ToString(temp, 16)}");
                        break;
                    }
                }

                base.PrintInfo("退出循環   讀數據");
            });
        }
測試原子性操作

64位的數據結構 在32位的系統上(當然和CPU也有關系)是需要兩個命令來實現讀寫操作的,也就是說、如果恰好在兩個寫命令中間發生了讀取操作,就有可能讀取到不完成的數據。故而要警惕讀寫撕裂。

 

3、粒度錯誤

  AddIndex 方法中,和 index 無關的大量操作 ,放在鎖中是沒有必要的,雖然沒必要但是也不是錯的,只能說這個鎖的粒度過大,造成了沒必要的並發上的性能影響。

下面舉例一個錯誤的鎖粒度:

        public class BankAccount
        {
            private long id;
            private decimal m_balance = 0.0M;

            private object m_balanceLock = new object();

            public void Deposit(decimal delta)
            {
                lock (m_balanceLock)
                {
                    m_balance += delta;
                }
            }

            public void Withdraw(decimal delta)
            {
                lock (m_balanceLock)
                {
                    if (m_balance < delta)
                        throw new Exception("Insufficient funds");
                    m_balance -= delta;
                }
            }

            public static void ErrorTransfer(BankAccount a, BankAccount b, decimal delta)
            {
                a.Withdraw(delta);
                b.Deposit(delta);
            }


            public static void Transfer(BankAccount a, BankAccount b, decimal delta)
            {
                lock (a.m_balanceLock)
                {
                    lock (b.m_balanceLock)
                    {
                        a.Withdraw(delta);
                        b.Deposit(delta);
                    }
                }
            }

            public static void RightTransfer(BankAccount a, BankAccount b, decimal delta)
            {
                if (a.id < b.id)
                {
                    Monitor.Enter(a.m_balanceLock); // A first
                    Monitor.Enter(b.m_balanceLock); // ...and then B
                }
                else
                {
                    Monitor.Enter(b.m_balanceLock); // B first
                    Monitor.Enter(a.m_balanceLock); // ...and then A 
                }

                try
                {
                    a.Withdraw(delta);
                    b.Deposit(delta);
                }
                finally
                {
                    Monitor.Exit(a.m_balanceLock);
                    Monitor.Exit(b.m_balanceLock);
                }
            }

        }
錯誤的鎖粒度

在 ErrorTransfer 方法中,在轉賬的兩個方法中間的時間點上,轉賬金額屬於無主狀態,這時鎖的粒度就過小了 。

在 Transfer 方法中,雖然粒度正確了,但是此時容易死鎖。而比較恰當的方式可以是:RightTransfer 。

 

4、不合理的lock方式

鎖定非私有類型的對象是一種危險的行為,因為非私有類型被暴露給外界、外界也可以對被暴露的對象進行加鎖,這種情況下很容造成死鎖 或者 錯誤的鎖粒度。

較為合理的方式是 將 thislock 改為 private .

由上述進行類推:

1、lock(this):如果當前類型為外界可訪問的也會有類似問題。

2、lock(typeof(T)): 因為Type對象,是整個進程域中是唯一的。所以,如果T為外界可訪問的類型也會有類似問題。

3、lock("字符串"):因為String類型的特殊性(內存駐留機制),多個字符串其實有可能是同一把鎖,所以、一不小心就容易掉入陷阱、造成死鎖 或者錯誤的鎖粒度。

 

二、通過 IL 代碼看本質

 下面是 AddIndex 方法的全部il代碼 [使用 .NET 4.5類庫,VS2015 編譯]:

.method public hidebysig instance void  AddIndex() cil managed
{
  // 代碼大小       81 (0x51)
  .maxstack  3
  .locals init ([0] object V_0,
           [1] bool V_1,
           [2] bool V_2)
  IL_0000:  nop
  IL_0001:  ldarg.0
  IL_0002:  ldfld      object ParallelDemo.Demo.LockMonitorClass::thisLock
  IL_0007:  stloc.0
  IL_0008:  ldc.i4.0
  IL_0009:  stloc.1
  .try
  {
    IL_000a:  ldloc.0
    IL_000b:  ldloca.s   V_1
    IL_000d:  call       void [mscorlib]System.Threading.Monitor::Enter(object,
                                                                        bool&)
    IL_0012:  nop
    IL_0013:  nop
    IL_0014:  ldarg.0
    IL_0015:  ldarg.0
    IL_0016:  ldfld      int64 ParallelDemo.Demo.LockMonitorClass::index
    IL_001b:  ldc.i4.1
    IL_001c:  conv.i8
    IL_001d:  add
    IL_001e:  stfld      int64 ParallelDemo.Demo.LockMonitorClass::index
    IL_0023:  ldarg.0
    IL_0024:  ldfld      int64 ParallelDemo.Demo.LockMonitorClass::index
    IL_0029:  ldc.i8     0x3fffffffffffffff
    IL_0032:  cgt
    IL_0034:  stloc.2
    IL_0035:  ldloc.2
    IL_0036:  brfalse.s  IL_0042
    IL_0038:  nop
    IL_0039:  ldarg.0
    IL_003a:  ldc.i4.0
    IL_003b:  conv.i8
    IL_003c:  stfld      int64 ParallelDemo.Demo.LockMonitorClass::index
    IL_0041:  nop
    IL_0042:  nop
    IL_0043:  leave.s    IL_0050
  }  // end .try
  finally
  {
    IL_0045:  ldloc.1
    IL_0046:  brfalse.s  IL_004f
    IL_0048:  ldloc.0
    IL_0049:  call       void [mscorlib]System.Threading.Monitor::Exit(object)
    IL_004e:  nop
    IL_004f:  endfinally
  }  // end handler
  IL_0050:  ret
} // end of method LockMonitorClass::AddIndex
IL

 當然你沒必要完全看懂,你只需要注意到三個細節就可以了:

1、調用 [mscorlib]System.Threading.Monitor::Enter(object, bool&) 方法,其中第二個入參為 索引為1的local變量 [查類庫后發現該參數是 ref 傳遞引用]。

2、如果索引為1的local變量 不為 false,則 調用 [mscorlib]System.Threading.Monitor::Exit(object) 方法

3、try... finally 語句塊

換句話,也就是說 lock關鍵字其實本質上就是 Monitor 類的簡化實現方式,為了安全、進行了try...finally處理。

 

三、Monitor 的 wait 和 Pulse 

因為進入鎖(Enter)和離開鎖(Exit)都是有一定的性能損耗的,所以,當有頻繁的沒有必要的鎖操作的時候,性能影響更大。

比如:在生產者消費者模式中,如果沒有需要消費的數據時,對鎖的頻繁操作是沒有必要的(輪詢模式,不是推送)。

在這種情況下, wait方法就派上用場了。如下是MSDN中的一句備注:

當前擁有對指定對象的鎖的線程調用此方法以釋放該對象,以便另一個線程可以訪問它。 等待重新獲取鎖時阻止調用方。 當調用方需要等待另一個線程操作后將發生狀態更改時,調用此方法。

 

wait 和  pulse 方法一筆帶過,這對方法、筆者用的也不多。

 

 

隨筆暫告一段落、下一篇隨筆介紹: 鎖(ReaderWriterLockSlim)(預計1篇隨筆)

附,Demo : http://files.cnblogs.com/files/08shiyan/ParallelDemo.zip

參見更多:隨筆導讀:同步與異步


(未完待續...)

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM