环境:
- 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; } //use 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));
}
我们反编译看一下:
三、Monitor
参照:
Monitor.Pulse()的意义?如果不调用它会造成怎样的后果?
C# Monitor的Wait和Pulse方法使用详解
上面说了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
现在,开始看病:
首先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的特点:
- 首先Monitor是根据一把锁让所有等待的线程同一时间只能有一个线程执行。
- Monitor可以允许进入锁的线程中途退出线程去休眠,然后保留这个线程执行的进度信息。
- Monitor可以允许进入锁内的线程发送脉冲(Pulse)去唤醒因为Wait而休眠中的线程。
下面看个实例:
四、ReadWriterLock
参照:5天不再惧怕多线程——第二天 锁机制
先前也知道,Monitor实现的是在读写两种情况的临界区中只可以让一个线程访问,那么如果业务中存在”读取密集型“操作,就好比数据库一样,读取的操作永远比写入的操作多。针对这种情况,我们使用Monitor的话很吃亏,不过没关系,ReadWriterLock就很牛X,因为实现了”写入串行“,”读取并行“。
ReaderWriteLock中主要用3组方法:
<1> AcquireWriterLock: 获取写入锁。
ReleaseWriterLock:释放写入锁。
<2> AcquireReaderLock: 获取读锁。
ReleaseReaderLock:释放读锁。
<3> UpgradeToWriterLock:将读锁转为写锁。
DowngradeFromWriterLock:将写锁还原为读锁。
-
下面代码测试一下读时候的并行:
class Program { static ReaderWriterLock readerWriterLock = new ReaderWriterLock(); public static void Main(string[] args) { var thread = new Thread(() => { Console.WriteLine("thread1 start..."); readerWriterLock.AcquireReaderLock(3000); int index = 0; while (true) { index++; Console.WriteLine("du..."); Thread.Sleep(1000); if (index > 6) break; } readerWriterLock.ReleaseReaderLock(); }); thread.Start(); var thread2 = new Thread(() => { Console.WriteLine("thread2 start..."); readerWriterLock.AcquireReaderLock(3000); int index = 0; while (true) { index++; Console.WriteLine("读..."); Thread.Sleep(1000); if (index > 6) break; } readerWriterLock.ReleaseReaderLock(); }); thread2.Start(); Console.ReadLine(); } }
-
测试写的串行
class Program { static ReaderWriterLock readerWriterLock = new ReaderWriterLock(); public static void Main(string[] args) { var thread = new Thread(() => { Console.WriteLine("thread1 start..."); readerWriterLock.AcquireWriterLock(1000); Console.WriteLine("写..."); Thread.Sleep(5000); Console.WriteLine("写完了..."); readerWriterLock.ReleaseReaderLock(); }); thread.Start(); var thread2 = new Thread(() => { Console.WriteLine("thread2 start..."); try { readerWriterLock.AcquireReaderLock(2000); Console.WriteLine("du..."); readerWriterLock.ReleaseReaderLock(); Console.WriteLine("du wan..."); } catch (Exception ex) { Console.WriteLine(ex.Message); } }); Thread.Sleep(100); thread2.Start(); Console.ReadLine(); } }
class Program { static ReaderWriterLock readerWriterLock = new ReaderWriterLock(); public static void Main(string[] args) { var thread = new Thread(() => { Console.WriteLine("thread1 start..."); try { readerWriterLock.AcquireWriterLock(1000); Console.WriteLine("写..."); readerWriterLock.ReleaseReaderLock(); Console.WriteLine("写完了..."); } catch (Exception ex) { Console.WriteLine(ex.Message); } }); var thread2 = new Thread(() => { Console.WriteLine("thread2 start..."); readerWriterLock.AcquireReaderLock(2000); Console.WriteLine("du..."); Thread.Sleep(5000); readerWriterLock.ReleaseReaderLock(); Console.WriteLine("du wan..."); }); thread2.Start(); Thread.Sleep(100); thread.Start(); Console.ReadLine(); } }
从上面的试验可以看出,“读“和“写”锁是不能并行的,他们之间相互竞争,同一时间,里面可以有一批“读”锁或一个“写”锁 ,其他的则不允许。
五、Mutex
本篇参照:c# mutex
Mutex的实现是调用操作系统层的功能,所以Mutex的性能要略慢一些,而它所能锁住的范围更大(它能跨进程上锁),但是它的功能也就相当于lock关键字(因为没有类似Monitor.Wait和Monitor.Pulse的方法)。
Mutex分为命名的Mutex和未命名的Mutex,命名的Mutex可用来跨进程加锁,未命名的相当于lock。
所以说:在一个进程中使用它的场景真的不多。它的比较常用场景如:限制一个程序在一个计算机上只能允许运行一次:
class Program
{
private static Mutex mutex = null;
static void Main()
{
bool firstInstance;
mutex = new Mutex(true, @"Global\MutexSampleApp", out firstInstance);
try
{
if (!firstInstance)
{
Console.WriteLine("已有实例运行,输入回车退出……");
Console.ReadLine();
return;
}
else
{
Console.WriteLine("我们是第一个实例!");
for (int i = 60; i > 0; --i)
{
Console.WriteLine(i);
Thread.Sleep(1000);
}
}
}
finally
{
if (firstInstance)
{
mutex.ReleaseMutex();
}
mutex.Close();
mutex = null;
}
}
}
需要注意的地方:
new Mutex(true, @"Global\MutexSampleApp", out firstInstance)
代码不会阻塞当前线程(即使第一个参数为true),在多进程协作的时候最后一个参数firstInstance
很重要,要善于运用。mutex.WaitOne(30*1000)
代码,当前进程正在等待获取锁的时候,已占用了这个命名锁的进程意外退出了,此时当前线程并不会直接获得锁然后向后执行,而是抛出异常AbandonedMutexException
,所以在等待获取锁的时候要记得加上try catch。可以参照下面的代码:class Program { private static Mutex mutex = null; static void Main() { mutex = new Mutex(false, @"Global\MutexSampleApp"); while (true) { try { Console.WriteLine("start wating..."); mutex.WaitOne(20 * 1000); Console.WriteLine("enter success"); Thread.Sleep(20 * 1000); break; } catch (AbandonedMutexException ex) { Console.WriteLine(ex.Message); continue; } } //do something mutex.ReleaseMutex(); Console.WriteLine("Released"); Console.WriteLine("ok"); Console.ReadKey(); } }
六、Semaphore
参照:C#多线程–信号量(Semaphore)
这个称之为信号量,它和Mutex都是继承WaitHandle
的,所以他们本质都是调用操作系统,速度和Monitor相比略慢。
Semaphore的主要功能是限制并发的数量,即可以限制同一时间进入代码块的线程数量,不过Semaphore也可以有命名,命名后就可以进程间加锁了(也可以用来限制一个程序只能运行一次),并且new Semaphore(1, 1)
相当于Mutex,所以说Semaphore的功能是Mutex的功能的超集,但是有一个不同:
Semaphore在执行WaitOne()时,如果正在占用锁的进程没有Release()而是意外退出(比如我们强制关闭),那么Semaphore是不会直接进入锁的,而是一直死等了,而Mutex遇到这种情况的时候是抛出AbandonedMutexException
异常。
注意:
Semaphore的构造函数中允许我们指定初始可用的信号量和总共的信号量,一般我们都是让初始可用的信号量等于总共的信号量。但是我们也可以指定初始可用的信号量小于总共的信号量,那么我们在使用的时候可以一次Release多个信号量以达到全部信号量都被充分利用情况。
还有:我们可以在当前线程不进入锁内的时候执行Release方法,所以从Semaphore开始、ManualResetEvent和AutoResetEvent这三个更像是线程间的信号了,而不再是锁。
下面看个实例:
private static void Test4()
{
Semaphore sema = new Semaphore(5, 5);
int count = 0;
for (int i = 0; i < 10; i++)
{
new Thread(() =>
{
sema.WaitOne();
count++;
Thread.Sleep(1000);
Console.WriteLine($"time:{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}\tcount={count}");
Thread.Sleep(1000 * 10);
sema.Release();
}).Start();
}
}
从上面的输出可以看到,允许有5个并发。
七、ManualResetEvent和AutoResetEvent
参照:ManualResetEvent 与 AutoResetEvent 区别
这两个对象都继承自EventHandle
最终继承自WaitHandle
,所以他们很像。
其实通过名字也可以看出,一个是手动的,一个是自动的,它们都没有命名的功能,所以不能跨进程使用。
这两个的对象的功能是通过WaitOne
、Set
和ReSet
方法提现的,原理就是他们封装了同一个bool值,当bool值为true的时候就允许waitone同行,否则就阻塞waitone的线程直到有其他线程将bool值设为true。
WaitOne
:阻塞当前线程直到bool值变为true
Set
:将bool值设为true,对于AutoResetEvent 来说是将bool值设为true后等待一个线程通过,一旦通过后就立马设为flase,也就是一次只放行一个线程;对于ManualResetEvent来说是将bool值设为true,让所有线程通过,然后bool值就保持为true了直到下次执行Reset方法再变回false
ReSet
:将bool值设为false以阻止线程通过。
注意:
虽说AutoResetEvent的Set方法相当于ManualResetEvent的Set+Reset方法,但是AutoResetEvent的Set方法能保证一次只有一个线程通过,但是ManualResetEvent的Set+Reset方法
却不能保证只有一个线程通过。
下面给出几个测试代码:
/// <summary>
/// 测试AutoResetEvent执行set后必须要有一个线程被释放才会自动切换到无信号状态
/// </summary>
private static void Test11()
{
var resetevent = new AutoResetEvent(false);
resetevent.Set();
new Thread(() =>
{
resetevent.WaitOne();
Console.WriteLine("进来了...");
resetevent.WaitOne();
Console.WriteLine("又进来了....");
}).Start();
}
/// <summary>
/// 测试AutoResetEvent一次只能通知一个线程,而ManualResetEvent是批量通知
/// </summary>
private static void Test10()
{
//var testevent = new AutoResetEvent(false);
var testevent = new ManualResetEvent(false);
Console.WriteLine("main coming...");
int count = 0;
for (int i = 0; i < 5; i++)
{
new Thread(() =>
{
testevent.WaitOne();
count++;
}).Start();
}
testevent.Set();
Thread.Sleep(1000);
Console.WriteLine("count=" + count);
}
/// <summary>
/// 测试AutoResetEvent,注意:AutoResetEvent不获取锁也可以通知其他线程,一次只通知一个
/// </summary>
private static void Test9()
{
AutoResetEvent autoResetEvent = new AutoResetEvent(false);
int count = 0;
var thread = new Thread(() =>
{
Thread.Sleep(3000);
count++;
autoResetEvent.Set();
});
thread.Start();
Console.WriteLine("main ...");
autoResetEvent.WaitOne();
Console.WriteLine("count=" + count);
}
八、测试锁的性能消耗
测试锁对程序性能的影响(10000*10000次的简单运算,运算量比较大。。。)
对比不使用锁、使用Lock、使用Monitor、使用原子操作对程序性能的影响
class Program
{
public static void Main(string[] args)
{
new Thread(Run).Start();
new Thread(Run_Interlocked).Start();
new Thread(Run_Lock).Start();
new Thread(Run_Monitor).Start();
Console.WriteLine("ok");
Console.ReadLine();
}
private static void Run_Monitor()
{
int count = 0;
var stopWatch = new Stopwatch();
var type = typeof(Program);
stopWatch.Start();
for (int i = 0; i < 10000 * 10000; i++)
{
Monitor.Enter(type);
count++;
Monitor.Exit(type);
}
stopWatch.Stop();
Console.WriteLine("Monitor:" + stopWatch.ElapsedMilliseconds);
}
private static void Run_Interlocked()
{
int count = 0;
var stopWatch = new Stopwatch();
var type = typeof(Program);
stopWatch.Start();
for (int i = 0; i < 10000 * 10000; i++)
{
Interlocked.Increment(ref count);
}
stopWatch.Stop();
Console.WriteLine("Interlocked:" + stopWatch.ElapsedMilliseconds);
}
private static void Run_Lock()
{
int count = 0;
var stopWatch = new Stopwatch();
var type = typeof(Program);
stopWatch.Start();
for (int i = 0; i < 10000 * 10000; i++)
{
lock (type)
{
count++;
}
}
stopWatch.Stop();
Console.WriteLine("Lock:" + stopWatch.ElapsedMilliseconds);
}
private static void Run()
{
int count = 0;
var stopWatch = new Stopwatch();
var type = typeof(Program);
stopWatch.Start();
for (int i = 0; i < 10000 * 10000; i++)
{
count++;
}
stopWatch.Stop();
Console.WriteLine("Normal:" + stopWatch.ElapsedMilliseconds);
}
}
看运行结果:
可以看出:lock锁对是正常运行速度的25倍,原子操作要小一些。。。
注意: 这个25倍是我们进行1亿次锁操作出来的,可见实际中这种影响微乎其微,真正耗费时间的是线程等待时间。
九、总结
它们的特点及应用场景如下表所示:
互斥量、信号量和通知事件的类继承关系: