返回

基元线程同步构造

28章展示了如何通过不同的线程执行异步函数的不同部分, 可能有两个不同的线程访问相同的变量和数据, 但根据异步函数的实现方式, 不可能有两个线程同时访问相同的数据. 所以在代码访问异步函数中包含的数据时不需要线程同步.

线程同步存在许多问题, 第一个问题是比较繁琐,容易写错. 必须标识出所有可能由多个线程同时访问的数据,必须用额外的代码将其包围起来, 并获取和释放一个线程同步锁. 没办法检查是否正确添加了所有锁定代码, 只能在CPU数量尽量多的电脑上进行大量压力测试, 被检测出来的几率越大.

锁的作用: 确保一次只有一个线程访问资源. 但这也是一个问题. 阻塞一个线程会造成更多的线程被创建.

第二个问题是会损害性能. 锁的获取和释放.

可以试着用值类型,因为它们总被复制. 每个线程操作的都是它自己的副本.

类库和线程安全

FCL保证所有静态方法都是线程安全的. 不保证实例方法是线程安全的.

使一个方法线程安全, 并不是说它一定要在内部获取一个线程同步锁. 线程安全的方法意味着在两个不同线程试图同时访问数据时, 数据不会被破坏.

如果线程公开实例对象的引用, 也就是把它放到一个静态字段中, 把它作为状态实参传给一个ThreadPool.QueueUserWorkItemTask, 那么在多个线程可能同时进行非只读访问的前提下, 就需要线程同步.

如果实例方法的目的是协调线程, 则实例方法应该是线程安全的.

基元用户模式和内核模式构造

基元primitive 是指可以在代码中使用的最简单的构造. 有两种基元构造: 用户模式(user-mode)内核模式(kernel-mode) .

应尽量使用基元用户模式构造, 它们的速度显著快于内核模式构造. 因为它们使用了特殊的CPU指令来协调线程(协调是在硬件中发生的,所以才这么快).

在用户模式下运行的线程可能被系统抢占。所以也可以用内核模式构造,因为线程通过内核模式的构造获取其它线程拥有的资源时,Windows会阻塞线程以避免它浪费CPU时间。当资源变得可用时,Windows会恢复线程,允许它访问资源。然而线程从用户模式切换到内核模式(或相反)会招致巨大的性能损失。

对于在一个构造上等待的线程,如果占有构造的这个线程不释放它,前者就可能一直阻塞。构造是用户模式的构造情况下,线程会一直在一个CPU上运行,称为“活锁”。如果是内核模式的构造,线程会一直阻塞,称为“死锁”。死锁优于活锁,因为活锁既浪费CPU时间,又浪费内存,而死锁只浪费内存。

我理想的构造应兼具两者的长处, 也就是说, 在没什么竞争的情况下, 这个构造应该快而且不会阻塞(就像用户模式的构造). 但是如果存在堆构造的竞争, 我希望它被操作系统内核阻塞. 像这样的构造确实存在, 称为混合构造. 将在第30章讲解.

CLR的许多线程同步构造实际只是Win32线程同步构造的一些面向对象的类包装器.

毕竟CLR线程就是Window线程.

用户模式构造

CLR保证对以下数据类型的变量的读写是原子性的:BooleanCharS(Byte),U(Int16),U(Int32),U(IntPtr),Single以及引用类型。例如进行如下操作:

Int32 x = 0;
x = 0x01234567;

x变量会一次性从(原子性)0x00000000变成0x01234567。另一个线程不可能看到处于中间状态的值。

假设x是Int64,那么当一个线程执行以下代码时:

x = 0x0123456789abcdef;

另一个线程可能查询x,并得到值0x0123456700000000或0x0000000089abcdef值,因为读取写入操作不是原子性的。这称为一次torn read.

torn read : 一次读取不能读完, 在机器级别上,要分两个MOV指令才能读完

基元用户模式构造 就在于规划好这些原子性数据的读取/写入操作的时间。 这些构造还可强子对(U)Int64和Double类型的变量进行原子性的,规划好了时间的访问.

