混合线程同步构造

上一章讨论了基元用户模式和内核模式线程同步构造. 其他所有线程同步构造都基于它们而构建. 讨论了FCL自带的许多混合构造.

public static class Interlocked
{
  // return (++location)
  public static Int32 Increment(ref Int32 location);

  // return (--location)
  public static Int32 Decrement(ref Int32 location);

  // return (location1 += value)
  // 注意value可能是一个负数, 从而实现减法运算
  public static Int32 Add(ref Int32 location1, Int32 value);

  // Int32 old = location1; location1 = value; return old;
  public static Int32 Exchange(ref Int32 location1, Int32 value);

  // Int32 old = location1
  // if(location1 == comparand) location1 = value;
  // return old;
  public static Int32 CompareExchange(ref Int32 location1, Int32 value, Int32 comparand);
  ....
}
public class EventWaitHandle : WaitHandle
{
   public Boolean Set(); // 将Boolean设为true, 总是返回true
   public Boolean Reset(); // 将Boolean设为false, 总是返回true
}
public sealed class Semaphore : WaitHandle
{
   public Semaphore(Int32 initialCount, Int32 maximumCount);
   public Int32 Release(); // 调用 Release(1); 返回上一个计数
   public Int32 Release(Int32 releaseCount); // 返回上一个计数
}
public sealed class Mutex : WaitHandle
{
   public Mutex();
   public void ReleaseMutex();
}

一个简单的混合锁

// Hybrid 混合
public sealed class SimpleHybridLock : IDisposable
{
    // Int32 由基元用户模式构造(Interlocked的方法)使用
    private Int32 m_waiters = 0;

    // AutoResetEvent 时基元内核模式构造
    // 自动重置事件构造(内核模式构造)
    private readonly AutoResetEvent m_waiterLock = new AutoResetEvent(false);

    public void Enter()
    {
        // 这个线程想要获得锁
        // 用户模式构造
        if (Interlocked.Increment(ref m_waiters) == 1)
            return; // 锁可以自由使用,无竞争, 直接返回

        // 另一个线程拥有锁(发送竞争), 使这个线程等待
        m_waiterLock.WaitOne(); // 这里产生较大的性能影响
        // WaitOne返回后,这个线程拿到锁了
    }

    public void Leave()
    {
        // 这个线程准备释放锁
        if (Interlocked.Decrement(ref m_waiters) == 0)
            return; // 没有其他线程正在等待,直接返回

        // 有其他线程正在阻塞, 唤醒其中一个
        m_waiterLock.Set(); // 这里产生较大的性能影响
    }

    public void Dispose()
    {
        m_waiterLock.Dispose();//较大的性能影响
    }
}

为了获得出色的性能, 锁要尽量操作Int32, 少操作AutoResetEvent. 每次构造SimpleHybridLock对象就会创建AutoResetEventInt32对性能影响大的多. 多个线程同时访问锁时, 只有在第一次检测到竞争时才会创建AutoResetEvent, 这样就避免了性能损失.

调用Enter的第一个线程造成Interlocked.Incrementm_waiters字段上加1, 使它的值变成1. 这个线程发现以前有零个线程正在等待这个锁, 所以线程从它的Enter调用中返回. 如果另一个线程介入并调用Enter, 这个线程将m_waiters递增到2, 发现锁在另一个线程里, 所以这个线程会使用AutoResetEventWaitOne方法, 从而阻塞自身. 调用WaitOne造成线程的代码转变成内核模式的代码, 反正线程都要停止, 花费点时间来停止也不算太坏. 这样的好处就是不会因为在CPU上自旋而浪费CPU时间.

Leave方法. 一个线程调用Leave时, 会调用Interlocked.Decrementm_waiters字段上减1, 如果它的值现在是0, 表明没有其他线程在调用Enter时发生阻塞, 调用Leave的线程可以直接返回. 这个速度很快. 如果不为0, 就意味着存在竞争, 这个线程必须唤醒一个(只能一个)阻塞的线程. 唤醒线程是通过m_waiterLock.Set()实现, 线程必须转换成内核模式代码, 再转换回来. AutoResetEvent确保只有一个阻塞的线程被唤醒. AutoResetEvent上阻塞的其他所有线程会继续阻塞,直到新的,解除了阻塞的线程最终调用Leave.

