在上一节我们已经讨论了使用Lock来保证并发程序的一致性,Lock锁是借助了Monitor类的功能。本节将详细的介绍Monitor类,以及如何通过Monitor类的成员函数实现并行程序的一致性。


1.Monitor类介绍

根据微软的说法,C#中的监视器类提供了一种同步对象访问的机制。让我们简化上面的定义。简而言之,我们可以说,像锁一样,我们也可以使用这个监视器类来保护多线程环境中的共享资源免受并发访问。这可以通过获取对象上的独占锁来完成,以便在任何给定时间点只有一个线程可以进入关键部分。

Monitor 类是一个静态类,属于 System.Threading




多线程实时目标检测 多线程monitor_多线程实时目标检测


解释一下Monitor类的成员函数:

  1. Enter():当我们调用 Monitor 类的 Enter 方法时,它会在指定对象上获取一个独占锁。这也标志着关键部分的开始或共享资源的开始。
  2. Exit():当调用 Monitor 类的 Exit 方法时,它会释放指定对象上的锁。这也标志着关键部分的结束或受锁定对象保护的共享资源的结束。
  3. Pules():当调用 Monitor 类的 Pulse 方法时,它会向等待队列中的线程发送锁定对象状态更改的信号。
  4. Wait():当调用 Monitor 类的 Wait 方法时,它会释放对象上的锁并阻止当前线程,直到它重新获取锁。
  5. PulesAll():当从监视器类调用 PulseAll 方法时,它会将信号发送到锁定对象状态更改的所有等待线程。
  6. TryEnter():当我们调用 Monitor 类的 TryEnter 方法时,它会尝试获取指定对象上的独占锁。

2.Enter和Exit的基本用法

Enter和Exit的基本用法如下:


多线程实时目标检测 多线程monitor_c#_02


通俗一点来说,当某个线程使用Enter函数后,在执行到Exit前,这之间的代码都被会被锁住,Exit执行后,其它线程可以重新调用Enter进入,也就是Monitor类中有一把钥匙,谁调用Enter时,钥匙就分给这个进程,执行完推出时,钥匙重新回到Monitor类,然后钥匙可以分给其它线程。

现在看一个例子:

internal class MonitorBasicUasage
    {
        public static readonly object locker=new object();
        public static void Test()
        {
            Thread[] threads=new Thread[3];
            for(int i=0; i<threads.Length; i++)
            {
                threads[i] = new Thread(Print)
                {
                    Name = "Child Thread " + i
                };
            }
            Array.ForEach(threads,t=>t.Start());    
            Console.ReadLine();
        }
        public static void Print()
        {
            Console.WriteLine(Thread.CurrentThread.Name + " Trying to enter into the critical section");
            try
            {
                Monitor.Enter(locker);
                Console.WriteLine(Thread.CurrentThread.Name + " Entered into the critical section");
                for (int i = 0; i < 5; i++)
                {
                    Thread.Sleep(100);
                    Console.Write(i + ",");
                }
                Console.WriteLine();
            }
            finally
            {
                Monitor.Exit(locker);
                Console.WriteLine(Thread.CurrentThread.Name + " Exit from critical section");
            }
        }
    }

代码并不难,运行结果如下:

Child Thread 0 Trying to enter into the critical section
Child Thread 1 Trying to enter into the critical section
Child Thread 2 Trying to enter into the critical section
Child Thread 0 Entered into the critical section
0,1,2,3,4,
Child Thread 0 Exit from critical section
Child Thread 1 Entered into the critical section
0,1,2,3,4,
Child Thread 1 Exit from critical section
Child Thread 2 Entered into the critical section
0,1,2,3,4,
Child Thread 2 Exit from critical section

分析上面的结果可以看出,执行第一个Console.WriteLine时,程序时并行执行的,三个线程的输出都先于for循环的执行,但是再执行for循环时,即使有Sleep函数,也不会打乱for循环的执行顺序,必然时一个再释放锁之后,才轮得到另一个线程执行。

3.Monitor运行机制

C# 中的 Monitor 类提供了一种基于等待的同步机制,该机制一次只允许一个线程访问关键部分代码,以避免争用条件。所有其他线程必须等待并停止执行,直到释放锁定的对象。