两种基元用户模式线程同步构造:

  • 易变构造(volatile construct)

    在特定的时间, 它在包含一个简单数据类型的变量上执行原子性的读写操作.

  • 互锁构造(interlocked construct)

    在特定的时间, 它在包含一个简单数据类型的变量上执行原子性的读写操作.

所有易变和互锁构造都要求传递对包含简单数据类型的一个变量的引用(内存地址).

易变构造

在讲易变构造之前,得先讲一个问题,就是代码优化的问题。

之前我们讲过C#编译器,JIT编译器,CPU都可能会优化代码,从多线程的角度看, 我们的意图并不一定能得到保留. 下例演示了优化之后,程序的工作方式和我们预想的有出入:

internal static class StrangeBehavior
{
    // 将这个字段标记成volatile可修正问题
    private static Boolean s_stopWorker = false;

    public static void Go()
    {
        Console.WriteLine("Main: letting worker run for 5 seconds");
        Thread t = new Thread(Worker);
        t.Start();
        Thread.Sleep(100);
        s_stopWorker = true;
        Console.WriteLine("Main: waiting for worker to stop");
        t.Join();
        Environment.Exit(0);
    }

    private static void Worker(Object o)
    {
        Int32 x = 0;
        while (!s_stopWorker) x++;
        Console.WriteLine("Worker: stopped when x={0}", x);
    }
}

private Int32 m_flag  = 0;
private Int32 m_value = 0;

// 这个方法由一个线程执行
public void Thread1()
{
    // 注意: 以下代码可以按相反的顺序执行
    m_value = 5;
    m_flag  = 1;
}

// 这个方法由另一个线程执行
public void Thread2()
{
    // 注意: m_value可能先于m_flag读取
    if (m_flag == 1) Console.WriteLine(m_value);
}

静态System.Threading.Volatile类提供了两个静态方法, 如下所示:

public static void Volatile
{
  public static void Write(ref Int32 locatin, Int32 value);
  public static Int32 Read(ref Int32 location);
}

这两个方法比较特殊,它们会禁止C#编译器,JIT编译器和CPU平常执行的一些优化。

  • Volatile.Wirte方法强迫location中的值在调用时写入. 此外, 按照编码顺序, 之前的加载和存储操作必须在调用Volatile.Wirte之前发生.
  • Volatile.Read方法强迫location中的值在调用时读取. 此外, 按照编码顺序, 之后的加载和存储操作必须在调用Volatile.Read之后发生.
// This method is executed by one thread
public void Thread1()
{
    //在将1写入m_flag之前, 必须先将5写入m_value
    m_value = 5;
    Volatile.Write(ref m_flag, 1);
}

// This method is executed by another thread
public void Thread2()
{
    // m_value必然在读取m_flag之后读取
    if (Volatile.Read(ref m_flag) == 1)
        Console.WriteLine(m_value);
}

规则: 当线程通过共享内存相互通信时, 调用Volatile.Wirte来写入最后一个值, 调用Volatile.Read来读取第一个值.

C#对易变字段的支持

如何正确调用上述两个方法是程序员头疼的问题之一, 为了简化编程, C#提供了volatile关键字. 可以用于以下任何类型的静态或实例字段: BooleanCharS(Byte),U(Int16),U(Int32),U(IntPtr),Single, 还可以应用于引用类型的字段, 以及基础类型为S(Byte),U(Int16),U(Int32)的任何枚举字段.

JIT编译器确保对易变字段的所有访问都是以易变读取或写入的方式执行. 不需要显式调用Volatile.ReadVolatile.Write方法, 并且此关键字告诉C#和JIT编译器不将字段缓存到CPU的寄存器中,确保字段的所有读写操作都在RAM中进行.

