.NET面试题系列[18] - 多线程同步

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了.NET面试题系列[18] - 多线程同步相关的知识,希望对你有一定的参考价值。

多线程:线程同步

同步基本概念

多个线程同时访问共享资源时,线程同步用于防止数据损坏或发生无法预知的结果。对于仅仅是读取或者多个线程不可能同时接触到数据的情况,则完全不需要进行同步。

线程同步通常是使用同步锁来实现的。通过实现各种各样构造的锁,保证在一个特定的时间内,只有一个或有限个线程进入关键代码段访问资源。当线程进入代码段时,它获得锁,或将信号量减少1,当线程离开时,它释放锁,或将信号量增加1。锁也可以看成是一个信号量。

线程同步既繁琐又容易出错,而且对锁的获取和释放是需要时间的。锁的开销具体要损耗多少时间,取决于选择的锁的种类。锁可以分为自旋锁,互斥锁和混合锁。自旋锁通常由用户模式构造实现,互斥锁则由内核模式构造实现。

如果多个线程同时访问只读数据(例如具有不可变性的数据,如字符串),则是没有任何问题的,不需要进行同步。在使用值类型时,因为它们总是会被复制,所以每个线程操作的都是它自己的副本。线程安全不意味着一定会有锁的出现。

自旋锁,互斥锁和锁的递归调用

自旋锁和互斥锁的区别类似轮询和回调。前者不停请求,后者等待通知。自旋锁与互斥锁类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁还是自旋锁,在任何时刻,最多只能有一个保持者,但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态,等待之后被唤醒。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。由此我们可以看出,自旋锁是一种比较低级的保护数据结构或代码片段的原始方式,这种锁可能存在两个问题:活锁和过多占用cpu资源。

自旋锁比较适用于锁使用者保持锁时间比较短的情况。正是由于自旋锁使用者一般保持锁时间非常短,因此选择自旋而不是睡眠是非常必要的。

互斥锁适用于锁使用者保持锁时间比较长的情况,它们会导致调用者睡眠。

另外格外注意一点:自旋锁不能递归使用。

某些互斥锁例如Mutex支持递归使用。如果一个锁可以递归使用,它需要维护一个整型变量,其意义为,拥有这个锁的线程拥有了它多少次。如果一个线程当前拥有一个递归锁,然后它又在这个锁上等待,那么它再次持有该锁,整型变量的值加一。当它释放锁时,整型变量的值减一,只有整型变量的值为0时,另一个线程才能够获得锁。你完全可以自己写一个支持递归的锁,而不是使用Mutex。

锁:基元构造(Primitive Constructs)

Windows的线程同步方式可分为2种,用户模式构造和内核模式构造。通过C#,我们还可以创造出混合构造,它吸收了上面两种方式的优点,但Windows不具备产生混合构造锁的能力。

内核模式构造是由Windows系统本身使用,内核对象进行调度协助的。内核对象是系统地址空间中的一个内存块,由系统创建维护。内核对象为内核所拥有,而不为进程所拥有,所以不同进程可以访问同一个内核对象(所以内核模式构造的锁可以跨进程同步), 如WaitHandle,信号量,互斥量等都是Windows专门用来帮助我们进行线程同步的内核对象。

用户模式构造是由特殊CPU指令来协调线程,通常用于进行原子操作。volatile实现就是一种,Interlocked也是。用户模式构造的速度要显著快于内核模式的构造,这是因为他们使用了特殊CPU指令来协调线程,协调是在硬件中发生的。

混合构造兼具用户模式和内核模式的特点。

用户模式构造(User Mode Constructs)

用户模式构造使用的是自旋锁。它利用特殊的CPU指令,实现原子操作,所以它的速度远快于内核模式构造。它的缺点是:当一个线程在一个以用户模式构造创建的锁(以及获得锁的线程)上阻塞了,Windows不会知道这个情况的发生(操作系统只知道内核模式构造的锁中发生的事情)。所以,操作系统会认为这个线程正在良好的运行,从而不会将属于它的时间片分给其他线程。

在极端情况下,如果这个被阻塞的线程永远拿不到锁,它将永远自旋下去(轮询锁的状态),从而浪费CPU资源,这种现象称为活锁。活锁既浪费CPU又浪费内存(因为这个悲剧的线程本身也占用一定的内存)。和死锁相比,死锁更好一些,因为它不会浪费CPU。

.NET中为我们提供了两种用户模式构造:

  1. Thread.VolatileRead 和 Thread.VolatileWrite:易失构造,它在包含一个简单数据类型的变量上执行原子性的读写操作。
  2. System.Threading.Interlocked:互锁构造,它在包含一个简单数据类型的变量上执行原子性的读写操作。