自旋,线程所有权和递归

由于转换为内核模式会造成巨大的性能损失, 而且线程占有锁的时间通常都很短, 所以为了提升应用程序的总体性能, 可以让一个线程在用户模式中自旋一小段时间,再让线程转换为内核模式. 如果在线程正在等待的锁在线程自旋期间变得可用, 就能避免向内核模式的转换.

此外, 有的锁限制只能由获得锁的线程释放锁, 有的锁允许当前拥有它的线程递归地拥有锁(多次拥有). Mutex锁就是这样的一个例子. 可通过一些别致的逻辑构建支持自旋,线程所有权和递归的一个混合锁:

public sealed class AnotherHybridLock : IDisposable
{
    // Int32 由基元用户模式构造Interlocked方法使用
    private Int32 m_waiters = 0;

    // AutoResetEvent 是基元内核模式构造
    private AutoResetEvent m_waiterLock = new AutoResetEvent(false);

    // 这个字段控制自旋, 希望能提升性能
    private Int32 m_spincount = 4000; // 用来计数

    // 这些字段指出哪个线程拥有锁,以及拥有了多少次
    private Int32 m_owningThreadId = 0, m_recursion = 0;

    public void Enter()
    {
        // 如果调用线程已经拥有锁, 递增递归计数并返回
        Int32 threadId = Thread.CurrentThread.ManagedThreadId;
        if (threadId == m_owningThreadId)
        {
            m_recursion++;
            return;
        }

        // 调用线程不拥有锁, 尝试获取它
        SpinWait spinwait = new SpinWait();
        for (Int32 spinCount = 0; spinCount < m_spincount; spinCount++)
        {
            // 如果可以自由使用, 这个线程就获得它,设置一些状态并返回
            if (Interlocked.CompareExchange(ref m_waiters, 1, 0) == 0) goto GotLock;

            // 黑科技: 给其他线程运行的机会,希望锁会被释放
            spinwait.SpinOnce();
        }

        // 自旋结束, 锁仍未获得,再试一次
        if (Interlocked.Increment(ref m_waiters) > 1)
        {
            // 仍然是竞态条件,这个线程必须阻塞
            m_waiterLock.WaitOne(); // 等待锁, 性能有损失
            // 等这个线程醒来,它拥有锁, 设置一些状态并返回
        }

        GotLock:
        // 一个线程获得锁时, 我们记录它的ID,并指出线程拥有锁1次
        m_owningThreadId = threadId;
        m_recursion      = 1;
    }

    public void Leave()
    {
        // 如果调用线程不拥有锁, 表明存在bug
        Int32 threadId = Thread.CurrentThread.ManagedThreadId;
        if (threadId != m_owningThreadId)
            throw new SynchronizationLockException("Lock not owned by calling thread");

        // 递减递归计数,
        if (--m_recursion > 0) return;

        m_owningThreadId = 0; // 现在没有线程拥有锁

        // 如果没有其他线程, 直接返回
        if (Interlocked.Decrement(ref m_waiters) == 0)
            return;

        // 其他线程正在等待, 唤醒一个
        m_waiterLock.Set(); // 有性能损失
    }

    public void Dispose()
    {
        m_waiterLock.Dispose();
    }
}

可以看出, 为锁添加额外的行为只会, 会增大它拥有的字段数量, 进而增大内存消耗. 而且这些代码必须执行,造成锁的性能下降. 这里与之前几个版本锁进行再次比较:

AnotherHybridLock性能不如SimpleHybridLock. 这是因为需要额外的逻辑和错误检查来管理线程所有权和递归行为.

FCL中的混合构造