若要了解 Monitor 类在 C# 中的工作方式,请查看下图。如下图所示,只要线程执行 Thread 类的 Enter 方法,它就会在就绪队列中,并且以同样的方式,许多线程可以存在于就绪队列中。然后,就绪队列中的一个线程将在对象上获取独占锁,并将进入关键部分并执行代码,此时,没有其他线程有机会进入关键部分。然后,当我们执行 Thread 类的 Exit 方法时,当前正在执行的线程将进入等待队列,并向 Ready 队列中的线程发送一个信号,并且 Ready 队列中的一个线程将获取锁并将进入关键部分并开始执行关键部分的代码。这就是监视器类在 C# 中的工作方式。


多线程实时目标检测 多线程monitor_Powered by 金山文档_03


4.Enter重载(一)

这里顺便说一下Enter方法的另一个重载,Monitor.Enter(lockObject, ref IslockTaken)


多线程实时目标检测 多线程monitor_多线程实时目标检测_04


我们根据上面的逻辑重新修改之前的程序:

internal class MonitorBasicUasage
    {
        public static readonly object locker=new object();
        public static void Test()
        {
            Thread[] threads=new Thread[3];
            for(int i=0; i<threads.Length; i++)
            {
                threads[i] = new Thread(Print)
                {
                    Name = "Child Thread " + i
                };
            }
            Array.ForEach(threads,t=>t.Start());    
            Console.ReadLine();
        }
        public static void Print()
        {
            Console.WriteLine(Thread.CurrentThread.Name + " Trying to enter into the critical section");
            bool isLockTaken = false;
            try
            {
                Monitor.Enter(locker,ref isLockTaken);
                if(isLockTaken)
                    Console.WriteLine(Thread.CurrentThread.Name + " Entered into the critical section");
                for (int i = 0; i < 5; i++)
                {
                    Thread.Sleep(100);
                    Console.Write(i + ",");
                }
                Console.WriteLine();
            }
            finally
            {
                if(isLockTaken)
                {
                    Monitor.Exit(locker);
                    Console.WriteLine(Thread.CurrentThread.Name + " Exit from critical section");
                }
            }
        }

运行结果如下:

Child Thread 1 Trying to enter into the critical section
Child Thread 1 Entered into the critical section
Child Thread 0 Trying to enter into the critical section
Child Thread 2 Trying to enter into the critical section
0,1,2,3,4,
Child Thread 1 Exit from critical section
Child Thread 0 Entered into the critical section
0,1,2,3,4,
Child Thread 0 Exit from critical section
Child Thread 2 Entered into the critical section
0,1,2,3,4,
Child Thread 2 Exit from critical section

结果略有变化,不再时线程0最先进入,但是最先进入的一定会最先推出,且再for循环中时独立的。

在运行一下又会得到另一个结果:

Child Thread 0 Trying to enter into the critical section
Child Thread 1 Trying to enter into the critical section
Child Thread 2 Trying to enter into the critical section
Child Thread 0 Entered into the critical section
0,1,2,3,4,
Child Thread 1 Entered into the critical section
Child Thread 0 Exit from critical section
0,1,2,3,4,
Child Thread 2 Entered into the critical section
Child Thread 1 Exit from critical section
0,1,2,3,4,
Child Thread 2 Exit from critical section

5. Enter重载二

TryEnter(Object, TimeSpan, Boolean) 应该很好理解,在原先的基础上加了一个等待时间,如果在这个时间内没有获取到锁,那么便不会执行critical section区域。


多线程实时目标检测 多线程monitor_开发语言_05


我们再次尝试一下:

internal class MonitorBasicUasage
    {
        public static readonly object locker=new object();
        public static void Test()
        {
            Thread[] threads=new Thread[3];
            for(int i=0; i<threads.Length; i++)
            {
                threads[i] = new Thread(Print)
                {
                    Name = "Child Thread " + i
                };
            }
            Array.ForEach(threads,t=>t.Start());    
            Console.ReadLine();
        }
        public static void Print()
        {
            Console.WriteLine(Thread.CurrentThread.Name + " Trying to enter into the critical section");
            bool isLockTaken = false;
            try
            {
                Monitor.TryEnter(locker,TimeSpan.FromMilliseconds(1000),ref isLockTaken);
                if(isLockTaken)
                {
                    Console.WriteLine(Thread.CurrentThread.Name + " Entered into the critical section");
                    for (int i = 0; i < 5; i++)
                    {
                        Thread.Sleep(100);
                        Console.Write(i + ",");
                    }
                    Console.WriteLine();
                }
                else
                {
                    Console.WriteLine(Thread.CurrentThread.Name + " 对不起,没能获取到锁,真实丢脸");
                }
            }
            finally
            {
                if(isLockTaken)
                {
                    Monitor.Exit(locker);
                    Console.WriteLine(Thread.CurrentThread.Name + " Exit from critical section");
                }
            }
        }
    }

由于每个线程运行时间大概时500微秒,而每个线程的等待时间都是1000微妙,那么最后一个线程自然无法在规定时间内获得锁。让我运行看看结果:

Child Thread 0 Trying to enter into the critical section
Child Thread 1 Trying to enter into the critical section
Child Thread 2 Trying to enter into the critical section
Child Thread 0 Entered into the critical section
0,1,2,3,4,
Child Thread 0 Exit from critical section
Child Thread 1 Entered into the critical section
0,1,2,3,Child Thread 2 对不起,没能获取到锁,真实丢脸
4,
Child Thread 1 Exit from critical section

显然1000微妙过后,未获得锁的线程2自爆了,此时线程1正在打印最后一个数字。验证了我们之前的推断。

6. Wait()和Pulse()

6.1 轮流打印问题

考虑一个面试题,给你一个数组(比如自然数1-20)用两个线程打印出来,而且必须是轮流打印。

我们利用前面的Enter和Exit函数,让两个线程轮流进入和退出以此来实现轮流打印,代码如下:

internal class InTurnPrint
    {
        public static int Max= 20;
        public static object locker=new object();

        public static void Run()
        {
            Thread[] threads = new Thread[2];
            threads[0] = new Thread(Print) { Name = "Even" };
            threads[1] = new Thread(Print) { Name = "Odd" };
            Array.ForEach(threads, t => { t.Start(); t.Join(); });
            Console.WriteLine("-----------");
        }

        public static void Print()
        {
            while(Max>0)
            {
                try
                {
                    Monitor.Enter(locker);
                    Console.WriteLine(Max--+" ---- "+Thread.CurrentThread.Name);
                }
                finally
                {
                    Monitor.Exit(locker);
                    Thread.Sleep(100);
                }
            }
            Console.WriteLine(Thread.CurrentThread.Name + " Exit!");
        }
    }

实际运行结果如下:

20 ---- Even
19 ---- Even
18 ---- Even
17 ---- Even
16 ---- Even
15 ---- Even
14 ---- Even
13 ---- Even
12 ---- Even
11 ---- Even
10 ---- Even
9 ---- Even
8 ---- Even
7 ---- Even
6 ---- Even
5 ---- Even
4 ---- Even
3 ---- Even
2 ---- Even
1 ---- Even
Even Exit!
Odd Exit!
-----------

显然,另一个线程一直没有获得锁,即使我们在释放锁之后加了延迟(即使在循环开始加上延迟也是一样)。显然加延迟是没用的,总是由第一个线程获得锁。所以仅靠之前的几个函数无法实现,这就要介绍另外两个函数了。

6.2 Wait和Pulse介绍

1. Monitor.Wait 方法

有两个比较常用的方法重载:

Monitor.Wait(Object)

Object:等待的锁的对象

功能:释放当前线程所占用的对象锁,并且阻塞当前的线程直到它再次拥有这个锁。

Releases the lock on an object and blocks the current thread until it reacquires the lock.

Monitor.Wait(Object,Int32)

- Object:等待的锁的对象

- Int32:线程再次进入就绪队列的等待时长,单位毫秒

功能:释放当前线程所占用的对象锁,并且阻塞当前的线程直到它再次拥有这个锁。如果指定的时长过去,线程将由等待队列转移到就绪队列。

2. Monitor.Wait 方法的主要执行步骤

阻塞当前的线程

将这个线程移动到等待队列中

释放当前的同步锁

3. Monitor.Pulse (Object)

功能:通知一个等待队列中的线程,当前锁的状态被改变。(说白了就是有一个线程可以从等待队列中被移入就绪队列)

Notifies a thread in the waiting queue of a change in the locked object's state.

4. Monitor.PulseAll(Object)

功能:通知所有的等待队列中的线程,当前锁的状态改变。(说白了就是所有的线程可以从等待队列中被移入就绪队列)

Notifies all waiting threads in the waiting queue of a change in the locked object's state.

5. Monitor.Pulse 和 Monitor.PulseAll 的使用写法:

只能由当前获得锁的线程,调用 Monitor.Pulse 和 Monitor.PluseAll 后,使等待队列中的线程转义到就绪队列。

6.3 机制分析

6.3.1 情形一
  1. 假设有五个线程,t1,t2,t3,t4,t5,他们同时启动进入Lock区域,如下:
lock(obj)  //这里lock 等价于Enter、Exit组合
{
    Monitor.Wait(obj);
}
  1. 由于线程t1被第一个处理,进而进入了Lock,它获得锁,此时所有线程的状态:

拥有线程的锁

t1

就绪队列

t2,t3,t4,t5

等待队列



  1. 假设线程 t1 运行到了 Monitor.Wait,它将会被从拥有线程锁状态移动到等待队列状态中,于此同时将会释放其拥有的锁,而其它在就绪队列中的线程将有机会获得这个锁:

拥有线程的锁


就绪队列

t2,t3,t4,t5

等待队列

t1


  1. 此时假设线程 t2 获取了 t1 释放的锁,它将进入 lock 区域中,此时所有的线程状态如下:

拥有线程的锁

t2

就绪队列

t3,t4,t5

等待队列

t1


  1. 接着 t2 在 lock 区域中也会执行 Monitor.Wait ,之后 t2 也会像 t1 一样进入等待队列,重复 1、2 步骤,直至所有的线程 t1、t2、t3、t4、t5 都进入等待队列,如下图:

拥有线程的锁


就绪队列


等待队列

t1,t2,t3,t4,t5


6.3.2 情形二

如何将上面等待队列中的某一个线程重新变为就绪状态,从而可以再次拿到锁呢?

答:我们可以使用 Monitor.Pulse 来让 t1 线程从等待队列中转移到就绪队列中。

★★ 这里有一个需要注意的地方,就是 " 等待队列 " 是一个队列,满足 " 先进先出 ",所以第一个线程 t1 会被优先释放到就绪队列中。

  1. 我们在情形一第5点的状态下执行 Monitor.Pulse,此时所有的线程的状态如下:

拥有线程的锁


就绪队列

t1

等待队列

t2,t3,t4,t5


  1. 然后,线程 t1 在就绪队列中就会拿到锁,从 Monitor.Wait 的下一句程序开始执行:

拥有线程的锁

t1

就绪队列


等待队列

t2,t3,t4,t5


  1. 最后,t1 线程在执行完 lock 区域的剩余部分的代码之后就会退出,同时释放线程锁。于此同时,其它的线程依然被卡在等待队列中等待,如下:

拥有线程的锁


就绪队列


等待队列

t2,t3,t4,t5


  1. 对于 Monitor.PulseAll 将会把所有的等待状态的线程都移到就绪状态的队列中,从而有机会获得锁进行执行。从第3步接着执行 Monitor.PulseAll 之后,所有的线程状态如下:

拥有线程的锁


就绪队列

t2,t3,t4,t5

等待队列



6.4 实现轮流打印问题

Wait()和Pulse()为我们提供了很好的思路。我们先看第一种实现:

我们写两个函数,分别打印数组:

internal class MonitorStudy
    {
        const int numberLimit = 21;
        static readonly object _locker=new object();

        public static void Run()
        {
            Thread evenThread=new Thread(PrintEvenNumbers) { Name="Even"};
            Thread oddThread=new Thread(PrintOddNumbers) { Name="Odd"};   
            evenThread.Start();
            oddThread.Start();
            evenThread.Join();
            oddThread.Join();
            Console.WriteLine("-----------------");
        }
        static void PrintEvenNumbers()
        {
            try
            {
                Monitor.Enter(_locker);
                for(int i=0; i<numberLimit; i++)
                {
                    if(i%2==0)
                    {
                        Console.WriteLine($"{i} -----{Thread.CurrentThread.Name}");
                    }
                    //Notify Odd thread that I'm done, you do your job
                    //It notifies a thread in the waiting queue of a change in the 
                    //locked object's state.
                    Monitor.Pulse(_locker);
                    //Notify Odd thread that I'm done, you do your job
                    //It notifies a thread in the waiting queue of a change in the 
                    //locked object's state.
                    if (i + 1 == numberLimit)
                        break;
                    Monitor.Wait(_locker);
                }
            }
            finally
            {
                Monitor.Exit(_locker);
            }
        }

        static void PrintOddNumbers()
        {
            try
            {
                Monitor.Enter(_locker);
                for(int i=0;i<numberLimit;i++)
                {
                    if(i%2!=0)
                    {
                        Console.WriteLine($"{i} -----{Thread.CurrentThread.Name}");
                    }
                    Monitor.Pulse(_locker);
                    if (i + 1 == numberLimit)
                        break;
                    Monitor.Wait(_locker);
                }
            }
            finally
            {
                Monitor.Exit(_locker);
            }
        }
    }

这里值得注意的是上面的两端注释,第一段注释是让另一个线程进入就绪队列,一旦当前线程调用wait释放锁,则另一个线程立即执行。第二个要点是,一旦循环到末位,有一个线程会直接退出,此时另一个线程由于调用Wait还在等待中,这会导致阻塞,所以这里有一个判断,如果是边界值,则不用再等待了,结束整个程序。

上面的程序,我们做了奇偶判断,好像显得很繁琐,前面我们讨论了两种情形,理论上,当线程释放时,必然会被别的线程先获取,所以有且只有两个线程时,可以不用进行奇偶判断,于是我们可以只写一个函数,让两个线程都调用即可。实现如下:

internal class InTurnPrint
    {
        public static int Max= 20;
        public static object locker=new object();

        public static void Run()
        {
            Thread[] threads = new Thread[2];
            threads[0] = new Thread(Print) { Name = "Even" };
            threads[1] = new Thread(Print) { Name = "Odd" };
            Array.ForEach(threads, t => { t.Start(); t.Join(10); });
            Console.WriteLine("-----------");
        }

        public static void Print()
        {
            while(Max>0)
            {
                lock(locker)
                {
                    Console.WriteLine(Max-- + " ---- " + Thread.CurrentThread.Name);
                    Monitor.Pulse(locker);
                    if(Max!=0)
                        Monitor.Wait(locker);
                }              
            }
            Console.WriteLine(Thread.CurrentThread.Name + " Exit!");
        }
    }

注意线程调用Join是为了确保子线程先结束,如果不传入参数,Join会阻塞UI打印。另一种办法是不加Join,在第12行前面加一个:

Thread.sleep(100)

也可以实现同样的效果。

7. 总结

Monitor类和Lock的区别在于:Lock内部实际是将Monitor类的Enter和Exit函数放入Try……finally模块中,并添加异常处理,因此我们使用Monitor类时需要显示的使用try和finally模块来讲锁显示的释放

Lock=Monitor+try-finally

Lock语句通过同步对象提供一个基本的排它锁,但是如果你想跟精细的控制并行程序,就需要使用TryEnter(),Wait(),Pulse()和PulseAll()函数,此时使用Monitor类能满足你更好的需求。

锁和监视器帮助我们确保我们的代码是线程安全的。这意味着当我们在多线程环境中运行我们的代码时,我们不会得到不一致的结果。为了更好地理解,请看下图。


多线程实时目标检测 多线程monitor_开发语言_06


但是锁和监视器有一些限制。锁和监视器确保 In-Process 线程的线程安全,即由应用程序本身生成的线程,即内部线程。但是,如果线程来自外部应用程序(Out-Process)或外部线程,那么 Locks 和 Monitors 将无法控制它们。所以,在这种情况下,我们需要使用 Mutex。在我们的下一篇文章中,我们将讨论 Mutex。

参考链接:Monitor