对于易失构造,C#提供了volatile关键字,确保该关键字修饰的字段在读或写时,是原子的,也就是说一次只能有一个线程对其进行读写。当然,也可以通过互锁构造修改字段,此时不需要volatile关键字,只需要调用Interlocked的方法来修改即可,它保证了操作是原子的。Interlocked虽然只提供了Add方法,但是我们也可以实现诸如乘除等其他方式对值进行更改,可以参考CLR via C#的Interlocked Anything模式这一节,这里就略过。

这两种构造仅会读或写一个字段,而这是一个耗时很短的操作。所以对这种情况,使用用户模式构造是合理的,因为阻塞的线程只会自旋很短一段的时间,之后就可以正常工作。使用内核模式反而会过于臃肿,光是维护信号量或Event构造,发送通知的代价就远远大于自旋了。

使用用户模式构造的例子

最常见的例子,便是对整型变量不断累加了。首先是没有使用锁的做法。这种做法得到的结果是不稳定的。

技术分享
number = 0;
            Stopwatch sw = Stopwatch.StartNew();

            Parallel.For(0, 100000, (i) =>
            {
                number++;
            });
            Console.WriteLine("Result: " + number);
            Console.WriteLine("Time is :{0} ms", sw.ElapsedMilliseconds);
View Code

使用用户模式构造(易失构造):首先引入一个volatile字段:

public static volatile int number2;

这种做法每次都能得到正确的结果100000。

技术分享
            number2 = 0;
            Parallel.For(0, 100000, (i) =>
            {
                var address = number2;
                Thread.VolatileWrite(ref address, 1);
            });
            Console.WriteLine("Result: " + number);
            Console.WriteLine("Time is :{0} ms", sw.ElapsedMilliseconds);
View Code

使用用户模式构造(互锁构造),当然也能得到正确的结果100000:

技术分享
sw.Restart();
            number = 0;

            Parallel.For(0, 100000, (i) =>
            {
                Interlocked.Add(ref number, 1);
            });
            Console.WriteLine("Result: " + number);
            Console.WriteLine("Time is :{0} ms", sw.ElapsedMilliseconds);
View Code

虽然每次的耗时并不相同,但这三种做法耗时并没有太大的差别。

实现自旋锁

实现自旋锁需要借助Interlocked.Exchange方法。它会判断一个变量是否等于某个值,如果不是,则将其修改为该值。

实现一个锁理论上只需要两个方法:获得锁和释放锁。

技术分享
    public class SimpleSpinLock
    {
        private int _flag;
        public void Enter()
        {
            //如果flag不等于1,则将它置为1并离开while循环
            //否则就一直在循环里面自旋,直到有一个线程把flag改成0为止
            while (Interlocked.Exchange(ref _flag, 1)!=0)
            {
               //性能优化代码
            }
        }

        public void Exit()
        {
            //离开关键代码段,将flag置为0
            Thread.VolatileWrite(ref _flag, 0);
        }
    }
View Code

如果两个线程同时调用Enter,Interlocked.Exchange会确保其中一个线程将flag的值从0变成1,然后,它发现flag的原始值是0,于是它退出while,离开Enter,进入关键代码段,继续执行其他代码(在下面的例子中,它会给number的值增加1)。另一个线程会将flag从1变成1。但是它发现flag的原始值是1。此时,它无法离开while,会不停的调用Exchange(开始旋转)直到第一个线程调用Exit。调用Exit之后flag的值就又变成0了,这将把其他旋转的线程中的某个幸运儿解放出来。

使用自旋锁:

技术分享
sw.Restart();
            SimpleSpinLock ssl = new SimpleSpinLock();
            number = 0;
            Parallel.For(0, 1000000, (i) =>
            {
                ssl.Enter();
                number++;
                ssl.Exit();
            });
            Console.WriteLine("Result: " + number);
            Console.WriteLine("Time is :{0} ms", sw.ElapsedMilliseconds);
View Code

我们仍然可以得到正确的结果。不过,这次我们的代码用了比较多的时间完成(相比之前几次,都不会超过100毫秒,而这次使用自己实现的锁,通常都好几百毫秒才完成)。.NET为我们提供了一个现成的自旋锁SpinLock,通过使用它,代码的耗时会少一些,也就比100毫秒多一点。

技术分享
SpinLock ss2 = new SpinLock();            
            number = 0;

            Parallel.For(0, 1000000, (i) =>
            {
                bool lockTaken = false;
                try
                {
                    ss2.Enter(ref lockTaken);
                    number++;
                }
                finally
                {
                    if (lockTaken)
                        ss2.Exit();
                }
            });

            Console.WriteLine("Result: " + number);
            Console.WriteLine("Time is :{0} ms", sw.ElapsedMilliseconds);