FCL自带了许多混合构造, 他们通过一些别致的逻辑将你的线程保持在用户模式, 从而增强应用程序的性能. 有的混合构造直到首次有线程在一个构造上发生竞争时, 才会创建内核模式的构造. 许多构造还支持使用一个CancellationToken参数, 使一个线程强迫解除正在构造上等待的其他线程的阻塞.

ManualResetEventSlim类和SemaphoreSlim类

System.Threading.ManualResetEventSlimSystem.Threading.SemaphoreSlim这两个类。这两个类的构造方式和对应的内核模式构造完全一致,只是他们都在用户模式中“自旋”,而且都推迟到第一次竞争时,才创建内核模式的构造。它们的Wait方法运行传递一个CancellationToken和一个超时值。

public class ManualResetEventSlim : IDisposable
{
    public ManualResetEventSlim(bool initialState, int spinCount);
    public void Dispose();
    public void Reset();
    public void Set();
    public bool Wait(int millisecondsTimeout, CancellationToken cancellationToken);

    public bool IsSet { get; }
    public int SpinCount { get; }
    public WaitHandle WaitHandle { get; }
}
public class SemaphoreSlim : IDisposable
{
    public SemaphoreSlim(int initialCount, int maxCount);
    public void Dispose();
    public int Release(int releaseCount);
    public bool Wait(int millisecondsTimeout, CancellationToken cancellationToken);

    public Task<bool> WaitAsync(int millisecondsTimeout, CancellationToken cancellationToken);

    public int CurrentCount { get; }
    public WaitHandle AvailableWaitHandle { get; }
}

Monitor类和同步块

最常用的混合型线程构造就是Monitor类了,它提供了支持自旋,线程所有权和递归的互斥锁。 C#有内建的关键词支持它, JIT编译器对它知之甚详, 而且CLR自己也在代表你的应用程序使用它. 但是Monitor实际上是存在许多问题的。

堆中的每个对象都可关联一个名为同步块的数据结构,同步块包含字段,它为内核对象、拥有线程的ID、递归计数以及线程等待计数提供了相应的字段。Monitor是静态类,它的方法接受对任何堆对象的引用。这些方法对指定对象的同步块的字段进行操作。以下是Monitor最常用的方法:

public static class Monitor
{
  public static void Enter(object obj);
  public static void Exit(object obj);

  // 还可知道你个尝试进入锁时的超时值(不常用)
  public static bool TryEnter(object obj, int millisecondsTimeout);

  public static void Enter(object obj, ref bool lockTaken);
  public static void TryEnter(object obj, int millisecondsTimeout, ref bool lockTaken);
}

如果为每个堆中对象都关联一个同步数据结构显得很浪费, 尤其是大多数对象的同步块从不使用. 为了节省内存, CLR团队采用一种更经济的方式, 原理是:

CLR初始化时在堆中分配一个同步块数组. 一个对象在构造时, 它的同步块索引初始化为-1, 表明不使用任何同步块, 然后调用Monitor.Enter时, CLR在数组中找到一个空白同步块, 并设置对象的同步块索引,让它引用该同步块. 也就是说, 对象和同步块是动态关联的. 调用Exit时, 会检查是否有其他任何线程正在等待使用对象的同步块. 如果没有线程等待,同步块就自由了, Exit将对象的同步块索引设为返回-1, 自由的同步块可以和另一个对象关联.

类型对象有两个开销成员: 同步块索引和类型对象指针.

下面是Monitor原本的使用方法:

internal sealed class Transaction
{
    private DateTime m_timeOfLastTrans;

    public void PerformTransaction()
    {
        Monitor.Enter(this);
        //以下代码拥有对数据的独占访问权
        m_timeOfLastTrans = DateTime.Now;
        Monitor.Exit(this);
    }

    public DateTime LasTransaction
    {
        get
        {
            Monitor.Enter(this);
            //以下代码拥有对数据的独占访问权
            DateTime temp = m_timeOfLastTrans;
            Monitor.Exit(this);
            return temp;
        }
    }
}

