.Net CLR 中的同步机制(一): 互斥体

随着软硬件技术的发展,无论是在Web服务或者云计算,还是单一的应用程序,串行方式编写的软件越来越少,我们总是可以看见并行的存在。但是并行并不是适合于每一种场景,也完全不是将工作扔到线程池中排队运行那么简单。

由于在进程中,多个线程可能需要访问相同的虚拟内存地址空间,如果不进行控制就很容易出现数据竞争的并发问题,大多是因为操作非原子性和线程时间片的原因引起的,导致的现象会是抛出异常,程序崩溃,数据的值和期望不一致,数据破坏等等,关键有的时候还会随机出现一些问题,这次运行正确,下一次就不正确了,这些问题都不是简单的单元测试就可以测试出来的。为了解决这些问题,windows就提供了同步机制,同步就是唯一能够保证让多线程能正确的使用共享的可变状态的技术。

同步一般分为两种:数据同步和控制同步。

数据同步:一般是指同步的访问某个共享资源主要是内存数据,多个程序以并行的方式使用相同的资源时不会产生干扰。常见的有:lock,Mutex,Monitor,Semaphore等等

控制同步:多线程的运行依赖于程序的控制流,一个线程的运行往往要等待其他线程运行到某个点的通知。如:Event等。

这两种类型我们常常在开发中结合使用。

Windows有很多只能由内核访问的内核对象,如线程对象,文件对象,当然还有我们要介绍的用于同步控制的内核对象:互斥体(Mutex),信号量(Semaphore),事件(Auto-Reset Event, Manual-Reset Event)等。而内核对象是在内核内存中分配的,因此只有在内核态运行的代码中才能访问到它们,访问他们需要使用windows api,从而需要内核切换,所以使用内核对象会比使用其他的原语需要更高的开销。但是内核对象的优势也非常明显,很多都是用户态的同步机制(Win32临界区或CLR的Monitor等)所无法实现的,比如进程间的同步,对于同步的控制,和执行粒度更低的等待,以及托管代码与非托管代码之间的互操作。而可以简单快速可靠使用的api也是我们常常使用的一个关键。

 

接下来我们先来说一下互斥体。

对于一般性的数据竞争问题,解决方法之一就是使用互斥体来使共享状态的并发访问串行化。互斥体其实就是构建了一段指令临界域,在这个临界域中,同时只能有一个线程来执行临界域中的指令。CLR中的互斥体实现就是Mutex,它的目的就是构建拥有互斥行为的临界域,保证只有一个线程可以进入这个临界域。而在底层基本都是使用原子的比较交换(CAS)来实现,这需要硬件来提供支持。

Mutex继承自System.Threading.WaitHandle。

下面的例子展示了多线程中使用Mutex来实现同步。

class Program
    {
        static Mutex m = new Mutex();
        static void Main(string[] args)
        {
            Task t1 = new Task(() => CriticalRegion());
            Task t2 = new Task(() => CriticalRegion());
            t1.Start();
            t2.Start();

            Task.WaitAll(t1, t2);
            Console.WriteLine("Finished.");
            Console.ReadLine();
        }

        static void CriticalRegion()
        {
            m.WaitOne();
            try
            {
                //临界区域
                Thread.Sleep(5000);
                //
            }
            finally
            {
                m.ReleaseMutex();
            }
        }
    }

Mutex的WaitOne方法获取成功以后,互斥体将被线程获取并且标记为未触发。除非拥有互斥体的线城市防它并且重新回到未触发状态,否则其他线程都不能获得这个互斥体。释放的方法为ReleaseMutex。

如果有多个线程在等待同一个Mutex,那么内核将通过FIFO算法来跟踪等待着并决定唤醒哪一个线程。虽然在内核中有一定的顺序,但是我们不能保证我们的多线程程序能够按顺序执行WaitOne方法。所以对我们上层应用来说,首先唤醒哪个线程是未知的。但是你可以通过使用线程的优先级来提升线程首先被执行的可能性。

static void TestPriority()
        {
            for (int i = 0; i < 10; i++)
            {
                string taskName = "t" + i;
                Thread t1 = new Thread(() => CriticalRegionWithName(taskName));
 
                if (i == 6)
                {
                    t1.Priority = ThreadPriority.Highest;
                }
                t1.IsBackground = true;
                t1.Start();
            }
        }
 
        static void CriticalRegionWithName(string name)
        {
            m.WaitOne();
            try
            {
                //临界区域
                Thread.Sleep(2000);
                Console.WriteLine(name);
                //
            }
            finally
            {
                m.ReleaseMutex();
            }
        }

 