View Code

在我们自己实现的锁的Enter方法中,while循环内部什么都没做。但是.NET的自旋锁SpinLock,while循环内部做了一些时间片方面的优化(使用了一个叫做SpinWait的东东),这是它的性能好于我们自己实现的锁的原因。具体是如何优化的我也不清楚。

正如上面已经说过的,自旋锁只适合保护那些关键代码段执行的非常快的情形。当占有锁的线程优先级较低时,可能会发生活锁。此时占有锁的线程没有机会运行。

内核模式构造(Kernel Mode Constructs)

内核模式构造需要一个内核对象,以及操作系统自身的协作,它是通过调用操作系统的API来管理线程的,详细资料,可以参考《WINDOWS核心编程》这本书。

它比VolatileRead,VolatileWrite,Interlocked等用户模式的构造慢很多。这是因为在内核对象上调用的每个方法都会造成调用线程从托管代码(例如你的代码调用了WaitOne)转换到本地用户模式的代码,再转换为本地内核模式代码。然后,还要朝相反的方向一路返回。内核模式构造需要通过调用操作系统本身的API。

然而内核模式的构造也具有一些优点:

  1. 如果检测到资源竞争,使用内核模式的构造会令操作系统阻塞未能获取锁的线程,让它到等待队列去,而不是令它一直自旋下去,无谓的浪费处理器资源。当锁再次变得可用时,可以通过发送一个通知(例如Event构造中的Set)唤醒等待队列中的线程。
  2. 内核模式的构造可以同步不同进程中运行的线程。用户模式构造不是通过WaitHandle实现的,不能跨进程。
  3. 由于内核模式构造同时需要托管代码和本地代码,所以内核模式构造可以同步在本地和在托管代码中的线程(用户模式构造不会经本地代码,直接从托管代码跑到CPU指令)。
  4. 一个线程可以一直阻塞,直到一个线程集合中的所有内核对象全部可用,或部分可用。(WaitAll,WaitAny)用户模式构造不能模拟这种一等多的情景。
  5. 内核模式的构造上阻塞的一个线程可以指定一个超时值,如果过了这段时间,线程可以解除阻塞并执行其他任务。

所有的内核模式构造都可以看成是事件和信号量的某种特殊情况。事件构造可以看成是内核维护一个布尔型的内核对象,信号量构造(互斥体是信号量=1的情况)可以看成是内核维护一个整型的内核对象。所以,内核模式构造需要WaitHandle类型,它包装了一个内核对象。

内核模式构造适合:

1.  某个线程需要较长时间占用资源的情形

2.  跨进程同步,例如保证任何给定时刻,只允许程序的一个实例运行

通过WaitHandle操作内核对象

在Windows编程中,通过Windows API创建一个内核对象后,会返回一个句柄,句柄是每个进程句柄表的索引,而后可以拿到内核对象的指针、掩码、标示等。而WaitHandle抽象基类的作用是:包装了一个Windows内核对象的句柄,使得我们不需要直接和Windows API打交道。在内部,WaitHandle抽象基类拥有一个字段SafeWaitHandle,它就是Windows内核对象的句柄。

WaitHandle在线程同步时被所有线程共享。无论是事件构造还是信号量,它们都派生自WaitHandle类型。

WaitHandle提供了下列共同的静态方法:

  • WaitOne:阻塞调用线程,直到调用线程自己收到一个信号(通过Set方法,因为WaitHandle没有Set方法,所以一般用它的派生类AutoResetEvent或ManualResetEvent)。
  • WaitAny:需要传入一个WaitHandle数组,阻塞调用线程,直到WaitHandle数组中任意一个WaitHandle收到一个信号。如果你没有指定等待时间,则时间是无限长。
  • WaitAll:需要传入一个WaitHandle数组,阻塞调用线程,直到WaitHandle数组中所有WaitHandle收到信号。如果你没有指定等待时间,则时间是无限长。

这些方法会继续调用Windows中的对应API。

WaitHandle的派生类: 

WaitHandle

  |——EventWaitHandle                    事件构造

    |——AutoResetEvent

    |——ManualResetEvent

  |——Semaphore                         信号量构造

  |——Mutex                                 互斥体构造

Event构造

“Events are simply Boolean variables maintained by the kernel.”

Event构造维护一个布尔型的内核对象,它被所有线程共享。它如果为false,在事件上等待的线程就阻塞,否则就解除阻塞。Set方法将布尔对象置为true,Reset方法则置为false。