表面上看起来很简单,但实际却存在许多问题。现在的问题是,每个对象的同步块索引隐式为公共的,下面的代码演示了可能造成的影响:

static void DoSomeMethod()
{
    var t = new Transaction();
    // 这个线程获取对象的公共锁
    Monitor.Enter(t);

    //让线程池线程显示LastTransaction时间
    //注意:线程池线程会阻塞,知道DoSomeMethod调用了Monitor.Exit
    ThreadPool.QueueUserWorkItem(o =>
    {
        // 这里会调用LasTransaction的get方法,并Monitor.Enter(this);
        // 再次去获取这个公共锁, 因为这个锁已经被拿走了
        // 这里只能等到主线程执行Monitor.Exit(t);之后才能拿.
        Console.WriteLine(t.LastTransaction);
    });
    //这里执行一些其他代码
    Monitor.Exit(t);
}

DoSomeMethod的线程调用Monitor.Enter获取到了对象的公共锁,线程池线程查询LastTransaction属性时,这个属性也调用Monitor.Enter来获取同一个锁, 造成线程池阻塞, 直到DoSomeMethod执行了Monitor.Exit(t);, 用调试器可发现线程池线程在LastTransaction属性内部阻塞. 但很难判断是另外哪个线程拥有锁.

因此, 我的建议是始终坚持使用私有锁.

要解决这个问题的话,需要使用私有锁,把Transaction改成如下就可以解决上面的问题:

internal sealed class Transaction
{
    private DateTime m_timeOfLastTrans;

    // 现在每个Transaction对象都有私有锁
    private readonly Object m_lock = new Object();

    public void PerformTransaction()
    {
        Monitor.Enter(m_lock);
        //以下代码拥有对数据的独占访问权
        m_timeOfLastTrans = DateTime.Now;
        Monitor.Exit(m_lock);
    }

    public DateTime LasTransaction
    {
        get
        {
            // 之前是this 公开的锁
            Monitor.Enter(m_lock);
            //以下代码拥有对数据的独占访问权
            DateTime temp = m_timeOfLastTrans;
            Monitor.Exit(m_lock);
            return temp;
        }
    }
}

如果Transaction的成员时静态的, 只需要将只读m_lock字段也变成静态字段,即可确保静态成员的线程安全性.

通过这个讨论, 一个明显的结论是: Monitor根本就不该实现成静态类, 它应该像其他所有同步构造那样实现. 也就是说是一个可以实例化并在上面调用实例方法的类.

  • 变量能引用一个代理对象——–前提是变量引用的那个对象的类型派生自MarshalByObject类. 调用Monitor的方法时, 传递对代理对象的引用, 锁定的是代理对象而不是代理引用的实际对象.
  • 如果线程调用Monitor.Enter, 向它传递对类型对象的引用, 而且这个类型对象是以AppDomain中立的方式加载的, 线程就会跨越进程中的所有AppDomain在那个类型上获取锁, 这是CLR一个已知的BUG. 破坏了AppDomain本应提供的隔离能力. 建议: 永远都不要想Monitor的方法传递类型的对象引用.
  • 由于字符串可以留用, 所以两个完全独立的代码可能在不知情的情况下获取对内存中的一个String对象的引用. 如果将这个String对象传给Monitor的方法, 两个独立的代码段现在就回在不知情的情况下以同步方式执行.
  • 跨越AppDomain边界传递字符串时, CLR不创建字符串的副本; 相反, 它值是将对字符串的一个引用传给其他AppDomain. String对象也和其他对象一样关联了一个同步索引块, 这个索引时可变的. 使不同AppDomain中的线程在不知情的情况下开始同步, 这是CLR的AppDomain隔离存在的另一个bug. 建议: 永远都不要想Monitor的方法传递String.
  • 由于Monitor的方法要获取一个Object,所以传递值类型会导致值类型被装箱, 造成线程在已装箱对象上获取锁. 每次调用Monitor.Enter都会在一个完全不同的对象上获取锁, 造成完全无法实现线程同步.
  • 向方法应用[MethodImpl(MethodImplOptions.Synchronized)]特性, 会造成JIT编译器用Monitor.EnterMonitor.Exit调用包围方法的本机代码. 如果是实例方法, 会将this传给Monitor的这些方法. 锁定隐式公共的锁. 如果方法是静态的, 对类型的类型对象的引用会传给这些方法. 造成锁定AppDomain中立的类型. 我的建议永远不要使用这种特性.
  • 调用类型的类型构造器时, CLR要获取类型对象上的一个锁, 确保只有一个线程初始化类型对象及其静态字段. 同样的, 这个类型可能以AppDomain中立的方式加载,所以会出问题.
    • 加入类型构造器的代码进入死循环, 进程中的所有AppDomain都无法使用该类型.
    • 建议尽量避免使用类型构造器