internal sealed class ThreadsSharingDataV3
   {
       private volatile Int32 m_flag  = 0;
       private          Int32 m_value = 0;

       // This method is executed by one thread
       public void Thread1()
       {
           // Note: 5 must be written to m_value before 1 is written to m_flag
           m_value = 5;
           m_flag  = 1;
       }

       // This method is executed by another thread
       public void Thread2()
       {
           // Note: m_value must be read after m_flag is read
           if (m_flag == 1)
               Console.WriteLine(m_value);
       }
   }

然而作者却表示并不喜欢volatile关键字,因为出现上述所说的情况的概率很低,并且volatile禁止优化后对性能会有影响。且C#不支持以传引用的方式传递volatile变量给某个函数。

实现简单的自旋锁

Interlocked.Exchange方法将一个存储位置设为指定值, 并返回该存储位置的原始值.

internal struct SimpleSpinLock
{
    private Int32 m_ResourceInUse; // 0=false (默认), 1=true

    public void Enter()
    {
        while (true)
        {
            // 总是将资源设为"正在使用"
            // 只有从未使用->正在使用( 从0到1 ) 才会 return
            // 从1-1不会返回, 继续自旋
            if (Interlocked.Exchange(ref m_ResourceInUse, 1) == 0) return;
            // "黑科技"
            Leave();
        }
    }

    public void Leave()
    {
        // 一次只有一个线程才能进入这里访问资源
        Volatile.Write(ref m_ResourceInUse, 0);
    }
}

SimpleSpinLock实现很简单, 如果两个线程同时调用Enter,那么Interlocked.Exchange会确保一个线程将m_ResourceInUse0变为1, Exchange并返回原始值, 如果是0, 这个线程返回继续执行, 否则不会返回一直while判断. 造成自旋.

第一个线程完成对SomeResource对象的字段的处理之后会调用Leave, Leave内部调用Volatile.Writem_ResourceInUse改为0. 这就使正在自旋的线程能够将m_ResourceInUse从0设为1, 终于能返回.

这就是线程同步锁的一个简单实现, 这种锁最大的问题在于, 在存在对锁竞争的前提下,会造成线程自旋. 这个自旋会浪费CPU时间, 阻止CPU做其他更有用的工作, 因此, 自选锁只应用于保护哪些执行非常快的代码区域.

自选锁一般不再单CPU上使用. 占有锁的线程不能快速释放锁, 如果占有锁的线程的优先级低于想要获取锁的线程(自旋线程), 占有锁的线程可能根本没办法运行, 造成活锁,一直浪费CPU和内存. 因此对于正在使用自选锁的线程, 应该禁止像这样的优先级提升.

为了解决这些问题, 许多自选锁内部都有一些额外的逻辑. 我将这称为黑科技. FCL提供了一个名为System.Threading.SpinWait的结构, 它封装了人们关于这种黑科技的最新研究成果.

FCL还包含一个System.Threading.SpinLock结构, 和前面代码中的SimpleSpinLock自选锁类相似, 只是使用了SpinWait结构来增强性能. SpinLock还提供了超时支持. 而且都是值类型.意味着他们都是轻量级的,内存友好的对象.

在线程处理种引入延迟

黑科技 旨在希望获得资源的线程暂停执行, 使当前拥有资源的线程能执行它的代码并让出资源. 为此, SpinWait结构内部调用Thread的静态Sleep,YieldSpinWait方法.

线程可以告诉系统它在指定的时间内不想被调度, 这是调用Thread的静态Sleep方法来实现的. 这个方导致线程在指定时间内挂起. 调用Sleep允许线程自愿放弃它的时间片的剩余部分, 指定的时间里不被调度. 但如果你希望一个线程睡100毫秒, 那么会睡眠大致那么长时间, 但也有可能会多睡眠几秒,甚至几分钟的时间, 因为Window不是实时操作系统,你的线程可能在正确的时间唤醒, 取决于系统中正在发生的别的事情.

SleepmillisecondsTimeout参数传递Infinite 也就是-1,这告诉系统永远不调度线程, 这没什么意义, 更好的做法是让线程退出. 回收它的栈和内核对象.