在调用了AutoResetEvent的Set方法之后,布尔对象置为true,等待队列(包括所有呼叫了WaitOne方法的线程)中的第一个线程被允许进入(不再是阻塞状态),之后,AutoResetEvent自动调用Reset,将布尔对象置为false,使得仅有一个线程解除阻塞。在调用了ManualResetEvent的Set方法之后,所有等待队列中的线程都解除阻塞。你必须自己调用Reset方法将布尔对象重新置为false。

如果有任何线程呼叫了方法WaitOne,呼叫线程将进入阻塞状态,并加入等待队列,直到有任何其他线程呼叫了方法Set,或者布尔对象本身就是true。Set就像打开门的动作一样,把等待队列的第一个成员放进门里来(ManualResetEvent的话则是等待队列中的所有成员),解除它的阻塞状态。如果布尔对象一开始就是true,则WaitOne的阻塞立即解除,然后线程继续运作,AutoResetEvent自动调用Reset将门关闭。

AutoResetEvent的合适应用场景为:一条线程开门,只解除一条线程阻塞的情况。

ManualResetEvent的合适应用场景为:一条线程开门,可以解除多条线程阻塞的情况。

CountdownEvent的合适应用场景为:多条线程全部完毕才可以(自动导致)开门,也就是一条线程等待多条线程的情况。

AutoResetEvent

AutoResetEvent就像一个插票的旋转门:插入一张票只能让一个人通过。在这里,Auto的意思是旋转门的行为是自动的(即通过之后,自动关门,下一个人必须要再插票才能通过)。当一个人(一条线程)到达旋转门之后,它必须等待/被阻塞(通过呼叫WaitOne方法,在门前等待),直到有另外一条线程通知他为止(通过呼叫Set方法),此时,门开,它才能得以通过旋转门。

每一个呼叫WaitOne方法的线程都会被记录,从而形成一个队列。当某个线程呼叫了Set之后,队列中的第一个线程被允许进入(不再是阻塞状态)。如果呼叫Set之后,队列中没有任何线程,则句柄将一直开着,等待有线程呼叫WaitOne方法。队列中没有任何线程时,多次呼叫Set,并不会导致多个线程可以一起通过旋转门,只有最后一个Set有用,之前的会被抵消。

技术分享
class BasicWaitHandle
        {
            //建立一个新的AutoResetEvent对象
            //如果参数为true,相当于立刻呼叫了set方法一次
            static EventWaitHandle _waitHandle = new AutoResetEvent(false);
 
            static void Main()
            {
                new Thread(Waiter).Start();
                Thread.Sleep(1000);                  // Pause for a second...
                _waitHandle.Set();                    // Wake up the Waiter.
            }

            static void Waiter()
            {
                Console.WriteLine("Waiting...");
                _waitHandle.WaitOne();                // Wait for notification
                Console.WriteLine("Notified");
            }
        }
View Code

如图所示。注意我们的AutoResetEvent的构造函数中,我们传入了false,这意味着一开始旋转门是关着的。所以当子线程呼叫WaitOne之后,只能一直等待(注意WaitOne不会改变布尔内核对象的值!如果内核对象为true,阻塞就解除了),直到主线程呼叫Set,就如同给子线程一个信号一般,子线程就离开阻塞状态,重新回到Running状态。

 技术分享

如果AutoResetEvent的构造函数中传入true,则意味着一开始门是开着的,此时,第一个呼叫WaitOne的线程不会被阻塞,它会直接进入旋转门,然后,根据AutoResetEvent的性质,它自动调用Reset,旋转门会关闭,第二个(以及后面所有)呼叫WaitOne的线程会被阻塞,这也就是锁的功能。所以,当我们使用AutoResetEvent实现锁时,要为构造函数中传入true,否则,这个锁一开始就不让任何人进入,也就不叫锁了。

WaitOne方法接受一个Timeout参数。如果该参数为0,则相当于测试目前的AutoResetEvent是否处于“开启状态”。

如果你用完了一个句柄,应该用Close方法将他丢弃。当然你也可以丢弃所有这个句柄的reference从而令垃圾收集器将其视为垃圾。

ManualResetEvent

ManualResetEvent如同一个普通的大门。呼叫Set方法将会打开大门,让所有之前呼叫过WaitOne的线程都得以通过。呼叫Reset关闭大门。ManualResetEventSlim作为其轻量级的版本,同样不能跨进程。但其的开门时间较ManualResetEvent短些,而且允许所有WaitOne的线程取消等待。

ManualResetEvent的合适应用场景为一条线程开门,可以解除多条线程阻塞的情况。

ManualResetEvent与AutoResetEvent的区别

ManualResetEvent也可以做到上文中模拟场景的效果。代码一模一样,除了要把事件体的类型改变为ManualResetEvent。