有一种程序集可以被多个AppDomain使用,这种程序集叫做”AppDomain中立”的程序集

再看下面这种情况,由于C#提供了lock关键字来提供一个简化的语法,如果像下面这样写:

public void DoSomeMethod()
{
    lock(this)
    {
        //...这里的代码拥有对数据的独占访问权
    }
}

等价于以下写法:

public void DomSomeMethod()
{
    Boolean lockTaken=false;
    try
    {
        //这里可能发生异常
        Monitor.Enter(this,ref lockTaken);
        //这里的代码拥有对数据的独占访问权
    }finally{
        if(lockTaken) Monitor.Exit(this);
    }
}

第一个问题是,C#团队认为他们在finally块中调用Monitor.Exit是帮了你一个大忙,因为这样一样,总是可以确保锁得以释放。然而这只是他们一厢情愿的想法,如果在Try块更改状态时候发生异常,那么另一个线程很可能继续操作损坏的数据,这样的结果难以预料,同时还有可能引发安全隐患。第二个问题是进入和离开try会发生性能影响。所以在代码中应该不要使用lock语句。建议杜绝使用C#的lock语句.

讨论以下lockTaken变量, 下面是这个变量试图解决的问题. 假定一个线程进入try块, 但在调用Minitor.Enter之前推出. 现在finally块会得以调用, 但它的代码不应退出锁. 所以就用到了lockTaken, 初始化为false, 调用了Minitor.Enter之后会设为true. 这样finally块就知道到底要不要调用Monitor.Exit.

ReaderWriterLockSlim类

如果所有线程都希望以只读方式访问数据, 就没必要阻塞它们, 应该允许他们并发地访问数据. 另一方面, 如果一个线程希望修改数据,这个线程就需要对数据的独占式访问.System.Threading.ReaderWriterLockSlim封装了这种功能的逻辑。

  1. 一个线程向数据写入时,访问请求的其它所有线程都被阻塞。
  2. 一个线程从数据读取时,请求读取的其它线程允许继续执行,但请求写入的线程仍被阻塞。
  3. 向数据写入的线程结束后,要么解除一个写入线程(writer)的阻塞,使它能向数据写入。要么解除所有读取线程(reader)的阻塞,使它们能够并发访问数据。如果没有线程被阻塞,锁就进入可自由使用的状态,可供下一个readerwriter线程获取。
  4. 从数据读取的所有线程结束后,一个writer线程被解除阻塞,使其能够向数据写入。如果没有线程被阻塞,锁就进入可自由使用的状态,可供下一个writer或reader线程使用。
public class ReaderWriterLockSlim : IDisposable
{
  public ReaderWriterLockSlim(LockRecursionPolicy recursionPolicy);

  public void EnterReadLock();
  public bool TryEnterReadLock(int millisecondsTimeout);
  public void ExitWriteLock();

