CLR 混合线程同步构造

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。数据结构

 

自旋、线程全部权和递归并发

因为转换为内核模式会形成巨大的性能损失,并且线程占有锁的时间一般都很短,因此为了提高应用程序的整体性能,可让一个线程在用户模式中 “自旋”一小段时间,再让线程转换为内核模式。若是线程正在 等待的锁在线程“自旋”期间变得可用,就能避免内核模式的转换了。app

此外,有的锁限制只能由得到锁的线程释放锁。有的锁容许当前拥有它的线程递归地拥有锁(屡次拥有)。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(); }
}

能够看出,为锁添加了额外的行为以后,会增大它拥有的字段数量,进而增大内存消耗。代码也变得复杂了,并且这些代码必须执行,形成锁的性能降低。async

 

FCL 中的混合构造性能

上面只是Jeffrey 给的混合构造的例子,下面介绍FCL 中的混合构造。FCl 中的混合构造经过一些别致的逻辑将你的线程保持在用户模式,从而加强应用程序的性能。有的混合构造直到首次有线程在一个构造上发生竞争时,才会建立内核模式的构造。this

 

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)会招致性能损失。若是在更改状态时抛出异常,状态就会损坏,操做这个状态的其余线程会出现不可预料的行为。

若是写代码来占有锁,注意时间不要太长,不然会增大线程阻塞的概率。

相关文章
相关标签/搜索