SleepmillisecondsTimeout参数传递0, 告诉系统放弃当前时间片的剩余部分, 强迫系统调度另一个线程. 但系统可能重新调度刚才调用了Sleep的线程(如果没有相同或者更高优先级的其他可调度线程就会发生这样的情况).

线程可以要求window在当前CPU上调度另一个线程, 这是通过ThreadYield方法来实现的. 如果Windows发现有另一个线程准备好在当前处理器上运行, Yield就会返回true, 调用Yield的线程会提前结束它的时间片. 所选线程得以运行一个时间片. 然后调用Yield的线程被再次调度, 开始用一个全新的时间片运行. 如果Windows发现没有其他线程准备在当前处理器上运行, Yield就会返回false,调用Yield的线程继续运行它的时间片.

Yiled方法旨在使”饥饿”状态的,具有相等或更低优先级的线程有机会运行. 调用Yiled的效果介于调用Thread.Sleep(0)Thread.Sleep(1)之间. Thread.Sleep(0)不允许较低优先级的线程运行, 而Thread.Sleep(1)总是强迫进行上下文切换. 并且总是强迫线程睡眠超过1毫秒的时间(因为内部系统的计时器的解析度的问题).

Interlocked Anything模式

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);
  ....
}

许多人在查看Interlocked方法时, 都好奇为什么不创建一组更丰富的Interlocked方法, 使它们适用于更广泛的情形. 例如提供Multiple, Divide, Minium,Maximum,And,Or,Xor等.

虽然Interlocked没有提供这些方法, 但一个已知的模式允许使用Interlocked.CompareExchange方法以原子方式在一个Int32上执行任何操作, 事实上, 由于Interlocked.CompareExchange提供了其他重载版本, 能操作Int64,Single,Double,Objcet和泛型引用类型. 所以该模式适合所有这些类型.

该模式类似于在修改数据库记录时使用的乐观并发模式:

// 传入两个值, 判断哪个值大
public static Int32 Maximum(ref Int32 target, Int32 value)
{
    Int32 currenVal = target, startVal, desiredVal;

    // 不要在循环中访问目标(target), 除非是想改变它时另一个线程也在动它
    do {
        // 记录这一次循环迭代的起始值
        startVal = currenVal;

        // 基于startVal和value计算desiredVal
        desiredVal = Math.Max(startVal, value);

        // 注意, 这里线程可能被抢占, 所以以下代码不是原子性的
        // if(target == startVal) target = desiredVal;

        // 应该使用原子性的CompareExchange方法
        // 它返回在target在(可能)被方法修改之前的值
        // 判断target和startVal(target旧值)是否相等,  
        // 如果相等返回desiredVal(说明值没有被其他线程改变),
        // 不相等返回target(新值) (说明值被其他线程改变),
        currenVal = Interlocked.CompareExchange(ref target, desiredVal, startVal);

        // 如果target的值在这一次循环迭代中被其他线程改变,就重复  
    } while (startVal != currenVal);

    // 返回最大值
    return desiredVal;
}
  • 进入方法后, currenVal被初始化为开始执行前的target值.

  • 然后,循环内部, startVal被初始化为同一个值. 可以用startVal执行你希望的任何操作.

  • 最终得到一个结果, 放入desiredVal

    当这个操作进行时, 其他线程可能改变target, 虽然几率很小, 但仍有可能发生.

    如果真的发生, desiredVal是旧的startVal值所计算得出的,而不是新值target.

    这时旧不应该更改target.

  • 我们用Interlocked.CompareExchange方法确保在没有其他线程更改target的值的前提下将target的值更改为desiredVal

    该方法验证target值和startVal是否匹配.

    • 如果值没有改变, CompareExchange, 就把target更改为desiredVal中的新值.
    • 如果值被别的线程改变了, CompareExchange就不改变target(新)值.
    • 将方法target(新)返回值放入currenVal.
  • 比较currenValstartVal

    如果相等, 则说明没有其他线程更改target, 此时target包含了desiredVal新值.while循环不再继续.

    如果不相等, 代表其他线程更改了target, target需要重新循环进行下一次迭代和相同操作.

