从本篇文章开始,我将陆续介绍多线程中会遇到的三种情况。
情景一:此茅坑有主了
大锤:“我擦,居然一个茅坑有两个人在用。”
大锤:“啊,忍不住了,一起挤挤吧~~~”
叫兽:“舒坦了,先走了。”
叫兽按下了冲水开关.... "哗啦啦....."
大锤:“你妹啊,冲什么水啊,冲得我一身 shit ”
解决方案:为了解决这种混乱的情况,管理员给茅坑加了道门,一次只允许一个人使用,其他人只能在外面等待。而且只要有人占着,就算不拉屎,其他人也只能乖乖排队。
问题抽象:当某一资源可能同时被多个线程读取和修改时,资源的状态将变得难以预料。
线程同步方案:Interlocked、lock、Moniter、SpinLock、ReadWriteLockSlim、Mutex
方案特性:除所有者外,其他人无条件等待;先到先得(谁先进茅坑,谁先用,没有先后顺序)
各方案间的区别(关于如何使用每种方案,很多文章和书籍都有介绍,就不再一一赘述了。)
这些方案从它们各自的实现方式可分为三种:用户模式构造、内核模式构造 和 混合模式构造。
应该尽量使用用户模式构造,它们的速度要显著快于内核模式的构造。这是因为它们使用了特殊 CPU 指令来协调线程。这意味着协调是在硬件中发生的(所以才这么快)。它们有一个缺点:只有 Windows 操作系统内核才能停止一个线程的运行(以避免浪费 CPU 时间)。所以,一个线程想要取得一个资源但又暂时取不到,它会一直在用户模式中运行。这可能浪费大量 CPU 时间。
内核模式的构造是由 Windows 操作系统自身提供的。所以,它们要求你在应用程序的线程中调用在操作系统内核中实现的函数。将线程从用户模式切换为内核模式(或相反)会招致巨大的性能损失,这正是为什么应该避免使用内核模式构造的原因。然后,它们有一个重要的优点:一个线程使用一个内核模式的构造获取一个由其它线程拥有的资源时,Windows会阻塞线程,使它不再浪费 CPU 时间。然后,当资源变得可用时,Windows 会恢复线程,允许它访问资源。
---- 《CLR via C# (第 3 版)》 P706
上面这段话摘自《CLR via C#》,各别用词稍微调整了下以便于理解。简单来说,用户模式会通过在 CPU 中不断的执行某些指令来达到阻塞线程的效果(想像一下一直执行 while(true); 的样子),而内核模式则是实实在在的把线程的执行给停止了,CPU 不会再去调度这个线程。混合模式,就不用说了,是两者的结合。
那什么时候该用什么模式的构造呢?对于短时间的阻塞,选择用户模式;长时间的阻塞,选择内核模式;阻塞时间不定的,选择混合模式。
用户模式(user-mode)
Interlocked 保证的是原子性,其原子操作包括 “递增”、“递减”、“相加”、“交换” 。之所以把它也归入情景一,是因为它通过原子操作确保一个资源在 “读取后,写入前” 不会有其它线程中断它的执行,从而保证了资源的独占使用。
优点:速度最快,且单次操作阻塞时间短。
缺点:可执行的操作有限。
SpinLock 自旋锁,在 .Net 4.0 的时候引入。自旋的意思就是自个儿在原地旋转,以此来占用 CPU 时间。说白了就是类似 “while(状态是否可用); ”,如果状态不可用,则一直循环,直到状态可用为止。可以用 Interlocked 来实现 SpinLock 的效果:
//参考 Clr via C# struct MySpinLock { int _lock; public void Enter() { //第一个线程进来的时候,Exchange 返回0,while 退出。其它线程进来,都返回1 while (Interlocked.Exchange(ref _lock, 1) == 1) ; } public void Exit() { Interlocked.Exchange(ref _lock, 0); } }
优点:速度快,可以用于各种操作。
缺点:如果操作需要很长时间,将会严重浪费 CPU 时间。在单核的处理器中使用该方式,可能造成死锁。因为如果加锁的线程优先级低于阻塞的线程,那可能很长一段时间都无法被调度到CPU上,这样就无法解锁。
内核模式(kernal-mode)
Mutex 可以跨进程保证资源的独占使用,通过 WaitOne 来获取锁,ReleaseMutex 释放锁(使用哪个线程执行的 WaitOne,只能由该线程 ReleaseMutex)。它与后面要讲到的 “Event” 都来自于同一个父类 WaitHandle。这是一个抽象类,包装了 Windows 操作系统的内核对象句柄。
主要用于:限制应用程序只能启动一次。如 Sql Server、360安全卫士。
代码示例:
[STAThread] static void Main() { bool loaded = false; Mutex mutex = new Mutex(false, "SINGILE", out loaded); if (loaded) { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new Form1()); } else { Application.Exit(); } }
优点:允许递归使用,可以跨进程使用
缺点:速度最慢(不仅是因为会在内核模式与用户模式间进行切换,造成性能的损失;也因为相对于 Event,它提供了递归使用等高级的功能,这导致它比其它结构都要复杂)
混合模式(hybrid-mode)
Moniter 方式通过调用静态方法 Enter、Exit 来实现对共享资源或代码段的独占使用,是 .Net 领域中问世最早的一种线程同步机制。我们都知道每个引用类型在堆中都会包含两个特殊的字段:同步块索引 和 类型对象指针。而使用 Moniter.Enter 实际就会去操作同步块索引,让它指向堆中的同步块数组;Mointer.Exit 则会重新将同步块索引置为 -1。
优点:速度还行,介于内核模式和用户模式之间;支持递归使用。
缺点:会把所有操作(读或写)该资源的线程都阻塞,而当系统中读线程的数量远远多于写线程的时候,很有可能出现同一时刻只有多个读线程,这个时候阻塞的行为就显得多余了。
Lock 是 C# 的语法糖,通过查看 IL 代码可以知道,它最终将被解释为 Moniter.Enter 和 Moniter.Exit。下面是 C# 4.0 代码的 IL。
通过上面的 IL,可以明确的看到 Moniter.Exit 被放置在 finally 块中,这样保证了锁最终将被正确释放(避免了可能发生的死锁)。但有一点值得注意的是,如果代码块中抛出了异常,尽管可以保证锁被释放,但无法保证其中的共享资源仍旧是正确的。
优点:使用简单;保证锁肯定会被释放;速度同 Moniter 。
缺点:同 Moniter。
ReadWriteLockSlim 与 Mointer 不同,它通过 EnterReadLock、EnterWriteLock、ExitReadLock、ExitWriteLock 来区别对待读线程还是写线程。所以对于读线程加读锁,而写线程加写锁,这样当当前时刻不存在写线程的时候,所有读线程都可以并发的访问资源。
优点:读、写锁分离。当不存在写线程的时候,速度要明显快于 Mointer。而当有写线程的时候,速度稍慢于 Mointer。
上面的方式各有优缺点,就算是经验丰富的程序猿也不一定能保证线程一定是安全的。所以只要有可能还是建议大家尽量不使用、少使用共享资源,或者让共享资源变成只读。
总结
情景一中所说的所有方法都是围绕一个目的 ------ “解决对共享资源的争用问题”。当在实际开发过程中,如果碰到了共享资源(静态变量、类型的成员变量、文件等)或需要独占使用的代码段时,请考虑采用上述方式中的任何一种来保证线程安全。
本文来自《C# 基础回顾: 线程同步的情景之一》