ManualResetEvent与AutoResetEvent的区别在于,当某个线程调用了Set之后,所有之前调用过WaitOne的线程都会恢复工作,相当于门开了之后一直不关,任何人都可以进来。此时,可以调用ManualResetEvent的Reset方法,重新把门关上,此时,如果等待队列还有线程,则它们要再等待某个线程调用Set。而AutoResetEvent中,当某个线程调用了Set之后,等待队列的第一个线程会恢复工作,并与此同时,自动调用Reset方法把门关上。

使用AutoResetEvent实现锁

注意事项:

1. 要实现IDisposable接口

2. 初始化时将参数设为true,会得到我们想要的效果。

3. 一般实现一个锁只需要实现进入和离开两个方法。进入时,旋转门自动关闭,阻塞其他所有也想进入的线程,离开时则提供一个信号供其他线程进入。

代码如下:

技术分享
class AutoResetEventLock : IDisposable
    {
        //initialize: true
        private readonly AutoResetEvent _are = new AutoResetEvent(true);

        public void Enter()
        {
            //The calling thread blocks others
            _are.WaitOne();
        }
        public void Exit()
        {
            //Now the first thread in waiting queue may come in
            _are.Set();
        }
        public void Dispose()
        {
            _are.Dispose();
        }
    }
View Code

测试。结果应当是预期的(数字逐渐增加)。用时大约300 - 400毫秒。比起用户模式构造,用时确实长了很多。

技术分享
int number = 0;

            Stopwatch sw = Stopwatch.StartNew();
            AutoResetEventLock l = new AutoResetEventLock();

            System.Threading.Tasks.Parallel.For(0, 100000, (i) =>
            {
                l.Enter();
                number++;
                l.Exit();
            });
            Console.WriteLine("Result: " + number);
            Console.WriteLine("Time is :{0} ms", sw.ElapsedMilliseconds);
            Console.ReadKey();
View Code

信号量(Semaphore)构造

“Semaphore are simply Int32 variables maintained by the kernel.”

信号量的历史十分久远。1965年荷兰科学家Dijkstra提出了信号量,作为一个卓有成效的进程同步工具(那时候还没有出现线程)。当然,信号量也适用于线程。信号量是内核维护的一个整型变量,所以也是内核对象。它允许最多n个线程在关键代码段中。互斥量则是n最大为1的信号量。和互斥量不同的是,任何一个在关键代码段中的线程都可以释放信号量(离开关键代码段)。这并不会对其他的线程造成影响,而由于容量不够而在外面排队的线程则得以进入。

使用信号量就是不断修改整型变量的过程。修改只有两种方式,称为V(又称signal())与P(wait())。V操作会增加信号量S的数值,P操作会减少它。信号量的值初始为n。当一个线程进入关键代码段时,通过P操作为信号量的值减一,当一个线程离开关键代码段时,通过V操作为信号量的值加一。当信号量为0时,在外面排队的线程就被阻塞,直到有线程离开关键代码段,所以信号量的值永远不会小于0。

V与P操作是历史术语,在C#中,FCL提供了Release和WaitOne。

信号量和互斥量都是内核对象,可以作用于多个进程。SemaphoreSlim是轻量级的信号量实现,于.NET 4.0中出现。它的释放和占有速度较快,但不能像互斥量一样作用于多个进程。

使用信号量实现锁

使用信号量实现锁十分简单。在此我就以信号量最大为1(实际上是一个互斥体)做例子。使用信号量实现锁和直接用Semaphore类基本没区别,所以通常直接使用C#提供的Semaphore类就可以了。

技术分享
public class SemaphoreLock : IDisposable
    {
        private readonly Semaphore s = new Semaphore(0, 1);

        public void Enter()
        {
            s.WaitOne();
        }
        public void Exit()
        {
            s.Release(1);
        }
        public void Dispose()
        {
            s.Dispose();
        }
    }
View Code

调用也是非常简单,我们的累加肯定会得到正确的值。耗时也是300-400毫秒左右。

技术分享
int number = 0;

            Stopwatch sw = Stopwatch.StartNew();
            SemaphoreLock s = new SemaphoreLock();
            System.Threading.Tasks.Parallel.For(0, 100000, (i) =>
            {
                s.Enter();
                number++;
                s.Exit();
            });
            Console.WriteLine("Result: " + number);
            Console.WriteLine("Time is :{0} ms", sw.ElapsedMilliseconds);
            Console.ReadKey();
View Code

互斥量(Mutex)构造

Mutex的工作方式和AutoResetEvent或者计数为1的信号量相似。因为这三种构造一次都只释放一个正在等待的线程。

互斥量也可以用于多个进程。对互斥量Mutex的进入和离开比lock慢一些(50倍长的时间)。加锁的方法是使用WaitOne(),解锁则是ReleaseMutex()。和锁一样,释放锁的对象必须是持有锁的对象。一个使用互斥量的典型场景是保证同一时间,只有一个程序的实例在运行。