  public void EnterWriteLock();
  public bool TryEnterWriteLock(int millisecondsTimeout);
  public void ExitWriteLock();
  // 大多数应用程序从不查询以下任何属性
  public bool IsReadLockHeld { get; }
  public bool IsWriteLockHeld { get; }
  public int CurrentReadCount { get; }
  public int RecursiveReadCount { get; }
  public int RecursiveWriteCount { get; }
  public int WaitingReadCount { get; }
  public int WaitingWriteCount { get; }
  public LockRecursionPolicy RecursionPolicy { get; }
}

用法:

internal sealed class Transaction : IDisposable
{
    // 构造ReaderWriterLockSlim实例,
    // NoRecursion 不支持递归加锁
    // SupportsRecursion 支持线程所有权和递归行为
    private readonly ReaderWriterLockSlim m_lock = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion);
    private          DateTime             m_timeOfLastTrans;

    public void PerformTransaction()
    {
        m_lock.EnterWriteLock();
        //以下代码拥有对数据的独占访问权
        m_timeOfLastTrans = DateTime.Now;
        m_lock.ExitWriteLock();
    }

    public DateTime LastTransaction
    {
        get
        {
            m_lock.EnterReadLock();
            DateTime temp = m_timeOfLastTrans;
            m_lock.ExitReadLock();
            return temp;
        }
    }

    public void Dispose()
    {
        m_lock.Dispose();
    }
}

ReaderWriterLockSlim的构造器允许传递LockRecursionPolicy标志, 是否支持线程所有权和递归行为. 建议总是想构造器传递NoRecursion, 那些行为对锁的性能有负面影响. reader-writer锁支持线程所有权和递归的代价非常高昂, 事实上, 为了线程安全的方式维护所有这些信息, 内部要使用一个互斥的自旋锁.

OneManyLock类

作者自己实现了一个OneManyLock类, 速度比ReaderWriterLockSlim快约1.7倍.

CountdownEvent类

System.Threading.CountdownEvent构造使用ManualResetEventSlim对象。这个构造阻塞一个线程,直到它的内部计数器变成0。从某种角度来说,这个构造的行为和Semaphore的行为相反(Semaphore是在计数为0时阻塞线程)。下面列出这个类的一些成员

public class CountdownEvent : IDisposable
{
    public CountdownEvent(int initialCount);
    public void Dispose();
    public void Reset();
    public void AddCount();
    public bool TryAddCount();
    public bool Signal();
    public void Wait();
    public int CurrentCount { get; }
    public bool IsSet { get; }
}

一旦一个CountdownEventCurrentCount为0时,它就不能再更改了,CountdownEvent为0时,addCount方法会抛出一个InvalidOperationException异常。如果CurrentCount为0,TryAddCount直接返回false.

Barrier类

平时一般用不上. System.Threading.Barrier控制一些列线程需要并行工作,从而在一个算法的不同阶段推进。

线程同步构造小结

代码尽量不要阻塞任何线程, 执行异步计算或I/O操作时, 将数据从一个线程交给另一个线程时, 避免多个线程同时访问数据. 如果不能做到, 请尽量使用VolatileInterlocked的方法. 因为它们速度很快,绝不阻塞线程. 只能操作简单类型, 可以像Interlocked Anything模式那样在这些类型上执行丰富的操作.

  • 线程模型很简单
    • 阻塞线程虽然会牺牲一些资源和性能, 但可顺序地写应用程序代码, 无需使用回调方法. 不过C#的异步方法功能现在提供了不阻塞线程的简化编程模型.
  • 线程又专门用途

为了同步在不同AppDomain或进程中运行的线程, 请使用内核对象构造.

要在一系列操作中原子性地操纵状态,请使用带有私有字段的Monitor类. 可以用reader-writer锁代替Monitor. 虽然前者慢, 但它们允许多个线程并发执行. 提升了总体性能.

对于计算限制的工作, 可以使用任务避免使用大量线程同步构造. 每个任务都关联一个或多个延续任务, 某个操作完成后, 这些任务将通过某个线程池线程继续执行.

对于I/O限制的工作, 调用各种XxxAsync方法将造成你的代码在I/O操作完成后继续. 这其实类似于任务的延续任务.