• .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

    参见更多:随笔导读:同步与异步


    (未完待续...)

  • 相关阅读:
    es6 简介
    npm 快速开发技巧
    css清除浮动方法
    mui 总结
    7种 JS 创建对象的经典方式
    JavaScript 的 this 原理
    使用定时器
    dom 操作及方法
    JavaScript的6种继承方式及其优缺点
    贪吃蛇游戏代码
  • 原文地址:https://www.cnblogs.com/08shiyan/p/6272470.html
Copyright © 2020-2023  润新知