互斥体对象支持递归获取,这意味着当拥有互斥体的线程在互斥体上再次进行等待的时候,这个等待将马上被满足,即使对象处于未触发状态。在互斥体内部,内核维护者一个计数器,对于每个互斥体来说,初始值为0,每次执行获取操作WaitOne的时候,都会增加1,而每次释放Release的时候都会减去1。只有这个互斥体的计数器降为0的时候,其他线程才能够重新获取这个互斥体。所以在使用互斥体Mutex的时候一定要记得调用ReleaseMutex释放。

static void TestRecursion()
        {
            Task t1 = new Task(() => CriticalRegionWithRecursion("t1"));
            Task t2 = new Task(() => CriticalRegionWithRecursion("t2"));
            t1.Start();
            t2.Start();
 
            Task.WaitAll(t1, t2);
            Console.WriteLine("Finished.");
            Console.ReadLine();
        }
 
        static void CriticalRegionWithRecursion(string taskName)
        {
            m.WaitOne();
            Console.WriteLine(taskName + " got the mutex");
            try
            {               
                try
                {
                    m.WaitOne();
                    Console.WriteLine(taskName + " got the mutex");
                    //临界区域
                    Thread.Sleep(3000);
                }
                finally
                {
                    m.ReleaseMutex();
                    Console.WriteLine(taskName + " released the mutex");
                }
                //临界区域
                Thread.Sleep(5000);
            }
            finally
            {
                m.ReleaseMutex();
                Console.WriteLine(taskName + " released the mutex");
            }
        }

结果:

.Net CLR 中的同步机制(一): 互斥体_第1张图片

 

Mutex还是已有有Owner的锁对象,和Monitor一样。只有获取了带锁的对象才能释放它,如果获取的线程和释放的线程不是同一个线程的话将会产生异常: 从不同步的代码块中调用了对象同步方法。

测试代码:

        static void TestOwner()
        {
            Task.Factory.StartNew(() => { m.WaitOne(); Thread.Sleep(5000); });
            Thread.Sleep(2000);
            Task.Factory.StartNew(() => { m.ReleaseMutex(); });
        }

 

Mutex可以作为机器范围的,也可以用作进程范围的。在进程范围中,我们可以使用它来同步线程,而在机器范围中,我们可以用Mutex来同步进程。进程同步和线程同步类似,即保证同步的访问在进程间共享的数据。常见的进程间同步是用Mutex来保证进程的在机器上的单实例运行。在内核对象中,同样可以使用与进程间同步的还有Semaphore。

static void Main(string[] args)
        {
            CheckProcessExists();
            Console.ReadLine();
        }
 
static void CheckProcessExists()
        {
            using (var mutex = new Mutex(false, "Global\\Demo"))
            {
                if (!mutex.WaitOne(TimeSpan.FromSeconds(3), false))
                {
                    Console.WriteLine("Another app instance is running. Bye!");
                    return;
                }
                Console.WriteLine("Runing...");
                Console.ReadLine();
            }
        }

 

附上MSDN上面的说明 (http://msdn.microsoft.com/zh-cn/library/system.threading.mutex.aspx):

.Net CLR 中的同步机制(一): 互斥体_第2张图片

 

拥有该Mutex互斥体的线程在结束之前没有正确的释放Mutex,比如忘记写finally,忘记ReleaseMutex,或者在递归获取Mutex的过程中计数器出现错误,又或者进程或线程突然中止,那么这个Mutex就会被认为是废弃的互斥体。当互斥体被废弃时,如果不借助操作系统的帮助,那么其他的线程将不能获取到该Mutex,因为此时互斥体并没有被释放。也不用太担心,操作系统会作处理,操作系统可以保证党出现废弃的互斥体时候,有一个等待线程可以被唤醒获取到该互斥体。

Mutex互斥体由于是内核对象,在使用过程中会有大量的内核切换,所以它的效率不是很高。执行的速度约比lock(Monitor)锁慢50倍左右。所以在我们多线程编程中,Mutex的使用频率并不是很高。

 

测试代码在这里下载

你可能感兴趣的:(.net)