我们可以直接使用C#的Mutex类,当然,自己用互斥的方式实现锁也很简单,在上一节,实际上我们实现的就是一个互斥量。一个必须要提的事情是,Mutex是支持递归的,所以如果你并不需要递归获得锁,不要使用Mutex,因为支持递归需要额外维护一些变量,这会损失性能。即使你需要递归锁,CLR via C#也推荐你自己实现一个(书中使用AutoResetEvent模拟了一个),实现递归锁只需要额外维护一个整型变量,以及当前拥有锁的线程ID即可,难度不大。

锁:混合构造(Hybrid Constructs)

混合线程同步构造合并了基元构造的用户模式和内核模式,吸取了它们的优点。在没有竞争的情况下,线程将会快速的进入关键代码段(就像用户模式的构造),如果存在竞争,阻塞线程的应当是操作系统内核,这样该线程将会睡眠而不是自旋

一个简单的混合锁

下面是一个混合锁的例子。这个锁包含一个整型变量,令其可以使用用户模式的构造来使线程快速获得锁,另外还借用了AutoResetEvent结构,使得线程在等待时睡眠而不是自旋。

技术分享
public class SimpleHybridLock : IDisposable{
        private int waiters = 0;
        private readonly AutoResetEvent are = new AutoResetEvent(false);

        public void Enter()
        {
            //没有争用时,将int变量增加1,从而获得锁
            //当大部分时候都没有争用时,不需要呼叫WaitOne,这是一个内核模式构造的方法,它会影响性能
            //相比之下,用户模式的原子操作速度快得多,这里吸收了用户模式的优点
            if(Interlocked.Increment(ref waiters) == 1) return;
            
            //发生争用时,阻塞当前线程,直到收到通知
            //阻塞而不是自旋,这里吸收了内核模式的优点
            are.WaitOne();
        }

        public void Exit()
        {
            //理由同上,这里就不重复了
            if (Interlocked.Decrement(ref waiters) == 0) return;
            are.Set();
        }

        public void Dispose()
        {
            are.Dispose();
        }
    }
View Code

调用Enter的第一个线程造成Interlocked.Increment在m_waiters字段上加1,使它的值变成1。这个线程发现以前有0个线程在等待这个锁,所以它从Enter返回。(获得了锁)这是如果另一个线程进入并调用Enter,它会将m_waiters递增到2,从而不满足if判断,故它会调用WaitOne阻塞自己(而不是自旋)。

调用Exit时,道理相同。如果拥有锁的进程调用Leave,发现m_waiters不是1,其会知道必然有至少一个线程由于自己拥有锁而被阻塞在外,它将唤醒一个线程。

使用混合锁叠加10万次消耗的时间大概为200-300毫秒,相比AutoResetEvent的300-400毫秒,它的时间缩短了大概四分之一。如果大部分时间都没有争用,那么AutoResetEvent的方法不会被调用,它也就不需要被初始化。将AutoResetEvent改为延迟初始化,会进一步增强性能。

使锁支持递归和自旋

有的锁支持递归调用。如果一个锁可以递归使用,它需要维护一个整型变量,其意义为,拥有这个锁的线程拥有了它多少次。如果一个线程当前拥有一个递归锁,然后它又在这个锁上等待,那么它再次持有该锁,整型变量的值加一。当它释放锁时,整型变量的值减一,只有整型变量的值为0时,另一个线程才能够获得锁。

令混合构造的锁支持自旋,主要是为了考虑关键代码段耗时很少的情景。此时,由于每次线程获得锁之后,只会在关键代码段工作很短的时间,这时其他线程呼叫WaitOne,转为内核模式,就会造成较大的性能损失。我们可以令其他线程自旋一段时间,尝试获得锁,通过牺牲一点CPU换来整体速度的提升。如果自旋时获得了锁,则不需要转为内核模式。如果自旋过后仍然没有获得锁,才转为内核模式。理想的情况如果总是可以在自旋时获得锁,整个混合锁的性能将会大大提升。

