前两篇博客,分别介绍了用户模式和内核模式的同步构造,由于它们各有优势和劣势。本文将介绍如何将这两者的优势结合在一起,构建一个性能良好的同步机制。
#region hybird lock /// <summary> /// 简单的混合同步锁 /// </summary> private sealed class HybirdLock { private int m_waiters = 0; AutoResetEvent m_waitLock = new AutoResetEvent(false); public void Enter() { //如果只有一个线程,直接返回 if (Interlocked.Increment(ref m_waiters) == 1) return; //1个以上的线程在这里被阻塞 m_waitLock.WaitOne(); } public void Leave() { //如果只有一个线程,直接返回 if (Interlocked.Decrement(ref m_waiters) == 0) return; //如果有多个线程等待,就唤醒一个 m_waitLock.Set(); } }
优点:只有一个线程的时候仅在用户模式下运行(速度极快),多于一个线程时才会用到内核模式(AutoRestEvent),这大大的提升了性能。由于线程的并发访问毕竟是少数,多数情况下都是一个线程在访问资源,利用用户模式构造可以保证速度,利用内核模式又可以阻塞其它线程(虽然也有线程切换代价,但比起用户模式的一直自旋浪费cpu时间可能会更好,况且只有在多线程冲突时才会使用这个内核模式,几率很低)。
下面来看看具体的实现:
/// <summary> /// 加入自旋,线程多有权,递归的混合同步锁 /// </summary> private sealed class AnotherHybirdLock : IDisposable { //等待的线程数 private int m_waiters = 0; //切换到内核模式是,用于同步 AutoResetEvent m_waitLock = new AutoResetEvent(false); //用户模式自旋的次数(可以调整大小) private int m_spinCount = 4000; //用于判断获取和释放锁是不是同一线程 private int m_owningThreadId = 0; //同一线程循环计数(为0时,代表该线程不拥有锁了) private int m_recursion = 0; private void Enter() { int threadId = Thread.CurrentThread.ManagedThreadId; //同一线程,多次调用的情况 if (m_owningThreadId == threadId) { m_recursion++; return; } //先采用用户模式自旋,这避免了切换 SpinWait spinWait = new SpinWait();//.Net自带的用于用户模式等待的类 for (int i = 0; i < m_spinCount; i++) { //试图在用户模式等待获得锁,如果获得成功,应跳过内核模式的阻塞 if (Interlocked.CompareExchange(ref m_waiters, 1, 0) == 0) { //这里用了goto语句,可以用flag等去掉goto goto GotLock; } spinWait.SpinOnce(); } //内核模式阻塞(在尝试获取了一次) //如果=1,也不用在内核模式阻塞 if (Interlocked.Increment(ref m_waiters) > 1) { //多个线程在这里都会被阻塞 m_waitLock.WaitOne();//性能损失 //等这个线程醒来时,它拥有锁,并记录一些状态 } GotLock: //线程获取锁是记录线程Id,重置计数为1 m_owningThreadId = threadId; m_recursion = 1; } private void Leave() { int threadId = Thread.CurrentThread.ManagedThreadId; //检查释放锁的线程的一致性 if (threadId != m_owningThreadId) throw new SynchronizationLockException("Lock not owned by calling thread"); //同一线程,循环计数没有归0,不能递减线程计数 if (--m_recursion > 0) return; m_owningThreadId = 0;//没有线程拥有锁 //么有其它线程被阻塞,直接返回 if (Interlocked.Decrement(ref m_waiters) == 0) return; //有其他线程被阻塞,唤醒其中一个 m_waitLock.Set();//这里有性能损失 } #region IDisposable [MethodImpl(MethodImplOptions.Synchronized)] public void Dispose() { m_waitLock.Dispose(); } #endregion }
注释中,已经写的相当详细了,一定要好好理解它的实现方式,我们最常用的Monitor类和它的实现方式几乎一样。
有了上面的自定义混合同步构造的基础,再来看看.net为我们都准备了哪些能够直接使用的混合同步构造。
特别要注意一点:它们的性能都会比单纯的内核模式构造(如Mutex,AutoResetEvent等)要好很多,在实际项目中,要酌情使用。
它们的构造和内核模式的ManualResetEvent,Semaphore完全一致,只是它们都在用户模式中“自旋”,而且都推迟到发生第一次竞争时,才创建内核模式的构造。另外,可以向wait方法传入CancellationToken以支持取消。
Monitor类应该是我们我们使用得最频繁的同步技术。它提供了一个互斥锁,这个锁支持自旋,线程所有权和递归。和我们上面展示的那个自定义同步类AnotherHybirdLock相似。它是一个静态类,提供了Enter和Exit方法用于获取锁和释放锁,会使用到传递给Enter和Exit方法对象的同步块。同步块的构造和AnotherHybirdLock的字段相似,包含一个内核对象、拥有线程的ID、一个递归计数、以及一个等待线程的计数。关于同步块的概念,可以查阅其它的资料,这里不做太多的讲解。
Monitor存在的问题以及使用建议:
lock关键字是对Monitor类的一个简化语法。
public void SomeMethod() { lock (this) { //对数据的独占访问。。。 } } //等价于下面这样 public void SomeMehtodOther() { bool lockToken = false; try { //线程可能在这里推出,还没有执行Enter方法 Monitor.Enter(this, ref lockToken); //对数据的独占访问。。。 } finally { if (lockToken) Monitor.Exit(this); } }
lockToken变量的作用:如果一个线程在没有调用Enter方法时就退出,这时它的值为false,finally块中就不会调用Exit方法;如果成功获得锁,它就为true,这时就可以调用Exit方法。
lock关键字存在的问题:
Jeffrey指出,编译器为lock关键字生成的代码默认加上了try/finally块,如果在对数据的独占访问时发生了异常,当前线程是可以正常退出的。但是,如果有其他的线程正在等待,它们会被唤醒,从而访问到由于异常而被破坏掉的脏数据,进而引发安全漏洞。与其这样,还不如让进程终止。另外,进入一个try块和finally块会使代码的速度变慢。它建议我们杜绝使用lock关键字,当然,估计太多的程序员都在使用lock关键字,该不该杜绝使用,自己判断。
互斥锁保证多线程在访问一个资源时,只有一个线程才会运行,其它的线程都阻塞了,这会降低应用程序的吞吐量。如果所有线程都以只读的方法访问资源,我们就没有必要阻塞它。另一方面,如果一个线程希望修改数据,就需要独占的访问。ReaderWriterLockSlim就能解决这个问题。
它的实现方式是这样的:
一个简单的例子:
public class MyResource:IDisposable { //LockRecursionPolicy(NoRecursion,SupportsRecursion) //SupportsRecursion会导致增加递归,开销会变得很大,尽量用NoRecursion private ReaderWriterLockSlim m_lock = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion); private object m_source; public void WriteSource(object source) { m_lock.EnterWriteLock(); //写独占访问 m_source = source; m_lock.ExitWriteLock(); } public object GetSource() { m_lock.EnterReadLock(); //共享访问 object temp = m_source; m_lock.ExitReadLock(); return temp; } #region IDisposable public void Dispose() { m_lock.Dispose(); } #endregion }
.net 1.0提供了一个ReaderWriterLock,少了一个Slim后缀。它存在下面的几个问题:
不太常用。这个构造阻塞一个线程,直到它的内部计数为0。这和Semaphore恰恰相反。如果它的CurrentCount变为0,就不能再度更改了。再次调用AddCount方法会抛出异常。
不太常用。它可以用于一系列线程并行工作。每个参与者线程完成阶段性工作后,都调用SignalAndWait方法阻塞自己,最后一个参与者线程调用SignalAndWait方法后会解除所有线程的阻塞。
如果你觉得本文对你还有一丝丝帮助,支持一下吧,总结提炼也要花很多精力呀,伤不起。。。
主要参考资料:
CLR Via C# 3 edition