内核模式构造

内核模式比用户模式慢,这个是可以预见的,因为线程要从托管代码转为本机用户模式代码,再转为内核模式代码,然后原路返回,也就了解为什么慢了。

但是之前也介绍过了,内核模式也具备用户模式所不具备的优点:

  • 内核模式的构造检测到一个资源上的竞争,windows会阻塞输掉的线程,使他不会像之前介绍的用户模式那样“自旋”(也就是那个不断循环的鬼),这样也就不会一直占着一个CPU了,浪费资源。
  • 内核模式的构造可实现本机托管线程相互之间的同步
  • 内核模式的构造可同步在同一台机器的不同进程中运行的线程。
  • 内核模式的构造可应用安全性设置,防止未经授权的帐户访问它们。
  • 线程可一直阻塞,直到集合中所有内核模式构造可用,或直到集合中的任何内核模式构造可用
  • 在内核模式的构造上阻塞的线程可指定超时值;指定时间内访问不到希望的资源,线程就可以解除阻塞并执行任务。

事件和信号量是两种基元内核模式线程同步构造. 至于互斥体什么的则是在这两者基础上建立而来的。

System.Threading命名空间提供了一个抽象基类WaitHandle。这个简单的类唯一的作用就是包装一个Windows内核对象句柄。

类层次继承结构如下:

  • WaitHandle
    • EventWaitHandle
      • AutoResetEvent
      • ManualResetEvent
    • Semaphore (信号量)
    • Mutex (互斥体)

WaitHandle基类内部有一个SafeWaitHandle字段,它容纳一个Win32内核对象句柄。 这个字段是在构造一个具体的WaitHandle派生类时初始化的, 在一个内核模式的构造上调用的每个方法都代表一个完整的内存栅栏.

内存栅栏: 是表明调用这个方法之前的任何变量写入都必须在这个方法调用之前发生. 而调用之后的任何变量读取都必须在这个调用之后发生.

WaitHandle公开方法:

这些方法有几点需要注意:

  • 可以调用WaitHandle.WaitOne方法让调用线程等待底层内核对象收到信号, 这个方法在内部调用WIN32 WaitForSingleObjectEx函数. 如果对象收到信号, WaitOne就返回true,超时则返回false.
  • 静态方法WaitAll让调用线程等待WaitHandle[]中指定的所有内核对象都收到信号, 才返回true.
  • 静态方法WaitAny方法让调用线程等待WaitHandle[]中指定的任何对象收到信号. 返回的Int32是与收到信号的内核对象对应的数组元素索引.
  • 在传给WaitAny和WaitAll方法的数组中, 包含的元素数不能超过64个, 否则会抛出NotSupportedException异常.
  • 强烈反对显式调用Dispose. 让GC去完成清理工作.

内核模式的构造一个常见用途是创造在任何只允许它的一个实例运行的应用程序.

QQ截图20190930103701
QQ截图20190930103701

Event构造

EventHandle(Event构造),事件实际上就是由内核维护的Boolean变量。事件为false在事件上等待的线程就阻塞, 为true就解除阻塞

有两种事件,即自动重置事件(AutoResetEvent)和手动重置事件(ManualResetEvent)。区别就在于是否在解除第一个线程的阻塞后,将事件自动重置为false, 造成其余线程继续阻塞. 而当手动重置事件为true时, 它解除正在等待它的所有线程的阻塞, 因为内核不将事件自动重置回false, 需要在代码中将事件手动重置回false.

public class EventWaitHandle : WaitHandle
{
   public Boolean Set(); // 将Boolean设为true, 总是返回true
   public Boolean Reset(); // 将Boolean设为false, 总是返回true
}

public sealed class AutoResetEvent : EventWaitHandle
{
   public AutoResetEvent(Boolean initialState);
}

