混合线程同步核心篇——自定义混合同步锁,Monitor,lock,ReaderWriterLockSlim・・・
2012-08-31 10:18 xiashengwang 阅读(2754) 评论(5) 编辑 收藏 举报前两篇博客,分别介绍了用户模式和内核模式的同步构造,由于它们各有优势和劣势。本文将介绍如何将这两者的优势结合在一起,构建一个性能良好的同步机制。
一,实现一个简单的混合同步锁
#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时间可能会更好,况且只有在多线程冲突时才会使用这个内核模式,几率很低)。
二、实现一个加入自旋,线程所有权,递归的混合同步锁
- 自旋:使多线程并发时,可以在一定的时间内维持在用户模式,如果在这个期间获得了锁,就不用切换到内核模式,以避免切换的开销。
- 线程所有权:只有获得锁的线程才能释放锁。
- 递归:就是同一线程可以多次调用获取锁的方法,然后调用等次数的释放锁的操作(mutex就属于这种类型)。
下面来看看具体的实现:
/// <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类和它的实现方式几乎一样。
三、细数FCL提供的混合构造:
有了上面的自定义混合同步构造的基础,再来看看.net为我们都准备了哪些能够直接使用的混合同步构造。
特别要注意一点:它们的性能都会比单纯的内核模式构造(如Mutex,AutoResetEvent等)要好很多,在实际项目中,要酌情使用。
2.1 ManualResetEventSlim,SemaphoreSlim
它们的构造和内核模式的ManualResetEvent,Semaphore完全一致,只是它们都在用户模式中“自旋”,而且都推迟到发生第一次竞争时,才创建内核模式的构造。另外,可以向wait方法传入CancellationToken以支持取消。
2.2 Monitor类和同步块
Monitor类应该是我们我们使用得最频繁的同步技术。它提供了一个互斥锁,这个锁支持自旋,线程所有权和递归。和我们上面展示的那个自定义同步类AnotherHybirdLock相似。它是一个静态类,提供了Enter和Exit方法用于获取锁和释放锁,会使用到传递给Enter和Exit方法对象的同步块。同步块的构造和AnotherHybirdLock的字段相似,包含一个内核对象、拥有线程的ID、一个递归计数、以及一个等待线程的计数。关于同步块的概念,可以查阅其它的资料,这里不做太多的讲解。
Monitor存在的问题以及使用建议:
- Monitor类如果锁住了一个业务对象,那么其他线程在该对象上的任何操作都会被阻塞。所以,最好的办法是提供一个私有的专用字段用于锁。如:private objectm_lock = new object();如果方法是静态的,那么这个锁字段也标注成静态(static)就可以了。
- 不要对string对象加锁。原因是,字符串可能留用(interning),两个完全不同的代码段可能指向同一个string对象。如果加锁,两个代码段在完全不知情的情况下就被同步了。另一个原因是跨界一个AppDomain传递一个字符串时,不会复制副本,相反,它传递的是一个引用,如果加锁,也会出现上面的情况。这是CLR在AppDomain隔离中的一个bug。
- 不要锁住一个类型(Type)。如果一个类型对象是以“AppDomain中立”的方式加载,它会被其它AppDomain共享。线程会跨越AppDomain对该类型对象加锁,这也是CLR的一个已知bug。
- 不要对值类型加锁。每次调用Monitor的Enter方法,都会对这个值类型装箱,造成每次锁的对象都不一样,无法做到线程同步。
- 避免向一个方法应用[MethodImpl( MethodImplOptions.Synchronized)]特性。如果方法是一个实例方法,那么JIT编译器会加入Monitor.Enter(this)和Monitor.Exit(this)来包围代码。如果是一个静态方法,传给Enter方法的就是这个类的类型。
- 调用一个类型的类型构造器(静态构造函数)时,CLR要获取类型对象上的一个锁,确保只有一个线程初始化类型对象及其静态字段。同样,如果类型是以“AppDomain中立”的方式加载,也会出现问题。例如,静态构造函数里出现一个死循环,进程中所有AppDomain都不能使用该类型。所以要尽量保证静态函数短小简单,或尽量避免用类型构造器。
2.3 lock关键字
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关键字,该不该杜绝使用,自己判断。
2.4 ReaderWriterLockSlim
互斥锁保证多线程在访问一个资源时,只有一个线程才会运行,其它的线程都阻塞了,这会降低应用程序的吞吐量。如果所有线程都以只读的方法访问资源,我们就没有必要阻塞它。另一方面,如果一个线程希望修改数据,就需要独占的访问。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后缀。它存在下面的几个问题:
- 不存在线程竞争,数度也很慢。
- 线程所有权和递归被它进行了封装,并且还取消不了。
- 相比writer,它更青睐reader,这可能造成writer排很长的对而得不到执行。
2.5 CountDownEvent
不太常用。这个构造阻塞一个线程,直到它的内部计数为0。这和Semaphore恰恰相反。如果它的CurrentCount变为0,就不能再度更改了。再次调用AddCount方法会抛出异常。
2.6 Barrier
不太常用。它可以用于一系列线程并行工作。每个参与者线程完成阶段性工作后,都调用SignalAndWait方法阻塞自己,最后一个参与者线程调用SignalAndWait方法后会解除所有线程的阻塞。
如果你觉得本文对你还有一丝丝帮助,支持一下吧,总结提炼也要花很多精力呀,伤不起。。。
主要参考资料:
CLR Via C# 3 edition