CLR 混合线程同步构造
“基元线程同步构造”讨论了基元用户模式和 内核模式线程同步构造。其他所有线程同步构造都基于它们而构建,而且一般都合并了用户模式 和 内核模式构造,我们称为混合线程同步构造。 没有竞争时 —— 用户模式,有竞争时—— 内核模式。
下面是一个混合线程同步锁的例子:
internal sealed class SimpleHybridLock: IDisposable{
// The Int32 is used by the primitive user•mode constructs (Interlocked methods) private Int32 m_waiters = 0;
// The AutoResetEvent is the primitive kernel•mode construct
private readonly AutoResetEvent m_waiterLock = new AutoResetEvent(false);
public void Enter() {
// Indicate that this thread wants the lock
if (Interlocked.Increment(ref m_waiters) == 1)
return; // Lock was free, no contention, just return
// Another thread has the lock (contention), make this thread wait
m_waiterLock.WaitOne(); // Bad performance hit here
// When WaitOne returns, this thread now has the lock
}
public void Leave() {
// This thread is releasing the lock
if (Interlocked.Decrement(ref m_waiters) == 0)
return; // No other threads are waiting, just return
// Other threads are waiting, wake 1 of them
m_waiterLock.Set(); // Bad performance hit here
}
public void Dispose() { m_waiterLock.Dispose(); }
}
SimpleHybridLock 包含两个字段:一个Int32,由基元用户模式的构造来操作;以及一个 AutoResetEvent,它是一个基元内核模式的构造。为了获得出色的性能,锁要尽量操作 Int32 ,尽量少操作 AutoResetEvent。
自旋、线程所有权和递归
由于转换为内核模式会造成巨大的性能损失,而且线程占有锁的时间通常都很短,所以为了提升应用程序的总体性能,可以让一个线程在用户模式中 “自旋”一小段时间,再让线程转换为内核模式。如果线程正在 等待的锁在线程“自旋”期间变得可用,就能避免内核模式的转换了。
此外,有的锁限制只能由获得锁的线程释放锁。有的锁允许当前拥有它的线程递归地拥有锁(多次拥有)。Mutex 锁就是这样的一个例子。可通过一些别致的逻辑构建支持自旋,线程所有权和递归的一个混合锁。Mutex 为了支持所有权和递归就要维护一些字段来实现这个功能。下面是实现了自旋、线程拥有权、递归的混合锁。
internal sealed class AnotherHybridLock : IDisposable {
// The Int32 is used by the primitive user•mode constructs (Interlocked methods)
private Int32 m_waiters = 0;
// The AutoResetEvent is the primitive kernel•mode construct
private AutoResetEvent m_waiterLock = new AutoResetEvent(false);
// This field controls spinning in an effort to improve performance
private Int32 m_spincount = 4000; // Arbitrarily chosen count
// These fields indicate which thread owns the lock and how many times it owns it
private Int32 m_owningThreadId = 0, m_recursion = 0;
public void Enter() {
// If calling thread already owns the lock, increment recursion count and return
Int32 threadId = Thread.CurrentThread.ManagedThreadId;
if (threadId == m_owningThreadId) { m_recursion++; return; }
// The calling thread doesn't own the lock, try to get it
SpinWait spinwait = new SpinWait();
for (Int32 spinCount = 0; spinCount < m_spincount; spinCount++) {
// If the lock was free, this thread got it; set some state and return
if (Interlocked.CompareExchange(ref m_waiters, 1, 0) == 0) goto GotLock;
// Black magic: give other threads a chance to run
// in hopes that the lock will be released
spinwait.SpinOnce();
}
// Spinning is over and the lock was still not obtained, try one more time
if (Interlocked.Increment(ref m_waiters) > 1) {
// Still contention, this thread must wait
m_waiterLock.WaitOne(); // Wait for the lock; performance hit
// When this thread wakes, it owns the lock; set some state and return
}
GotLock:
// When a thread gets the lock, we record its ID and
// indicate that the thread owns the lock once
m_owningThreadId = threadId; m_recursion = 1;
}
public void Leave() {
// If the calling thread doesn't own the lock, there is a bug
Int32 threadId = Thread.CurrentThread.ManagedThreadId;
if (threadId != m_owningThreadId)
throw new SynchronizationLockException("Lock not owned by calling thread");
// Decrement the recursion count. If this thread still owns the lock, just return
if (--m_recursion > 0) return;
m_owningThreadId = 0; // No thread owns the lock now
// If no other threads are waiting, just return
if (Interlocked.Decrement(ref m_waiters) == 0)
return;
// Other threads are waiting, wake 1 of them
m_waiterLock.Set(); // Bad performance hit here
}
public void Dispose() { m_waiterLock.Dispose(); }
}
可以看出,为锁添加了额外的行为之后,会增大它拥有的字段数量,进而增大内存消耗。代码也变得复杂了,而且这些代码必须执行,造成锁的性能下降。
FCL 中的混合构造
上面只是Jeffrey 给的混合构造的例子,下面介绍FCL 中的混合构造。FCl 中的混合构造通过一些别致的逻辑将你的线程保持在用户模式,从而增强应用程序的性能。有的混合构造直到首次有线程在一个构造上发生竞争时,才会创建内核模式的构造。
FCL 中的ManualResetEventSlim 类和 SemaphoreSlim 类
-
这两个构造的工作方式和对应的内核模式构造完全一致,只是它们都在用户模式中“自旋”,而且都推迟到发生第一次竞争时,才创建内核模式的构造。
-
它们的Wait 方法允许传递一个超时值 和一个 CancellationToken。下面展示了这些类。
public class ManualResetEventSlim : IDisposable {
public ManualResetEventSlim(Boolean initialState, Int32 spinCount);
public void Dispose();
public void Reset();
public void Set();
public Boolean Wait(Int32 millisecondsTimeout, CancellationToken cancellationToken);
public Boolean IsSet { get; }
public Int32 SpinCount { get; }
public WaitHandle WaitHandle { get; }
}
public class SemaphoreSlim : IDisposable {
public SemaphoreSlim(Int32 initialCount, Int32 maxCount);
public void Dispose();
public Int32 Release(Int32 releaseCount);
public Boolean Wait(Int32 millisecondsTimeout, CancellationToken cancellationToken);
// Special method for use with async and await (see Chapter 28)
public Task<Boolean> WaitAsync(Int32 millisecondsTimeout, CancellationToken
cancellationToken);
public Int32 CurrentCount { get; }
public WaitHandle AvailableWaitHandle { get; }
}
Monitor 类和同步块
最常用的混合型线程同步构造,它提供了支持自旋、线程所有权 和 递归的互斥锁。它资格老,C#有内建的关键字支持它,JIT 编译器对它知之甚详,而且CLR 自己也在代表你的应用程序使用它。Jeffrey 说这个构造存在许多问题,用它很容易让代码出现bug。我们来看看 Monitor 里到底是怎么实现的,Jeffery 为什么这么说 ?
堆中的每个对象都可以关联一个名为同步块的数据结构。同步块包含一些字段,这些字段的作用和前面提到的 AnotherHybridLock 类的字段相似。这些字段为内核对象、拥有线程的ID、递归计数、以及等待线程计数提供了相应的字段。
Monitor 是静态类,它的方法接受任何堆对象的引用。这些方法对指定对象的同步块中的字段进行操作。以下是Monitor 类最常用的方法:
public static class Monitor{
public static void Enter(Object obj);
public static void Exit(Object obj);
//还可指定尝试进入锁时的超时值(不常用):
public static Boolean TryEnter(Object obj, Int32 millisecondsTimeout, ref Boolean lockTaken);
}
显然,为堆中每个对象都关联一个同步块数据结构显得很浪费,尤其是考虑到大多数对象的同步块都从不使用。为节省内存,CLR 团队采用更为经济的方式提供刚才的描述。
它的工作原理是:
-
CLR 初始化时在堆中分配一个同步块数组。
-
每当一个对象在堆中创建的时候,都有两个额外的开销字段与它关联。
-
第一个是“类型对象指针”,包含类型的“类型对象”的内存地址。
-
第二个是“同步块索引”,包含同步块数组中的一个整数索引。
-
-
一个对象在构造时,它的同步块索引初始化为-1,表明不引用任何同步块。
-
调用Monitor.Enter 时,CLR 在数组中找到一个空白同步块,并设置对象的同步块索引,让它引用该同步块。同步块和对象是动态关联的。
-
调用Exit 时,会检查是否有其他任何线程正在等待使用对象的同步块。如果没有线程在等待它,Exit 将对象的同步块索引设为回 -1。获得自由的同步块将来可以和另一个对象关联。
下图展示了堆中的对象、它们的同步块索引以及CLR 的同步块数组元素之间的关系。
每个对象的同步块索引都隐式为公共的。
Monitor 被设计成一个静态类,所以存在许多问题,下面对这些额外的问题进行了总结。
-
1 变量能引用一个代理对象—— 前提是变量引用的Negev对象的类型派生自 System.MarshalRefObject 类。调用 Monitor的方法时,传递对代理对象的引用,锁定的是代理对象而不是代理引用的实际对象。
-
2 如果线程调用 Monitor.Enter,向它传递对类型对象的引用,而且这个类型对象是以“appDomain中立”的方式加载的,线程就会跨越进程中的所有 AppDomain在那个类型上获取锁。所以永远不要向 Monitor 的方法传递类型对象引用。
-
3 由于字符串可以留用,所以两个完全独立的代码端可能在不之情的情况下取得内存中的一个 String对象的引用。
-
4 跨越 AppDomain 边界传递字符串时,CLR 不创建字符串的副本,相反,它只是将对字符串的一个引用传给其他 AppDomain。所以 永远不要将 String 引用传给Monitor 的方法。
-
5 由于Monitor 的方法要获取一个 Object ,所以传递值类型会导致值类型被装箱,造成线程在已装箱对象上获取锁。每次调用 Monitor.Enter 都会在一个完全不同的对象上获取锁,造成完全无法实现线程同步。
-
6 向方法应用 [MethodImpl (MethodImplOptions.Synchronized )] 特性,会造成JIT 编译器用 Monitor.Enter 和 Monitor.Exit 调用包围方法的本机代码。永远不要使用这个特性。
-
7 调用类型的类型构造器时,CLR 要获取类型对象上的一个锁,确保只有一个线程初始化类型对象及其静态字段。同样的,这个类型可能以“AppDomain中立”的方式加载,所以会出问题。尽量避免使用类型构造器,或者至少保持它们的短小和简单。
由于开发人员习惯在一个方法中获取一个锁,做一些工作,然后释放锁,所以C# 语言通过 lock 关键字提供了一个简化的语法。
private void SomeMethod(){
lock(this){
//这里的代码拥有对数据的独占访问权
}
}
它等价于这样的写法:
private void SomeMethod(){
Boolean lockTaken = false;
try{
//这里可能发生异常(比如ThreadAbortException)..
Monitor.Enter(this, ref lockTaken);
//这里的代码有独占数据的访问权
}
finally{
if(lockTaken) Monitor.Exit(this);
}
}
这样写会有两个问题:1 在try 块中,如果在更改状态时发生异常,这个状态就会处于损坏状态。锁在 finally块中退出时,另一个线程可能开始操作损坏的状态。现如更好的解决办法是让应用程序挂起,而不是带着损坏的状态继续运行。2 进入和离开 try 块会影响方法的性能。有的JIT 编译器可能不会内联含有 try 块的方法,造成性能进一步下降。Jeffrey 建议是杜绝使用 C#的lock 语句。
Boolean lockTaken 变量可解决如下问题。
假定 一个线程进入 try 块,但在调用 Monitor.Enter 之前退出。现在,finally 块会得到调用,但它的代码不应该退锁。这时 finally 块中会判断 lockTaken 是否等于 true,如果不是就不会退锁。如果调用 Monitor.Enter 而且成功获得锁,lockTaken 就会将lockTaken 设为true 。SpinLock 结构也支持这个 lockTaken。
ReaderWriterLockSlim 类
互斥锁(SimpleSpinlock、SimpleWaitLock、SimpleHybridLock、AnotherHybridLock,Mutex或者 Monitor)当多个线程同时试图访问 被同步锁保护的数据时,如果这些线程都是试图读取数据,那就没必要设锁,如果有线程想修改数据,那就要有锁保护了。
ReaderWriterLockSlim 构造封装了解决这个问题的逻辑。具体的说,这个构造像下面这样控制线程。
-
一个线程向数据写入时,请求访问的其他所有线程都被阻塞
-
一个线程从数据读取时,请求读取的其他线程允许继续执行,但请求写入的线程任被阻塞。
-
向线程写入的线程结束后,要么解除一个写入线程的阻塞,是它能向数据写入,要么解除所有读取线程的阻塞,使它们能并发读取数据。如果没有线程被阻塞,锁就可以进入自由使用的状态,可供下一个reader 或 writer 线程获取。
-
从数据读取的所有线程结束后,一个writer 线程被解除阻塞,使它能向数据写入。如果没有线程被阻塞,锁就进入自由使用的状态,可提供下一个reader 或 writer 线程获取。
下面的代码演示了这个构造的用法:
internal sealed class Transaction : IDisposable {
private readonly ReaderWriterLockSlim m_lock =
new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion);
private DateTime m_timeOfLastTrans;
public void PerformTransaction() {
m_lock.EnterWriteLock();
// This code has exclusive access to the data...
m_timeOfLastTrans = DateTime.Now;
m_lock.ExitWriteLock();
}
public DateTime LastTransaction {
get {
m_lock.EnterReadLock();
// This code has shared access to the data...
DateTime temp = m_timeOfLastTrans;
m_lock.ExitReadLock();
return temp;
}
}
public void Dispose() { m_lock.Dispose(); }
}
这个构造有几个概念要留意。
-
首先 ReaderWriterLockSlim 的构造器允许传递一个 LockRecurionsPolicy 标志,定义如下:
public enum LockRecursionPolicy { NoRecursion, SupportsRecursion }
如果传递 SupportsRecursion 标志,锁就支持线程所有权和递归行为。但这样会对锁的性能有负面影响。所以建议使用 NoRecursion。
-
ReaderWriterLockSlim 类提供了一些额外的方法允许一个reader 线程升级为 writer 线程。以后,线程可以把自己降级回reader 线程。但这样做也会使锁的性能大打折扣。
OneManyLock 类
这个类是 Jeffery 自己创建的,在FCL 中找不到。它的速度比FCL 中的ReaderWriterLockSlim 类快。OneManyLock 类要么允许一个writer 线程访问,要么允许多个reader 线程访问。
Jeffery 的Power Threading 库免费提供给我们使用,这里是地址:
http://Wintellect.com/PowerThreading.aspx
http://Wintellect.com/Resource-Power-Collections-Library
CountdownEvent 类
System.Threading.CountdownEvent ,这个构造阻塞一个线程,直到它的内部计数器变成 0。从某种角度说,这个构造的行为和 Semaphore 的行为相反(Semaphore 是在计数为 0 时祖寺啊线程)。
Barrier 类
System.Threading.Barrier 构造用于解决一个非常稀有的问题,平时一般用不上。Barrier 控制的一系列线程需要并行工作,从而在一个算法的不同阶段推进。这个构造使每个线程完成了它自己的那一部分工作之后,必须停下来等待其他线程完成。
构造Barrier 时要告诉它有多少个线程准备参与工作,还可传递一个Action<Barrier> 委托来引用所有参与者完成一个阶段的工作后要调用的代码。
线程同步小结
Jeffery 建议尽量不要阻塞任何线程。执行异步计算或 I/O 操作时,将数据从一个线程交给另一个线程时,应避免多个线程同时访问数据。如果不能做到这一点,请尽量使用Volatile 和 Interlocked 的方法,因为它们的速度很快,而且绝不阻塞线程。
主要是以下两种情况阻塞线程:
-
线程模型很简单
阻塞线程虽然会牺牲一些资源和性能,但可顺序地写应用程序代码,无需使用回调方法。不过,C# 的异步方法功能现在提供了不阻塞线程的简化编程模型。
-
线程有专门用途
有的线程是特定任务专用的。最好的例子就是应用程序的主线程。如果应用程序的主线程没有阻塞,它最终会返回,造成整个进程的终止。其他例子还有应用程序的GUI 线程。Windows 要求一个窗口或控件总是由创建它的线程操作。因此,我们有时写代码阻塞一个GUI 线程,直到其他某个操作完成。
要避免阻塞线程,就不要刻意地为线程打上标签。为线程打上标签,其实是在告诫自己该线程不能做其他任何事情。相反,应该通过线程池将线程出租短暂时间。所以正确方式是一个线程池线程开始拼写检查,再改为语法检查,再代表一个客户端请求执行工作。
如果一定要阻塞线程,为了同步在不同 AppDomain 或进程中运行的线程,请使用内核对象构造。要在一系列操作中原子性地操纵状态,请使用带有私有字段的 Monitor类。另外可以使用 reader-writer 锁代替 Monitor。 reader-writer 锁通常比 Monitor 慢,但它们允许多个线程并发执行,提升总体性能,并将阻塞线程的几率降至最低。(可用Spinlock 代替 Monitor,SpinLock 虽然稍快一些,但 SpinLock 比较危险,他可能浪费 CPU时间。作者看来,它还没有快到非用不可的地步)。
此外,避免使用递归锁(尤其是递归的 reader-writer 锁),因为它们损害性能。但Monitor 是递归的,性能也不错。另外,不要在 finally 块中释放锁,因为进入和离开异常处理块(try)会招致性能损失。如果在更改状态时抛出异常,状态就会损坏,操作这个状态的其他线程会出现不可预料的行为。
如果写代码来占有锁,注意时间不要太长,否则会增大线程阻塞的几率。