c#多线程同步之临界区(lock、Monitor、ReadWriterLock)互斥量(M。。。环境:
window 10
netcore 3.1.1
vs2019 16.4.3
⽬的:
探索c#中的临界区、互斥量、信号量和事件的特点和使⽤⽅法
⼀、概念介绍
参照:
线程同步是⼀个⾮常⼤的话题,包括⽅⽅⾯⾯的内容。从⼤的⽅⾯讲,线程的同步可分⽤户模式的线程同步和内核对象的线程同步两⼤类。⽤户模式中线程的同步⽅法主要有原⼦访问和临界区等⽅法。其特点是同步速度特别快,适合于对线程运⾏速度有严格要求的场合。内核对象的线程同步则主要由事件、等待定时器、信号量以及信号灯等内核对象构成。由于这种同步机制使⽤了内核对象,使⽤时必须将线
程从⽤户模式切换到内核模式,⽽这种转换⼀般要耗费近千个CPU周期,因此同步速度较慢,但在适⽤性上却要远优于⽤户模式的线程同步⽅式。
1.1 临界区(Critical Section)
保证在某⼀时刻只有⼀个线程能访问数据的简便办法。在任意时刻只允许⼀个线程对共享资源进⾏访问。如果有多个线程试图同时访问临界区,那么 在有⼀个线程进⼊后其他所有试图访问此临界区的线程将被挂起,并⼀直持续到进⼊临界区的线程离开。临界区在被释放后,其他线程可以继续抢占,并以此达到⽤原⼦⽅式操 作共享资源的⽬的。c#中的lock、Monitor、ReadWriterLock属于临界区。
1.2 互斥量(Mutex)
互斥量跟临界区很相似,只有拥有互斥对象的线程才具有访问资源的权限,由于互斥对象只有⼀个,因此就决定了任何情况下此共享资源都不会同时被多个线程所访问。当前占据资源的线程在任务处理完后应将拥有的互斥对象交出,以便其他线程在获得后得以访问资源。互斥量⽐临界区复杂。因为使⽤互斥不仅仅能够在同⼀应⽤程序不同线程中实现资源的安全共享,⽽且可以在不同应⽤程序的线程之间实现对资源的安全共享。c#中的Mutex属于互斥量。
1.3 信号量(Semaphores)
信号量对象对线程的同步⽅式与前⾯⼏种⽅法不同,信号允许多个线程同时使⽤共享资源 ,这与操作系统中的PV操作相同。它指出了同时访问共享 资源的线程 最⼤数⽬。它允许多个线程在同⼀时刻访问同⼀资源,但是需要限制在同⼀时刻访问此资源的最⼤线程数⽬。c#中的Semaphore属于信号量。
1.4 通知事件(Event)
通知事件对象可以通过通知操作的⽅式来保持线程的同步。c#中的ManualResetEvent和AutoResetEvent属于通知事件。
⼆、lock关键字
如果说c#中的锁,那么⾸当其冲的就是lock关键字了。给lock关键字指定⼀个引⽤对象,然后上锁,保证同⼀时间只能有⼀个线程在锁⾥。这应该是最我们最常⽤的场景了。注意:我们说的是⼀把锁⾥同时只能有⼀个线程,⾄于这把锁⽤在了⼏个地⽅,那就不确定了。⽐如:object lockobj=new object(),这把锁可以锁⼀个代码块,也可以锁多个代码块,但⽆论锁多少个代码块,同⼀时间只能有⼀个线程打开这把锁进去,所以会有⼈建议,不要⽤lock(typeof(Program))或lock(this)这种锁,因为这把锁是所有⼈能看到的,别⼈可以⽤这把锁锁住⾃⼰的代码,这样就会出现⼀把锁锁住多个代码块的情况了,但现实使⽤中,⼀般没⼈会这么⼲,所以即使我们在阅读开源⼯程的源码时也能常常见到lock(typeof(Program))这种写法,不过还是建议⽤私有字段做锁,下⾯给出锁的⼏中应⽤场景:
当需要初始化对象时:
注意:下⾯的代码使⽤的是⼀把锁+内外判断,外层的判断是防⽌多余的锁竞争,内层的判断是为了防⽌重复初始化
class Program
{
private readonly object lockObj =new object();
private object obj =null;
public void TryInit()
{
if(obj ==null)
{
lock(lockObj)
{
if(obj ==null)
{
obj =new object();
}
}
}
}
}
⾃动编号⽣成
下⾯的代码只是⼀个实例,你也可以使⽤原⼦操作或直接id++
class DemoService
{
private static int id;
private static readonly object lockObj =new object();
public void Action()
{
//do something
int newid;
lock(lockObj)
{
newid = id +1;
id = newid;
}
//
}
}
最后: 需要说明的是,lock关键字只不过是Monitor的语法糖,也就是说下⾯的代码:
lock(typeof(Program))
{
int i =0;
//do something
}
被编译成IL后就变成了:
try
{
Monitor.Enter(typeof(Program));
int i =0;
//do something
}
finally
{
Monitor.Exit(typeof(Program));
}
我们反编译看⼀下:
注意:lock关键字不能跨线程使⽤,因为它是针对线程上的锁。下⾯的代码是不被允许的:
三、Monitor
参照:
上⾯说了lock关键字是Monitor的语法糖,那么肯定Monitor功能是lock的超集,所以这⾥讲讲Monitor除了lock的功能外还有什么:
Monitor.Wait(lockObj):让⾃⼰休眠并让出锁给其他线程⽤(其实就是发⽣了阻塞),直到其他在锁内的线程发出脉冲
(Pulse/PulseAll)后才可从休眠中醒来开始竞争锁。Monitor.Wait(lockObj,2000)则可以指定最⼤的休眠时间,如果时间到还没有被唤醒那么就⾃⼰醒。注意: Monitor.Wait有返回值,当⾃⼰醒的时候返回false,当其他线程唤醒的时候返回true,这主要是⽤来防⽌线程锁死,返回值可以⽤来判断是否向后执⾏或者是重新发起Monitor.Wait(lockObj)
Monitor.Pulse或Monitor.PulseAll:唤醒由于Monitor.Wait休眠的线程,让他们醒来参与竞争锁。不同的是:Pulse只能唤醒⼀个,PulseAll是全部唤醒。这⾥顺便提⼀下:在多⽣产者、多消费者的情况下,我们更希望去唤醒消费者或者是⽣产者,⽽不是谁都唤醒,在java中我们可以使⽤lock的condition来解决这个问题,在c#中我们可以使⽤下⾯介绍的ManaualResetEvent或
AutoResetEvent
下⾯把Monitor⼯作的流程做⼀个⽐喻:
⼀批⼈在医院⾥看病,诊室⾥⾯只能⼀个⼈进去看医⽣,诊室⾥⾯有个铃铛按钮,诊室的门⼝站了⼏个⼈等待进去,其中有A和B,诊室外⾯板凳上有⼏个⼈在睡觉(现在轮不到他们,所以他们在睡觉,还可能⾃带闹钟哦)其中有C。好了现在说下他们分别代表的对象:
医⽣: cpu
病⼈们: 线程
诊室: 上了锁的代码块
站在诊室门⼝的病⼈: 正在竞争锁的线程(处在就绪区)
坐在旁边睡觉的病⼈: 执⾏了Monitor.Wait(lovkObj)的线程(处在等待区),对于那些带了闹钟的病⼈,就是执⾏了
Monitor.Wait(lockObj,2000)的
诊室⾥病⼈按铃铛: 正在锁内的线程执⾏了Monitor.Pulse或Monitor.PulseAll
writeline特点
现在,开始看病:
⾸先A和B竞争,最后A竞争进去,B就在外⾯等着,A进去之后开始检查看病,检查中A发现⾃⼰拍的⾎常规化验结果还没出来于是⾃⼰就只能中断看病去外⾯睡觉等⼀会了,不过A在出去前按了⼀下诊室⾥的铃铛,铃铛⼀响,坐在板凳上睡觉的C被唤醒了,然后C发现⾃⼰是被铃铛吵醒的说明诊室通知⾃⼰去做准备了,于是C就⾛到诊室门⼝和B⼀起焦急的等待了,这时候A开始退出诊室,不过A⾛出去的时候给⾃⼰定了⼀个闹钟(最多睡三分钟),然后医⽣就让A⾛出诊室了并且把A的的检查资料和进度放在了⼀遍以等A下次进来后接着检查看病,A出来后B和C开始竞争,最后C赢了,于是C进去看病了,C进去之后就看了⼀会就出来了,此时B还等在门外⽽A还在睡觉,于是B毫⽆竞争压⼒的就进去了,这个B看的时间有点长,所以B看病的期间A就醒了⼀次(闹钟定了3分钟),A醒了之后发现诊室的铃铛并没有响,是⾃⼰的闹钟把⾃⼰唤醒了,然后A就开始⾃⼰思考了“我是再睡⼀会还是赶紧去诊室门⼝等着呢”,最终A决定再睡⼀会并再定⼀个闹钟,等了⼀会B看完没事了,B想“外⾯板凳上可能还
有⼈”,于是B就按了下铃铛然后就⾛出诊室了,此时A被铃铛吵醒了,于是就赶紧⾛到诊室门⼝,然后毫⽆竞争压⼒的就进去了,医⽣把刚才检查到⼀半的进度和材料拿过来继续给A看病,最后A也看完出去了,剧终!
说明:
从上⾯的场景模拟中我们可以看出Monitor的特点:
1. ⾸先Monitor是根据⼀把锁让所有等待的线程同⼀时间只能有⼀个线程执⾏。
2. Monitor可以允许进⼊锁的线程中途退出线程去休眠,然后保留这个线程执⾏的进度信息。
3. Monitor可以允许进⼊锁内的线程发送脉冲(Pulse)去唤醒因为Wait⽽休眠中的线程。
下⾯看个实例:

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。