技术分享
public class SimpleMonitor : IDisposable
    {
        private int waiters = 0;
        private readonly AutoResetEvent are = new AutoResetEvent(false);

        private int currentThreadId;
        private int count;

        //自行指定的一个自旋时间
        private int spinningTime = 4000;

        public void Enter()
        {
            //如果调用线程已经拥有锁则递增递归次数
            if (currentThreadId == Thread.CurrentThread.ManagedThreadId)
            {
                count++;
                return;
            }

            var spinwait = new SpinWait();
            for(int spinCount = 0; spinCount < spinningTime; spinCount++)
            {
                //Interlocked.CompareExchange方法有三个参数,它比较第一个和第三个参数的值
                //如果它们相等,将第一个参数的值替换为第二个参数的值
                //并且返回第一个参数的原始值
                //所以下面的代码的意思是:如果waiters的初始值等于0,则将waiters替换成1
                //如果if成立,意味着waiters是从0变为1的
                //也就意味着当前没有人持有锁
                if (Interlocked.CompareExchange(ref waiters, 1, 0) == 0)
                {
                    currentThreadId = Thread.CurrentThread.ManagedThreadId;
                    count = 1;
                    return;
                }
                //自旋一段极短的时间
                spinwait.SpinOnce();
            }

            //自旋时间结束之后仍然没有获得锁,意味着假设“锁只会被线程持有很短的时间”失败
            //此时只能转为内核模式,不能再自旋下去了
            if (Interlocked.Increment(ref waiters) > 1)
                are.WaitOne();

            currentThreadId = Thread.CurrentThread.ManagedThreadId;
            count = 1;
        }

        public void Exit()
        {
            //调用的线程不拥有锁,表明存在一个bug
            if (Thread.CurrentThread.ManagedThreadId != currentThreadId)
                throw new SynchronizationLockException("调用的线程必须拥有锁");

            //减少递归锁的递归次数,如果这个线程多次拥有锁,则返回
            if (--count > 0) return;

            //重置拥有锁的线程id,现在没有线程拥有锁
            currentThreadId = 0;

            //理由同上,这里就不重复了
            if (Interlocked.Decrement(ref waiters) == 0) return;
            are.Set();
        }

        public void Dispose()
        {
            are.Dispose();
        }
    }
View Code

这个锁的性能大大好于AutoResetEvent,叠加十万次的耗时仅需要50-100毫秒。当每次我们叠加时,锁只会被线程拥有极短的一段时间,此时,我们改用自旋就基本规避了内核模式造成的性能损失。实际上,这差不多就是C#中Monitor的实现方式。

ManualResetEventSlim和SemaphoreSlim类

这两个类和非Slim版本的功能是相同的,除了它们使用了自旋策略延迟初始化它们的内核对象之外。所以,通常使用Slim版本来获得更好的性能。

Monitor类

Monitor是最常用的混合锁。lock是Monitor的语法糖。Monitor支持自旋和递归拥有锁。Monitor在加锁和解锁时,需要一个引用类型的对象,这是因为它借助了同步块索引来实现锁。

Monitor提供一个tryEnter方法,其可以输入一个Timeout时间。如果过了这个时间仍然没有获得锁,则将终止尝试获得锁。这是Lock所不具备的。所以Monitor相对于lock更加灵活。

Monitor的递归调用(Reentrancy)

Monitor支持递归调用。在这个例子中,程序不会出现任何问题,会运行下去,到最后,i等于10,我们一层层的离开关键代码段,终止程序。

技术分享
static void Main(string[] args)
        {
            DeadLockTest(20);
        }

        public static void DeadLockTest(int i)
        {
            object o = new object();
            lock (o)   //或者lock一个静态object变量
            {
                if (i > 10)
                {
                    Console.WriteLine(i--);
                    DeadLockTest(i);
                }
            }
        }
View Code

只有最外层的锁也被释放,另外一个进程才可以进入关键代码段。

同步块索引(Sync block index)

CLR初始化时,在堆上分配了一个同步块数组,可以认为这个数组拥有无限个成员。这些成员(同步块)储存了使锁支持递归的信息(持有次数,线程ID等)。Monitor通过将堆上的对象关联到同步块数组中的成员来实现同步和支持递归。

当在堆上新建一个对象时,分配空间给类型指针和同步块索引。同步块索引的值为-1,表示它目前没有和任何同步块数组的成员发生关系。当对象的同步块索引为-1时,任何线程都可以对其任意操作。

调用Monitor.Enter时,CLR检查同步块数组,并取出下一个可用的同步块(若没有会创建一个),然后将堆上的对象和这个同步块关联起来,此时对象的同步块索引就不再是-1了。这时,其他的线程就不能对它操作了。当Exit时,重置为-1,此时其他的线程才能使用对象。同步块数组可以被重复利用。

因此,锁对象必须是引用类型。但如果你这样写:

技术分享
Parallel.For(0, 100000, (i) =>
            {
                object o = new object();
                lock (o)
                {
                    number++;
                }               
            });
View Code

不好意思,这样写是有问题的。因为你每次用来加锁的对象都是新的,其实最后的结果就如同本没加锁,程序会极快的运行完,也就耗费几十毫秒,同步块数组中会有很多的同步块,程序每次的结果都不一样。那么正确的写法当然是把初始化拿出来就对了:

