.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
当然你没必要完全看懂,你只需要注意到三个细节就可以了:
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 : https://files.cnblogs.com/files/08shiyan/ParallelDemo.zip
参见更多:随笔导读:同步与异步
(未完待续...)