一提到线程同步,就会提到锁,做为线程同步的手段之一,锁老是饱受质疑。一方面锁的使用很简单,只要在代码不想被重入的地方(多个线程同时执行的地方)加上锁,就能够保证不管什么时候,该段代码最多有一个线程在执行;另外一方面,锁又不像它看起来那样简单,锁会形成不少问题:性能降低、死锁等。使用volatile关键字或者Interlocked中提供的方法可以避开锁的使用,可是这些原子操做的方法功能有限,不少操做实现起来很麻烦,如无序的线程安全集合。我在本系列的序中已经介绍了锁的总类,自旋锁、内核锁(内核构造)、混合锁,他们各有优缺点,下面就来一一介绍。html
我在 C#多线程编程(6) 中已经介绍了简单的利用Interlocked实现的自旋锁,这种锁的优势是单线程时很是快,可是在竟态时会形成等待的线程“自旋”--无限while循环,在循环中只是在不断的尝试得到锁,其余什么也不作。若是得到锁的线程执行的很是快,那么等待的线程在那自旋一会是值得的,由于当锁被释放时,自旋的线程可以立马得到锁。可是当得到锁的线程执行的时间很长,等待线程的自旋就毫无心义了。设想这样一个场景,A线程得到了锁,B和C线程想要得到锁,发现锁已被其余线程得到,就会开始自旋,而且A线程迟迟不愿交出锁,这至关于一个锁占用了3个线程,这是多么大的浪费!编程
其实准确的说,内核锁是内核维护的变量,《CLR via C#》中叫内核构造,更准确一些,可是我感受内核构造这个名字太奇怪了,仍是内核锁好理解一些。内核锁是一组继承了WaitHandle基类的一组类windows
public abstract class WaitHandle : MarshalByRefObject, IDisposable{ public virtual IntPtr Handle { get; set; } public SafeWaitHandle SafeWaitHandle { get; set; } public void Dispose(); public static bool SignalAndWait(WaitHandle toSignal, WaitHandle toWaitOn); public static bool WaitAll(WaitHandle[] waitHandles); public static int WaitAny(WaitHandle[] waitHandles); public virtual bool WaitOne(); }
我只列出了基本的方法,重载的版本没有列出。每构造一个内核锁,都是调用windows方法建立一个内核变量保存在SafeWaitHandle属性中。这是个抽象类,全部的内核锁都继承于此。简单介绍下上面列出的几个方法。数组
下面介绍几个从WaitHandle派生的类:AutoResetEvent、Semaphore、Mutex。安全
AutoResetEvent是内核维护的一个bool型变量,当锁为自由状态时,值为true,调用WaitOne方法,值变为false(至关于得到锁)。在调用Set()方法后(至关于释放锁)值恢复为true,并唤醒一个等待的线程。,咱们来看一个利用AutoResetEvent来重写 C#多线程编程(6) 中的SimpleSpinLock类,新类的名字为SimpleWaitLock。多线程
class SimpleWaitLock : IDisposable{ private AutoResetEvent _lockState = new AutoResetEvent(true); public void Enter() { _lockState.WaitOne(); } public void Leave() { _lockState.Set(); } public void Dispose() { _lockState.Dispose(); } }
能够看到锁和SimpleSpinLock很是像,可是这两个锁的性能大相径庭。我前面说过,自旋锁在串行下效率很是高,可是内核锁在访问内核变量并判断是否能够得到锁时,要先从托管代码切换到内核代码,得到值后,在切换回托管代码,这些消耗不管是否竟态都要花费。可是在竟态时,自旋锁会白白浪费CPU,而内核锁会阻塞等待的线程,而不会自旋而浪费CPU。性能
用一个例子来对比下在串行下两个锁的性能差距。this
static void Main(string[] args){ int x = 0; int count = 10000000; Stopwatch sw = Stopwatch.StartNew(); for (int i = 0; i < count; i++){ x++; } Console.WriteLine("NoMethod: {0} ms", sw.ElapsedMilliseconds); sw = Stopwatch.StartNew(); for (int i = 0; i < count; i++){ VoidMothed(); x++; VoidMothed(); } Console.WriteLine("VoidMethod:{0} ms", sw.ElapsedMilliseconds); SpinLock spinLock = new SpinLock(false); sw = Stopwatch.StartNew(); for (int i = 0; i < count; i++){ bool taken = false; spinLock.Enter(ref taken); x++; spinLock.Exit(); } Console.WriteLine("SpinLock:{0} ms", sw.ElapsedMilliseconds); using (SimpleWaitLock simpleWaitLock = new SimpleWaitLock()){ sw = Stopwatch.StartNew(); for (int i = 0; i < count; i++){ simpleWaitLock.Enter(); x++; simpleWaitLock.Leave(); } Console.WriteLine("WaitLock: {0} ms", sw.ElapsedMilliseconds); } Console.ReadLine(); }
运行结果: spa
NoMethod: 22 ms
VoidMethod:56 ms
SpinLock:216 ms
WaitLock: 11683 mspwa
执行一千万次的x++只须要22ms,带一个空方法VoidMethod的是56ms,spinlock是216ms,而内核锁执行了11683ms,是spinlock的54倍(11683/216),而比NoMethod慢了531倍(11683/22),确实直接访问内核锁要慢不少,所以能避免使用内核锁就要避免。
信号量(Semaphore)就是内核维护的一个int型变量,其用法和AutoResetEvent相似。Release(int)方法支持同时释放多个阻塞的线程。与AutoResetEvent相比,AutoResetEvent.Set方法调用屡次,只有一个线程接触阻塞,可是Release()会一直解除其余线程的阻塞,直到全部线程所有解除等待。若将全部等待的线程所有解除阻塞后,继续调用Release方法,会抛出一个SemaphoreFullException异常。
public sealed class Semaphore:WaitHandle{ public Semaphore(int initialCount, int maximumCount); public int Release()//调用Release(1);释放1个线程的等待(阻塞) public int Release(int releaseCount);//释放releaseCount个的线程的等待 }
能够用信号量来从新实现SimpleWaitLock:
class SimpleWaitLock : IDisposable{ private readonly Semaphore _lockState; public SimpleWaitLock(int maxCount) { _lockState = new Semaphore(maxCount, maxCount); } public void Enter() { _lockState.WaitOne(); } public void Leave() { _lockState.Release(); } public void Dispose() { _lockState.Dispose(); }
Mutex是一个互斥锁,该锁的功能和AutoResetEvent还有SimpleWaitLock,
public class Mutex:WaitHandle{ public Mutex(); public void Release(); }
可是Mutex还有一些额外的功能:该锁支持递归调用。锁的递归调用就是单线程屡次加锁,以下所示:
var smLock = new SmLock(); void M1(){ smLock.Enter(); M2();//M2中会再次加锁,即“递归” smLock.Exit(); } void M2(){ smLock.Enter(); //一些操做 smLock.Exit(); }
为了支持这个功能,Mutex中会记录当前得到锁的线程Id,容许已经得到锁的线程再次加锁,并对加锁次数进行计数。当计数为0时,调用Release()方法重置锁。
前面介绍了自旋锁和内核锁,下面来介绍混合锁。前面介绍了自旋锁的优点在串行时的性能消耗较小,内核锁的优点在竟态时不占用CPU资源,而混合锁就是这二者的结合体,咱们来实现一个简单的混合锁。
public class SimpleHybridLock : IDisposable { private readonly AutoResetEvent _coreLock = new AutoResetEvent(true); private volatile int _outLock = 0; public void Enter() { //尝试得到锁 if (++_outLock == 1) //锁无人使用,直接返回 return; //有其余线程已得到锁,则在此等待该锁释放。 _coreLock.WaitOne(); } public void Leave() { //尝试释放锁,若是没有其余线程在等待锁,则直接返回 if (--_outLock == 0) return; //唤醒一个等待的线程,比较浪费性能 _coreLock.Set(); } /// <summary> /// 释放内核锁 /// </summary> public void Dispose() { _coreLock.Dispose(); } }
在SimpleHybridLock中,我声明了一个int变量和一个AutoResetEvent变量,当调用Enter方法时,使_outLock++,并判断新值是否为1,若是是1,则表示该锁是自由的,没有被其余线程得到。由于当该锁被其余线程得到后,再次调用Enter方法会使_outLock的值变为2,。若是是1,则直接返回,表示该线程已经得到了该锁。若是大于1,表示该锁已经被其余线程得到,这时就会继续执行,调用_coreLock.WaitOne()方法,来等待该锁的释放。在调用Leave方法时,会先判断--_outLock 是否为0,若返回0,则该锁已经被释放。由于当返回值大于0时,表明有其余线程在等待该锁,这时就要调用_coreLock.Set来唤醒一个等待的线程。
SimpleHybridLock锁的优点在:当单线程执行时,永远不会出现另一个线程来尝试得到锁,那么该锁只有很小的开支,对_outLock++,并判断新值是否为1,而后直接返回,在释放锁的时候,对_outLock--,而后判断是否为0。_coreLock.WaitOne()永远不会被执行,所以该锁有自旋锁的优势,单线程快。当多线程时,会调用_coreLock.WaitOne()来等待该锁的释放,而不会“自旋”,从而浪费CPU。
该锁的问题是,没有记录哪一个线程得到了锁,若是想要得到锁的线程直接调用Leave方法,就会形成错误的锁释放。不光如此,该锁也没有实现锁的递归。实现这些功能就会形成资源的消耗,就看你是否有能力来保证不会出现上述状况。
还能够在Enter方法中,if(++_outLock == 1){}处添加一个较短的自旋,该自旋是为了处理那些很是短的得到锁和释放所的程序。当判断锁已经被获取时,不会直接调用_coreLock.WaitOne(),而是自旋一段时间,若是得到锁的线程执行了一段很短的程序,那么在自旋的过程当中,锁已经被释放了,这时就能够避免调用内核锁来浪费资源。这是一种有针对性的修改。使锁更适合那些较短的任务。
FCL提供了几个混合锁,其中最经常使用的是Monitor,它支持自旋、递归、锁的全部权,咱们在调用lock(object)时,编译器会将其编译成Monitor.Enter和Exist块。那到底Monitor是怎么实现的?
Monitor的基本原理是利用class在堆中初始化时,会初始化一个同步块。该同步块是用来记录有哪些指针引用了该对象,以及引用的个数。咱们在调用Monitor的时候,会这样
Monitor.Enter(obj); x++; Monitor.Exist(obj);
简单理解,在调用Monitor.Enter(obj)方法后,会形成obj没法再被其余线程调用,直到Monitor.Leave(obj)被调用。这样会形成Monitor的BUG,例子以下:
class MonitorExample { private int m; public void Test() { Monitor.Enter(this); m = 5; Monitor.Exit(this); } public int M { get { Monitor.Enter(this); //保证对m访问的安全性 int n = m; Monitor.Exit(this); return n; } } } public static void TestMethod() { var t = new MonitorExample(); Monitor.Enter(t); //注意,线程池会阻塞,直到TestMethod调用Monitor.Exist(t)! //由于已经对t添加了Monitor.Enter,其余线程没法访问t。 ThreadPool.QueueUserWorkItem(o => {Console.WriteLine(t.M); });
//执行其余代码
Monitor.Exist(t); }
TestMethod方法会形成线程池的阻塞,缘由是已经对t添加了Monitor.Enter,其余线程没法访问t,究其缘由,Monitor不应设计成静态类,而是应该和其余锁同样,设计成正常的类,而后初始化Monitor,就不会形成上述问题,由于锁是独有的。解决的办法是单独声明一个只读的字段用来锁定,以下:
object readonly obj = new object(); Monitor.Enter(obj); //执行其余操做 Monitor.Exist(obj);
避免对访问对象调用Monitor.Enter,而是对单独的字段来进行锁定。
以上,就是本文的所有内容,至此,我完成了C#多线程编程系列的所有内容。本文的知识点可能是来自《CLR via C#》,说是该书最后一部分线程基础的总结和梳理也不为过。还有一部分是来自《果壳中的C#》,虽然该书内容较浅,不少知识都是浅尝辄止,可是我在开始看《CLR via C#》时,有点读不明白,由于知识点不少,并且太细了,反而有些无法掌握,偶然翻开《果壳》,简单的看了一下多线程部分,发现这本书讲的提纲挈领,我一会儿就明白很多。
若是您对本文有任何问题,欢迎在评论区与我互动。