技术分享
object o = new object();
            Parallel.For(0, 100000, (i) =>
            {              
                lock (o)
                {
                    number++;
                }               
            });
View Code

此时同步块数组中只会有一个同步块。

锁对象选择 – 不要lock类型对象

下面的代码选择了使用方法所在的类型的类型对象SynchroThis作为锁对象。它的问题是,如果其他地方我们持有这个类型的一个实例,而且这个实例恰好和关键代码段的实例相同,则其他地方的实例有机会影响到正常使用者的使用。这是因为类型对象只有一个,它是全局的。

技术分享
class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("开始使用");
            SynchroThis st = new SynchroThis();

            // 模拟恶意的使用者
            // 其持有一个SynchroThis的实例,然后永远不放 
            // 去掉这行程序就可以正常工作
            Monitor.Enter(st);

            // 正常的使用者会受到恶意使用者的影响
            // 下面的代码完全正确,但永远无法进入关键代码段,因为其他地方持有的实例和这个实例相同 
            Thread thread = new Thread(st.Work);
            thread.Start();

            // 主线程永远阻塞
            thread.Join();

            // 程序不会执行到这里
            Console.WriteLine("使用结束");

            Console.ReadKey();
        }
    }

    public class SynchroThis
    {
        private int number;

        public void Work(object state)
        {
            lock (this)
            {
                Console.WriteLine("number现在的值为:{0}", number);
                number++;
                // 模拟做了其他工作
                Thread.Sleep(200);
                Console.WriteLine("number自增后值为:{0}", number);
            }
        }
    }
View Code

ReaderWriterLockSlim类

这个类比较适合读取数据内容的情景。它的构造像下面这样控制线程:

  • 一个线程向数据写入时,请求访问的其他所有线程都被阻塞
  • 一个线程读取数据时,请求读取的线程可以继续执行,请求写入的则被阻塞
  • 数据写入的一个线程结束后,要么解除另一个请求写入的线程阻塞,要么解除所有请求读取的线程的阻塞
  • 所有从数据读取的线程结束后,解除一个请求写入的线程阻塞,如果没有,则锁成为自由状态,无人持有

FCL中提供了一个ReaderWriterLock构造,早在1.0时就有了。而ReaderWriterLockSlim构造是对它的改进。

性能比较

如果关键代码段有且仅有原子操作,使用Interlocked最快,最灵活。

如果关键代码段有较多操作,首选Monitor,如果还要支持跨进程则首选SemaphoreSlim。在读写同步的情况下可以考虑ReaderWriterLockSlim。

CountDownEvent可以处理一条线程等待多条线程的情况。

永远不要使用:AutoResetEvent,ManualResetEvent,Semaphore,ReaderWriterLock。

 

种类

递归的

自旋的

性能概要

累加十万
次时间(毫秒)

Volatile

用户模式

Interlocked相比易失构造更灵活
(有Interlocked anything模式)

小于10

Interlocked

用户模式

原子操作优先考虑用户模式

小于10

AutoResetEvent

内核模式

考虑使用Monitor

200-400

ManualResetEvent

内核模式

考虑使用它的Slim版本

200-400

Semaphore

内核模式

考虑使用它的Slim版本

200-400

Mutex

内核模式

支持递归导致性能不佳
如果希望自己的锁支持递归,可以自己实现一个
跨进程时,使用SemaphoreSlim

200-400

Monitor

混合模式

支持

最常用的混合锁,除了原子操作,跨进程之外的首选

小于10

ReaderWriterLock

混合模式

N/A

使用它的Slim版本

不适用

ReaderWriterLockSlim

混合模式

N/A

N/A

为了加强读线程的运行速度
考虑使用(所有只读线程可以一起进入关键代码段)。如果全是写线程,则没有必要使用

不适用

CountDownEvent

混合模式

支持

内部使用了ManualResetEventSlim
适合一条线程等待多条线程的情形

不适用

ManualResetEventSlim

混合模式

支持

和普通混合锁相比,只有在第一次扫描到竞争时,
才会创建ManualResetEvent
会自旋很短的一段时间,规避内核模式的性能损失
适合多条线程等待一条线程的情形

20-50

SemaphoreSlim

混合模式

支持

跨进程时使用,模拟信号量时使用。

20-50

以上是关于.NET面试题系列[18] - 多线程同步的主要内容,如果未能解决你的问题,请参考以下文章

.NET面试题解析(07)-多线程编程与线程同步

多线程面试题系列:经典线程同步 关键段CS

.NET面试题系列[16] - 多线程概念

.NET面试题系列[17] - 多线程概念

秒杀多线程第一篇 多线程笔试面试题汇总

多线程面试题系列(16):多线程十大经典案例之一 双线程读写队列数据