C# 线程同步技术(三)之 Monitor 和Lock
今天我们总结一下 C#线程同步 中的 Monitor 类 和 Lock 关键字进行一下总结。
首先来看看他们有什么异同(相信对此熟悉的朋友们都很清楚):
1、他们都是在指定对象上获取排他锁,用于同步代码区 lock(obj){ |
所以lock能做的,Monitor肯定能做,Monitor能做的,lock不一定能做,我们今天就主要说的就是Monitor 类。
Monitor 类 通过Enter(Object) 在指定对象上获取排他锁,通过Exit 方法释放指定对象上的排他锁。
Enter方法:使用 Enter 获取作为参数传递的对象上的 Monitor。如果其他线程已对该对象执行了 Enter,但尚未执行对应的 Exit,则当前线程将阻止,直到对方线程释放该对象
Exit方法:调用线程必须拥有 obj 参数上的锁。如果调用线程拥有指定对象上的锁并为该对象进行了相同次数的 Exit 和 Enter 调用,则该锁将被释放。如果调用线程调用 Exit 与调用 Enter 的次数不同,则该锁不会被释放。
我们来做一个游戏杀怪的例子来演示一下吧:建立一个控制台程序,并增加一个怪物类(Monster),代码如下:
public class Monster { public Monster(int blood) { this.Blood = blood; Console.WriteLine(string.Format("我是怪物,我有 {0} 滴血!\r\n", blood)); } public int Blood { get; set; } }
然后呢,我们在增加一个Player 类,里面有个物理工具的方法,此方法没有采取任何线程同步的措施:
public class Player { //姓名 public string Name { get; set; } //武器 public string Weapon { get; set; } //攻击力 public int Power { get; set; } //物理攻击 public void PhysAttack(Object monster) { Monster m = monster as Monster; while (m.Blood > 0) { Console.WriteLine("当前玩家 【{0}】,使用{1}攻击怪物!", this.Name, this.Weapon); if (m.Blood >= this.Power) { m.Blood -= this.Power; } else { m.Blood = 0; } Console.WriteLine("怪物剩余血量:{0}\r\n", m.Blood); } }
在主函数中,我们实例化两个玩家角色,一个游侠,一个野蛮人,并开启两个线程来调用一下他们的物理攻击方法,攻击同一个怪物。
static void Main(string[] args) { Monster monster = new Monster(1000); Player YouXia = new Player() { Name = "游侠", Weapon = "宝剑", Power = 150 }; Player YeManRen = new Player() { Name = "野蛮人", Weapon = "链锤", Power = 250 }; Thread t1 = new Thread(new ParameterizedThreadStart(YouXia.PhysAttack)); t1.Start(monster); Thread t2 = new Thread(new ParameterizedThreadStart(YeManRen.PhysAttack)); t2.Start(monster); t1.Join(); t2.Join(); Console.ReadKey(); }
由于没有采取线程同步的措施,运行结果可想而知,当然不同的计算机运行结果是不一样的,我的如下图:
这种结果肯定不是我们想要的,我们来对Player 类中的物理攻击方法,修改一下,用Monitor 类来实现一下线程同步,当然也可以用Lock 关键字,修改的代码如下:
//物理攻击
public void PhysAttack(Object monster) { Monster m = monster as Monster; while (m.Blood > 0) //异步读 { Monitor.Enter(monster); if (m.Blood > 0) //同步读 { Console.WriteLine("当前玩家 【{0}】,使用{1}攻击怪物!", this.Name, this.Weapon); if (m.Blood >= this.Power) { m.Blood -= this.Power; } else { m.Blood = 0; } Console.WriteLine("怪物剩余血量:{0}\r\n", m.Blood); } Thread.Sleep(500); Monitor.Exit(monster); }
}
由于我们加上了Monitor.Enter(monster) 和 Monitor.Exit(monster); 期间的代码段是线程同步的。假如程序启动后,游侠所在的线程 先进入了Monitor.Enter(monster),这时候游侠线程拥有对monster实例的排他锁,其他的线程必须等待,野蛮人线程运行到Monitor.Enter(monster)的时候,就会发生阻塞,直到游侠线程执行 Monitor.Exit(monster);之后,释放了排他锁,野蛮人线程才能进行杀怪的操作,此时野蛮人线程拥有排他锁的控制权,游侠线程就必须等待。运行结果如下:
将上面的代码中的 Monitor.Enter(monster); 和 Monitor.Exit(monster); 替换成lock(monster);是会得到同样的效果的,那么我们再来看lock 没有的功能。Monitor类中的Wait(object) 和Pulse 方法。
Wait(object)方法:释放对象上的锁并阻止当前线程,直到它重新获取该锁,该线程进入等待队列。
Pulse方法:只有锁的当前所有者可以使用 Pulse 向等待对象发出信号,当前拥有指定对象上的锁的线程调用此方法以便向队列中的下一个线程发出锁的信号。接收到脉冲后,等待线程就被移动到就绪队列中。在调用 Pulse 的线程释放锁后,就绪队列中的下一个线程(不一定是接收到脉冲的线程)将获得该锁。
另外:Wait 和 Pulse 方法必须写在 Monitor.Enter 和Moniter.Exit 之间。
不明白MSDN的解释,没有关系,不明白什么是等待队列和就绪队列也没有关系,来继续我们的实例。为了好演示,为Player类又增加两方法一个是 魔法攻击,一个是闪电攻击,两者的代码是一样的,只不过分别加上了Monitor.Wait 和 Monitor.Exit 方法。
//魔法攻击 public void MigcAttack(Object monster) { Monster m = monster as Monster; Monitor.Enter(monster); Console.WriteLine("当前玩家 {0} 进入战斗\r\n",this.Name); while (m.Blood > 0) { Monitor.Wait(monster); Console.WriteLine("当前玩家 {0} 获得攻击权限", this.Name); Console.WriteLine("当前玩家 {0},使用 魔法 攻击怪物!", this.Name, this.Weapon); m.Blood = (m.Blood >= this.Power) ? m.Blood - this.Power : 0; Console.WriteLine("怪物剩余血量:{0}\r\n", m.Blood); Thread.Sleep(500); Monitor.Pulse(monster); } Monitor.Exit(monster); } //闪电攻击 public void LightAttack(Object monster) { Monster m = monster as Monster; Monitor.Enter(monster); Console.WriteLine("当前玩家 {0} 进入战斗\r\n", this.Name); while (m.Blood > 0) { Monitor.Pulse(monster); Console.WriteLine("当前玩家 {0} 获得攻击权限", this.Name); Console.WriteLine("当前玩家 {0},使用 闪电 攻击怪物!", this.Name); m.Blood = (m.Blood >= this.Power) ? m.Blood - this.Power : 0; Console.WriteLine("怪物剩余血量:{0}\r\n", m.Blood); Thread.Sleep(500); Monitor.Wait(monster); } Monitor.Exit(monster); }
并在Main方法中开两个线程进行调用:
static void Main(string[] args) { Monster monster = new Monster(1500); Player Cike = new Player() { Name = "刺客", Power = 250 }; Player Mofashi = new Player() { Name = "魔法师", Power = 350 };
Thread t1 = new Thread(new ParameterizedThreadStart(Cike.LightAttack)); t1.Start(monster); Thread t2 = new Thread(new ParameterizedThreadStart(Mofashi.MigcAttack)); t2.Start(monster);
t1.Join(); t2.Join(); Console.ReadKey(); }
先不看上面代码的对与错,我们先认为理论上是正确的。
我们分析一下:有这样一种可能,程序运行后,魔法师线程先进入了 Monitor.Enter(monster), 获得了对monser实例的排他锁控制权,然后魔法师线程继续运行,当运行到了Monitor.Wait(monster)的时候,发生了阻塞,魔法师线程释放了排他锁的控制权,进入了等待队列。
这时候,刺客线程才刚刚获得CPU分给的时间片刚刚运行,由于魔法师线程已经释放了排他锁,因此刺客线程顺利的进入了Monitor.Enter(monster),并获得了对monser实例的排他锁控制权,然后 运行到了Monitor.Pulse(monster); 发送了个Pulse信号,告诉魔法师线程,你就绪吧,等我进入Wait之后,你可以杀怪了。
因此刺客线程运行到Wait 之后,魔法师线程可以继续运行。这样两线程的 Wait 和 Pulse 就形成了一个循环,就会出现,刺客用闪电攻击一次,魔法师用魔法攻击一次的情况,直到怪物被干掉。
结果如下图:
若没有出现上面的结果 (多运行几次,总会有机会出现的)。
不过总是有些幸运的人一运行就会出现如下的结果:
程序运行到这里,不动了....,哈哈哈恭喜你,这就是发生了死锁。
我们也来分析一下:
怪物出场后,刺客线程一马当先的进入了杀怪过程,先进入了Monitor.Enter(monster),又发送了Monitor.Pulse(monster),不过此时的没有任何等待线程(白玩),刺客进行闪电攻击后,遇到了Wait 方法,交出了 排他锁控制权,然后去一边儿等待去了。
此时的 魔法师线程才刚刚开始运行,进入了Monitor.Enter(monster),获得排他锁控制权,还没有出招,就碰到了Wait 方法,结果是 也交出了排他锁,去一边儿等待去了,我们的程序就两个角色线程,都去等待去了,不发生死锁才怪!
如何解决上面的问题呢?我们可以采用Wait(object)的一个重载方法 Wait(Object, Int32) 方法。
bool Wait(Object, Int32):释放对象上的锁并阻止当前线程,直到它重新获取该锁。 如果指定的超时间隔已过,则线程进入就绪队列。
结合下面的代码给大家通俗的解释就是:Int32 是一个毫秒数;该方法释放排他锁,阻塞当前线程,如果在规定的毫秒数内获得是锁的控制权,就返回True, 该线程继续运行; 否则就返回False,该线程也继续运行。
来修改一下上面的代码,将魔法攻击和闪电攻击代码修改如下:
//魔法攻击 public void MigcAttack(Object monster) { Monster m = monster as Monster; Monitor.Enter(monster); Console.WriteLine("当前玩家【{0}】进入战斗\r\n",this.Name); while (m.Blood > 0) { Monitor.Wait(monster); Console.WriteLine("当前玩家【{0}】获得攻击权限", this.Name); if (m.Blood > 0) { Console.WriteLine("当前玩家【{0}】,使用 魔法 攻击怪物!", this.Name, this.Weapon); m.Blood = (m.Blood >= this.Power) ? m.Blood - this.Power : 0; Console.WriteLine("怪物剩余血量:{0}\r\n", m.Blood); } else { Console.WriteLine("怪物倒下了! 【{0}】停止了魔法攻击 \r\n", this.Name); } Thread.Sleep(500); Monitor.Pulse(monster); } Monitor.Exit(monster);
} //闪电攻击 public void LightAttack(Object monster) { Monster m = monster as Monster; Monitor.Enter(monster); Console.WriteLine("当前玩家【{0}】进入战斗\r\n", this.Name); while (m.Blood > 0) { Monitor.Pulse(monster); if (Monitor.Wait(monster, 1000)) //主要是这里 { Console.WriteLine("当前玩家【{0}】获得攻击权限", this.Name); if (m.Blood > 0) { Console.WriteLine("当前玩家【{0}】,使用 闪电 攻击怪物!", this.Name); m.Blood = (m.Blood >= this.Power) ? m.Blood - this.Power : 0; Console.WriteLine("怪物剩余血量:{0}\r\n", m.Blood); } else { Console.WriteLine("怪物倒下了! 【{0}】停止了闪电攻击 \r\n", this.Name); } Thread.Sleep(500); } //Monitor.Wait(monster,1000); } Monitor.Exit(monster); }
由于我们 使用 Monitor.Wait(monster, 1000)修改了 闪电攻击的方法。当刺客线程进入Wait 的时候,我们只让该线程等待1s ,如果 1s 内 获取到了魔法师线程的pusle脉冲信号并获取到了锁控制权,刺客线程就可以进行闪电攻击,如果1s 后,还没有获取到控制权,刺客线程继续运行。总有那么一个时刻魔法师线程进行了等待,刺客线程运行到Pulse 之后,就会通知魔法师线程就绪,再执行到Monitor.Wait(monster, 1000)的时候,魔法师线程进行魔法攻击。如果魔法师线程1s内攻击完成,并运行到Wait的时候。刺客线程可以进入if 进行闪电攻击,如果超时,刺客线程进行循环。
运行结果如下:
通过Monitor.Wait(monster, 1000),我们成功的避免了死锁的发生,我们再来看看 Monitor.TryEnter(Object) 的使用,该方法也能够避免死锁的发生,我们下面的例子用到的是该方法的重载,Monitor.TryEnter(Object,Int32)。
Bool Monitor.TryEnter(Object,Int32):在指定的毫秒数内尝试获取指定对象上的排他锁。如果在指定的毫秒数内获得排他锁,则返回True,否则返回False。
同样结合下面的代码,Int32 是一个毫秒数;该方法尝试去获得排他锁,阻塞当前线程,如果在规定的毫秒数内获得是锁的控制权,就返回True, 该线程继续运行; 否则就返回False,该线程也继续运行。
为了说明情况,我们新建一个简单的计算类,包含加法和减法两个操作:
public class Calculate { public void Add() { while (true) { if (Monitor.TryEnter(this,1000)) //注意这里,如果1s内获得锁,则进入if,超时后进入else { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine(string.Format("线程{0}获得锁:进入了加法运算",Thread.CurrentThread.Name)); Console.WriteLine("开始加法运算 1s 钟"); Thread.Sleep(1000); Console.WriteLine(string.Format("线程{0}释放锁:离开了加法运算\r\n",Thread.CurrentThread.Name)); Monitor.Exit(this); } else { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine("\r\n 由于减法运算未完成,未进入加法运算"); } } } public void Sub() { while (true) { Monitor.Enter(this); Console.ForegroundColor = ConsoleColor.Blue; Console.WriteLine(string.Format("线程{0}获得锁:进入了减法运算", Thread.CurrentThread.Name)); Console.ForegroundColor = ConsoleColor.Blue; Console.WriteLine("开始减法运算 2s 钟"); Thread.Sleep(2000); //让减法运算长一点,可以演示效果 Console.ForegroundColor = ConsoleColor.Blue; Console.WriteLine(string.Format("线程{0}释放锁:离开了减法运算\r\n", Thread.CurrentThread.Name)); Monitor.Exit(this); Thread.Sleep(2000); } } }
在Main方法中进行调用:
static void Main(string[] args) {
Calculate c = new Calculate(); Thread t1 = new Thread(new ThreadStart(c.Sub)); t1.Name = "减法线程"; Thread t2 = new Thread(new ThreadStart(c.Add)); t2.Name = "加法线程"; t1.Start(); t2.Start(); t1.Join(); t2.Join(); Console.ReadKey();
}
由于我们的代码中两个方法均用到的是 While(true), 所以两个线程会不停的运行下去,但不会发生死锁。结果如下图:
今天主要总结了 Monitor 类的一些使用方法,希望大家能看明白。另外 Monitor 是很容易产生死锁的类,我们平时可以通过 Wait(object,int32) 方法 和 TryEnter() 方法来解决。