public sealed class ManualResetEvent : EventWaitHandle
{
   public ManualResetEvent(Boolean initialState);
}

用自动重置事件写个锁示例如下:

private sealed class SimpleWaitLock : IDisposable
{
    private readonly AutoResetEvent m_available;

    public SimpleWaitLock()
    {
        m_available = new AutoResetEvent(true); // 最开始时自由可用
    }

    public void Enter()
    {
        // 在内核中阻塞, 直到资源可用
        // 发生资源竞争, 没有竞争赢的线程会阻塞
        m_available.WaitOne();
    }

    public void Leave()
    {
        // 让另一个线程访问资源
        m_available.Set();
    }

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

// 对比自旋锁
internal struct SimpleSpinLock
{
    private Int32 m_ResourceInUse; // 0=false (默认), 1=true

    public void Enter()
    {
        while (true)
        {
            // 总是将资源设为 "正在使用"
            // 只有从未使用->正在使用( 从0到1 ) 才会 return
            // 从1-1不会返回, 继续自旋
            if (Interlocked.Exchange(ref m_ResourceInUse, 1) == 0) return;
            // "黑科技"
        }
    }

    public void Leave()
    {
        // 一次只有一个线程才能进入这里访问资源
        Volatile.Write(ref m_ResourceInUse, 0);
    }
}

可采取和使用SimpleSpinLock时完全一样的方式使用这个SimpleWaitLock. 事实上,外部行为是完全相同的; 不过两个锁的性能截然不同.

  • 锁上面没有竞争的时候,SimpleWaitLockSimpleSpinLock慢得多. 因为WaitLock的Enter和Leave方法的每一个调用都强迫调用线程从托管代码转换为内核代码, 再转换回来. 这是不好的地方.
  • 存在竞争的时候, 输掉的线程会被内核阻塞. 不会在那边自旋(浪费CPU时间), 这是好的地方.

线程同步能避免尽量避免, 如果一定要进行线程同步, 就尽量使用用户模式的构造. 内核模式会慢很多很多.

Semaphore构造

Semaphore的英文就是信号量,其实是由内核维护的Int32变量。信号量为0时,在信号量上等待的线程阻塞,信号量大于0时接触阻塞。信号量上等待的线解除阻塞时,信号量自动减1.

public sealed class Semaphore : WaitHandle
{
   public Semaphore(Int32 initialCount, Int32 maximumCount);
   public Int32 Release(); // 调用 Release(1); 返回上一个计数
   public Int32 Release(Int32 releaseCount); // 返回上一个计数
}

下面总结一下这三种内核模式基元的行为.

  • 多个线程在一个自动重置事件上等待时, 设置事件只导致一个线程被解除阻塞.
  • 多个线程在一个手动重置事件上等待时, 设置事件导致所有线程被解除阻塞.
  • 多个线程在一个信号量上等待时, 释放信号量导致releaseCount个线程被解除阻塞

因此自动重置事件在行为上和最大计数为1的信号量非常相似. 两者区别在在一个自动重置时间上连续多次调用Set, 同时仍然只有一个线程解除阻塞. 相反, 在一个信号量上连续多次调用Release,会导致它的计数超过最大计数, 这是Release会抛出SemaphoreFullException.

用信号量重新实现SimpleWaitLock:

/// <summary>
/// 简单的阻塞锁
/// </summary>
class SimpleWaitLock
{
    private readonly Semaphore m_ResourceInUse;

    public SimpleWaitLock(Int32 maxCount)
    {
        m_ResourceInUse = new Semaphore(maxCount, maxCount);
    }

    public void Enter()
    {
        //阻塞内核,直到资源可用
        m_ResourceInUse.WaitOne();
    }

