混合线程同步核心篇——自定义混合同步锁,Monitor,lock,ReaderWriterLockSlim・・・

 前两篇博客,分别介绍了用户模式和内核模式的同步构造,由于它们各有优势和劣势。本文将介绍如何将这两者的优势结合在一起,构建一个性能良好的同步机制。

一,实现一个简单的混合同步锁

#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存在的问题以及使用建议:

  1.  Monitor类如果锁住了一个业务对象,那么其他线程在该对象上的任何操作都会被阻塞。所以,最好的办法是提供一个私有的专用字段用于锁。如:private objectm_lock = new object();如果方法是静态的,那么这个锁字段也标注成静态(static)就可以了。
  2. 不要对string对象加锁。原因是,字符串可能留用(interning),两个完全不同的代码段可能指向同一个string对象。如果加锁,两个代码段在完全不知情的情况下就被同步了。另一个原因是跨界一个AppDomain传递一个字符串时,不会复制副本,相反,它传递的是一个引用,如果加锁,也会出现上面的情况。这是CLR在AppDomain隔离中的一个bug。
  3. 不要锁住一个类型(Type)。如果一个类型对象是以“AppDomain中立”的方式加载,它会被其它AppDomain共享。线程会跨越AppDomain对该类型对象加锁,这也是CLR的一个已知bug。
  4. 不要对值类型加锁。每次调用Monitor的Enter方法,都会对这个值类型装箱,造成每次锁的对象都不一样,无法做到线程同步。
  5. 避免向一个方法应用[MethodImpl( MethodImplOptions.Synchronized)]特性。如果方法是一个实例方法,那么JIT编译器会加入Monitor.Enter(this)和Monitor.Exit(this)来包围代码。如果是一个静态方法,传给Enter方法的就是这个类的类型。
  6. 调用一个类型的类型构造器(静态构造函数)时,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后缀。它存在下面的几个问题:

  1. 不存在线程竞争,数度也很慢。
  2. 线程所有权和递归被它进行了封装,并且还取消不了。
  3. 相比writer,它更青睐reader,这可能造成writer排很长的对而得不到执行。

2.5 CountDownEvent

不太常用。这个构造阻塞一个线程,直到它的内部计数为0。这和Semaphore恰恰相反。如果它的CurrentCount变为0,就不能再度更改了。再次调用AddCount方法会抛出异常。

2.6 Barrier

不太常用。它可以用于一系列线程并行工作。每个参与者线程完成阶段性工作后,都调用SignalAndWait方法阻塞自己,最后一个参与者线程调用SignalAndWait方法后会解除所有线程的阻塞。

如果你觉得本文对你还有一丝丝帮助,支持一下吧,总结提炼也要花很多精力呀,伤不起。。。

主要参考资料:

CLR Via C# 3 edition

你可能感兴趣的:(Monitor)