    public void Leave()
    {
        //解除当前线程阻塞,让另外2个线程访问资源
        m_ResourceInUse.Release(2);
    }
    public void Dispose()
    {
        m_ResourceInUse.Close();
    }
}

Mutex构造

Mutex的中文就是互斥体。代表了一个互斥的锁。 它的工作方式和AutoResetEvent(或者计数为1的Semaphore)相似, 三者都是一次只释放一个正在等待的线程.

public sealed class Mutex : WaitHandle
{
   public Mutex();
   public void ReleaseMutex();
}

互斥体有一个额外的逻辑,Mutex会记录下线程的ID值,如果释放的时候不是这个线程释放的,那么就不会释放掉,并且还会抛ApplicationException异常。 另外拥有Mutex的线程因为任何原因而终止, 在Mutex上等待的某个线程会因为抛出AbandonedMuteException异常而被唤醒. 该异常通常会成为未处理异常, 从而终止整个进程.

互斥体实际上在维护一个递归计数,一个线程当前拥有一个Mutex,而后该线程再次在Mutex等待,那么此计数就会递增,而线程调用ReleaseMutex会导致递减,只有计数递减为0,那么这个线程才会解除阻塞。另一个线程才会称为该Mutex的所有者.

Mutex对象需要额外的内存来容纳那些记录下来的ID值和计数信息,并且锁也会变得更慢了。所以很多人避免用Mutex对象。

通常, 当一个方法获取了一个锁, 然后调用也需要锁的另一个方法, 就需要一个递归锁.

public class SomeClass
{
     private readonly Mutex m_lock = new Mutex();
     public void Method1()
     {
         m_lock.WaitOne();
         Method2();//递归获取锁
         m_lock.ReleaseMutex();
     }
     public void Method2()
     {
         m_lock.WaitOne();
         /*做点什么*/
         m_lock.ReleaseMutex();
     }
}

上述代码中, 使用SomeClass对象的代码可以调用Method1,它获取Mutex,执行一些线程安全的操作, 然后调用Method2, 它也执行一些线程安全的操作. 由于Mutex对象支持递归, 所以线程会获取两次锁, 然后释放它两次.

如果SomeClass使用一个AutoResetEvent而不是Mutex,线程在调用Method2WaitOne方法时会阻塞,从而死锁.

如果需要递归锁, 可以使用AutoResetEvent来简单的创建一个:

/// <summary>
/// 事件构造实现的递归锁,效率比Mutex高很多
/// </summary>
class RecursiveAutoResetEvent : IDisposable
{
    private  AutoResetEvent m_lock=new AutoResetEvent(true);
    private Int32 m_owningThreadId = 0;
    private Int32 m_lockCount = 0;

    public void Enter()
    {
        // 获取当前线程ID
        Int32 currentThreadId = Thread.CurrentThread.ManagedThreadId;
        // 如果调用线程拥有锁, 就递增递归计数
        if (m_owningThreadId == currentThreadId)
        {
            m_lockCount++;
            return;
        }
        // 调用线程不拥有锁,等待它
        m_lock.WaitOne();

        // 调用线程现在拥有了锁, 初始化拥有线程的ID和递归计数
        m_owningThreadId = currentThreadId;
        m_lockCount = 1;
    }

    public void Leave()
    {
        //获取当前线程ID
        Int32 currentThreadId = Thread.CurrentThread.ManagedThreadId;
        // 如果调用线程不拥有锁, 就出错了
        if (m_owningThreadId != currentThreadId)
            throw new InvalidOperationException();
        // 从递归计数中减1
        // 如果递归计数为0, 表名没有线程拥有锁
        if (--m_lockCount == 0)
        {
            m_owningThreadId = 0;
            // 唤醒一个正在等待的线程
            m_lock.Set();
        }
    }
    public void Dispose()
    {
        m_lock.Dispose();
    }
}

虽然RecursiveAutoResetEvent类的行为和Mutex类完全一样, 但在一个线程试图递归获取锁时, RecursiveAutoResetEvent的性能会好得多, 因为现在跟踪线程所有权和递归的都是托管代码. 只有在第一次获取AutoResetEvent或者最后把它放弃给其他线程时, 才需要从托管代码转换为内核代码.

Licensed under CC BY-NC-SA 4.0
0