多线程之线程同步
多线程内容大致分两部分,其一是异步操作,可通过专用,线程池,Task,Parallel,PLINQ等,而这里又涉及工作线程与IO线程;其二是线程同步问题,鄙人现在学习与探究的是线程同步问题。
通过学习《CLR via C#》里面的内容,对线程同步形成了脉络较清晰的体系结构,在多线程中实现线程同步的是线程同步构造,这个构造分两大类,一个是基元构造,一个是混合构造。所谓基元则是在代码中使用最简单的构造。基元构造又分成两类,一个是用户模式,另一个是内核模式。而混合构造则是在内部会使用基元构造的用户模式和内核模式,使用它的模式会有一定的策略,因为用户模式和内核模式各有利弊,混合构造则是为了平衡两者的利与弊而设计出来。下面则列举整个线程同步体系结构
-
基元
1.1 用户模式
1.1.1 volatile
1.1.2 Interlock
1.2 内核模式
1.2.1 WaitHandle
1.2.2 ManualResetEvent与AutoResetEvent
1.2.3 Semaphore
1.2.4 Mutex
-
混合
2.1 各种Slim
2.2 Monitor
2.3 MethodImplAttribute与SynchronizationAttribute
2.4 ReaderWriterLock
2.5 Barier(少用)
2.6 CoutdownEvent(少用)
先从线程同步问题的原因说起,当内存中有一个整形的变量A,里面存放的值是2,当线程1执行的时候它会把A的值从内存中取出存放到CPU的寄存器中,并把A赋值为3,此时刚好线程1的时间片结束;接着CPU把时间片分给线程2,线程2同样把A从内存中的值取出来放到内存中,但是由于线程1并没有把变量A的新值3放回内存,故线程2读到的仍然是旧的值(也就是脏数据)2,然后线程2要是需要对A值进行一些判断之类的就会出现一些非预期的结果了。
而针对上面这种对资源的共享问题处理,往往会使用各种各样办法。下面则逐一介绍
先说说基元构造中的用户模式,凡是用户模式的优点是它的执行相对较快,因为它是通过一系列CPU指令来协调,它造成的阻塞只是极短时间的阻塞,对操作系统而言这个线程是一直在运行,从未被阻塞。缺点就是唯有系统内核才能停止这样的一个线程运行。另一方面就是由于线程在自旋而非阻塞,那么它还会占用这CPU的时间,造成对CPU时间的浪费。
首先是基元用户模式构造中的volatile构造,这个构造网上很多说法是让CPU对指定字段(Field,也就是变量)的读都是从内存读,每次写都是往内存写。然而它和编译器的代码优化有关系。先看看如下代码
public class StrageClass { volatile int mFlag = 0; int mValue = 0; public void Thread1() { mValue = 5; mFlag = 1; } public void Thread2() { if (mFlag == 1) Console.WriteLine(mValue); } }
在懂得多线程同步问题的同学们都会知道如果用两个线程分别去执行上面两个方法时,得出的结果有两个:1.不输出任何东西;2.输出5。但是在CSC编译器编译成IL语言或JIT编译成机器语言的过程中,会进行代码优化,在方法Thread1中,编译器会觉得给两个字段赋值会没什么所谓,它只会站在单个线程执行的角度来看,完全不会顾及多线程的问题,因此它有可能会把两行代码的执行顺序调乱,导致先给mFlag赋值为1,再给mValue赋值为5,这就导致了第三种结果,输出0。可惜这种结果我一直无法测试出来。
解决这个现象的就是volatile构造,使用了这种构造的效果是,凡是对使用了此构造的字段进行读操作时,该操作都保证在原有代码顺序下会在最先执行;或者是凡是对使用了此构造的字段进行写操作时,该操作都保证在原有代码顺序下会在最后执行。
实现了volatile的构造现在来说有三个,其一是Thread的两个静态方法VolatileRead和VolatileWrite,在MSND上的解析如下
Thread.VolatileRead 读取字段值。 无论处理器的数目或处理器缓存的状态如何,该值都是由计算机的任何处理器写入的最新值。
Thread.VolatileWrite 立即向字段写入一个值,以使该值对计算机中的所有处理器都可见。
在多处理器系统上, VolatileRead 获得由任何处理器写入的内存位置的最新值。 这可能需要刷新处理器缓存;VolatileWrite 确保写入内存位置的值立即可见的所有处理器。 这可能需要刷新处理器缓存。
即使在单处理器系统上, VolatileRead 和 VolatileWrite 确保值为读取或写入内存,并不缓存 (例如,在处理器寄存器中)。 因此,您可以使用它们可以由另一个线程,或通过硬件更新的字段对访问进行同步。
从上面的文字看不出他和代码优化有任何关联,那接着往下看。
volatile关键字则是volatile构造的另外一种实现方式,它是VolatileRead和VolatileWrite的简化版,使用 volatile 修饰符对字段可以保证对该字段的所有访问都使用 VolatileRead 或 VolatileWrite。MSDN中对volatile关键字的说明是
volatile 关键字指示一个字段可以由多个同时执行的线程修改。 声明为 volatile 的字段不受编译器优化(假定由单个线程访问)的限制。 这样可以确保该字段在任何时间呈现的都是最新的值。
从这里可以看出跟代码优化有关系了。而纵观上面的介绍得出两个结论:
1.使用了volatile构造的字段读写都是直接对内存操作,不涉及CPU寄存器,使得所有线程对它的读写都是同步,不存在脏读了。读操作是原子的,写操作也是原子的。
2.使用了volatile构造修饰(或访问)字段,它会严格按照代码编写的顺序执行,读操作将会在最早执行,写操作将会最迟执行。
最后一个volatile构造是在.NET Framework中新增的,里面包含的方法都是Read和Write,它实际上就相当于Thread的VolatileRead 和VolatileWrite 。这需要拿源码来说明了,随便拿一个Volatile的Read方法来看
而再看看Thraed的VolatileRead方法
另一个用户模式构造是Interlocked,这个构造是保证读和写都是在原子操作里面,这是与上面volatile最大的区别,volatile只能确保单纯的读或者单纯的写。
为何Interlocked是这样,看一下Interlocaked的方法就知道了
Add(ref int,int)// 调用ExternAdd 外部方法
CompareExchange(ref Int32,Int32,Int32)//1与3是否相等,相等则替换2,返回1的原始值
Decrement(ref Int32)//递减并返回 调用add
Exchange(ref Int32,Int32)//将2设置到1并返回
Increment(ref Int32)//自增 调用add
就随便拿其中一个方法Add(ref int,int)来说(Increment和Decrement这两个方法实际上内部调用了Add方法),它会先读到第一个参数的值,在与第二个参数求和后,把结果写到给第一参数中。首先这整个过程是一个原子操作,在这个操作里面既包含了读,也包含了写。至于如何保证这个操作的原子性,估计需要查看Rotor源码才行。在代码优化方面来说,它确保了所有写操作都在Interlocked之前去执行,这保证了Interlocked里面用到的值是最新的;而任何变量的读取都在Interlocked之后读取,这保证了后面用到的值都是最新更改过的。
CompareExchange方法相当重要,虽然Interlocked提供的方法甚少,但基于这个可以扩展出其他更多方法,下面就是个例子,求出两个值的最大值,直接抄了Jeffrey的源码
查看上面代码,在进入循环之前先声明每次循环开始时target的值,在求出最值之后,核对一下target的值是否有变化,如果有变化则需要再记录新值,按照新值来再求一次最值,直到target不变为止,这就满足了Interlocked中所说的,写都在Interlocked之前发生,Interlocked往后就能读到最新的值。
基元内核模式
内核模式则是靠操作系统的内核对象来处理线程的同步问题。先说其弊端,它的速度会相对慢。原因有两个,其一由于它是由操作系统内核对象来实现的,需要操作系统内部去协调,另外一个原因是内核对象都是一些非托管对象,在了解了AppDomain之后就会知道,访问的对象不在当前AppDomain中的要么就进行按值封送,要么就进行按引用封送。经过观察这部分的非托管资源是按引用封送,这就会存在性能影响。综合上面两方面的两点得出内核模式的弊端。但是他也是有利的方面:1.线程在等待资源的时候不会"自旋"而是阻塞,这个节省了CPU时间,并且这个阻塞可以设定一个超时值。2.可以实现Window线程和CLR线程的同步,也可同步不同进程中的线程(前者未体验到,而对于后者则知道semaphores中有边界值资源)。3.可应用安全性设置,为经授权账户禁止访问(这个不知道是咋回事)。
内核模式的所有对象的基类是WaitHandle。内核模式的所有类层次如下
WaitHandle
EventWaitHandle
AutoResetEvent
ManualResetEvent
Semaphore
Mutex
WaitHandle继承MarshalByRefObject,这个就是按引用封送了非托管对象。WaitHandle里面主要是各种Wait方法,调用了Wait方法在没有收到信号之前会被阻塞。WaitOne则是等待一个信号,WaitAny(WaitHandle[] waitHandles)则是收到任意一个waitHandles的信号,WaitAll(WaitHandle[] waitHandles)则是等待所有waitHandles的信号。这些方法都有一个版本允许设置一个超时时间。其他的内核模式构造都有类似的Wait方法。
EventWaitHandle的内部维护着一个布尔值,而Wait方法会在这个布尔值为false时线程就会被阻塞,直到该布尔值为true时线程才被释放。操纵这个布尔值的方法有Set()和Reset(),前者是把布尔值设成true;后者则设成false。这相当于一个开关,调用了Reset之后线程执行到Wait就暂停了,直到Set才恢复。它有两个子类,使用的方式类似,区别在于AutoResetEvent调用Set之后自动调用Reset,使得开关马上恢复关闭状态;而ManualResetEvent就需要手动调用Set让开关关闭。这样就达到一个效果一般情况下AutoResetEvent每次释放的时候能让一条线程通过;而ManualResetEvent在手动调用Reset之前有可能会让多条线程通过。
Semaphore的内部是维护着一个整形,当构造一个Semaphore对象时会指定最大的信号量与初始信号量值,每当调用一次WaitOne,信号量就会加1,当加到最大值时,线程就会被阻塞,当调用Release的时候就会释放一个或多个信号量,此时被阻塞掉的一个或多个线程就会被释放。这个就符合生产者与消费者问题了,当生产者不断往产品队列中加入产品时,他就会WaitOne,当队列满了,就相当于信号量满了,生成者就会被阻塞,当消费者消费掉一个商品时,就会Release释放掉产品队列中的一个空间,此时因没有空间存放产品的生产者又可以开始工作往产品队列中存放产品了。
Mutex的内部与规则相对前面两者稍微复杂一点,先说与前面相似的地方就是同样都会通过WaitOne来阻塞当前线程,通过ReleastMutex来释放对线程的阻塞。区别在于WaitOne的允许第一个调用的线程通过,其余后面的线程调用到WaitOne就会被阻塞,通过了WaitOne的线程可以重复调用WaitOne多次,但是必须调用同样次数的ReleaseMutex来释放,否则会因为次数不对等导致别的线程一直处于阻塞的状态。相比起之前的几个构造,这个构造会有线程所有权与递归这两个概念,这个是单纯靠前面的构造都无法实现的,额外封装除外。
混合构造
上面的基元构造是用了最简单的实现方式,用户 模式有用户模式的快,但是它会带来CPU时间的浪费;内核模式解决了这个问题,但是会带来性能上的损失,各有利弊,而混合构造则是集合了两者的利,它会在内部通过一定策略适当的时机使用用户模式,再另一种情况下又会使用内核模式。但是这些层层判断带来的是内存上的开销。在多线程同步中没有完美的构造,各个构造都有利弊,存在即有意义,结合具体的应用场景就会有最优的构造可供使用。只是在于我们能否按照具体的场景权衡利弊而已。
各种Slim后缀的类,在System.Threading命名空间中,可以看到若干个以Slim后缀结尾的类:ManualResetEventSlim,SemaphoreSlim,ReaderWriterLockSlim。除了最后一个,其余两个都是在基元内核模式中有一样的构造,但是这三个类都是原有构造的简化版,尤其是前两个,使用方式跟原有的一样,但是尽量避免使用操作系统的内核对象,而达到了轻量级的效果。比如在SemaphoreSlim中使用了内核构造ManualResetEvent,但是这个构造是通过延时初始化,没达到非不得已时都不使用。至于ReaderWriterLockSlim则在后面再介绍。
Monitor与lock,lock关键字可谓是最广为人知的一种实现多线程同步的手段,那么下面则又从一段代码说起
这个方法相当简单且无实际意义,它只是为了看编译器把这段代码编译成什么样子,通过查看IL如下
留意到IL代码中出现了try…finally语句块、Monitor.Enter与Monotor.Exit方法。然后把代码更改一下再编译看看IL
IL代码
代码比较相似,但并非等价,实际上与lock语句块等价的代码如下
那么既然lock本质上是调用了Monitor,那Monitor是如何通过对一个对象加锁,然后实现线程同步。原来每个在托管堆里面的对象都有两个固定的成员,一个指向该对象类型的指针,另一个是指向一个线程同步块索引。这个索引指向一个同步块数组的元素,Monitor对线程加锁就是靠这个同步块。按照Jeffrey(CLR via C#的作者)的说法同步块中有三个字段,所有权的线程Id,等待线程的数量,递归的次数。然而我通过另一批文章了解到线程同步块的成员并非单纯这几个,有兴趣的同学可以去阅读《揭示同步块索引》的文章,有两篇。 当Monitor需要为某个对象obj加锁时,它会检查obj的同步块索引有否为数组的某个索引,如果是-1的,则从数组中找出一个空闲的同步块与之关联,同时同步块的所有权线程Id就记录下当前线程的Id;当再次有线程调用Monitor的时候就会检查同步块的所有权Id和当前线程Id是否对应上,能对应上的就让其通过,在递归次数上加1,如果对应不上的就把该线程扔到一个就绪队列(这个队列实际上也是存在同步块里面)中,并将其阻塞;这个同步块会在调用Exit的时候检查递归次数确保递归完了就清除所有权线程Id。通过等待线程数量得知是否有线程在等待,如果有则从等待队列中取出线程并释放,否则就解除与同步块的关联,让同步块等待被下个被加锁的对象使用。
Monitor中还有一对方法Wait与Pulse。前者可以使得获得到锁的线程短暂地将锁释放,而当前线程就会被阻塞而放入等待队列中。直到其他线程调用了Pulse方法,才会从等待队列中把线程放到就绪队列中,等待下次锁被释放时,才有机会被再次获取锁,具体能否获取就要看等待队列中的情况了。
ReaderWriterLock读写锁,传统的lock关键字(即等价于Monitor的Enter和Exit),他对共享资源的锁是全互斥锁,一经加锁的资源其他资源完全不能访问。
而ReaderWriterLock对互斥资源的加的锁分读锁与写锁,类似于数据库中提到的共享锁和排他锁。大致情况是加了读锁的资源允许多个线程对其访问,而加了写锁的资源只有一个线程可以对其访问。两种加了不同缩的线程都不能同时访问资源,而严格来说,加了读锁的线程只要在同一个队列中的都能访问资源,而不同队列的则不能访问;加了写锁的资源只能在一个队列中,而写锁队列中只有一个线程能访问资源。区分读锁的线程是否在于统一个队列中的判断标准是,本次加读锁的线程与上次加读锁的线程这个时间段中,有否别的线程加了写锁,没没别的线程加写锁,则这两个线程都在同一个读锁队列中。
ReaderWriterLockSlim和ReaderWriterLock类似,是后者的升级版,出现在.NET Framework3.5,据说是优化了递归和简化了操作。在此递归策略我尚未深究过。目前大概列举一下它们通常用的方法
ReaderWriterLock常用的方法
Acqurie或Release ReaderLock或WriteLock 的排列组合
UpGradeToWriteLock/DownGradeFromWriteLock 用于在读锁中升级到写锁。当然在这个升级的过程中也涉及到线程从读锁队列切换到写锁队列中,因此需要等待。
ReleaseLock/RestoreLock 释放所有锁和恢复锁状态
ReaderWriterLock实现IDispose接口,其方法则是以下模式
TryEnter/Enter/Exit ReadLock/WriteLock/UpGradeableReadLock
(以上内容引用自另一篇笔记《ReaderWriterLock》)
CoutdownEvent比较少用的混合构造,这个跟Semaphore相反,体现在Semaphore是在内部计数(也就是信号量)达到最大值的时候让线程阻塞,而CountdownEvent是在内部计数达到0的时候才让线程阻塞。其方法有
AddCount //计数递增;
Signal //计数递减;
Reset //计数重设为指定或初始;
Wait //当且仅当计数为0才不阻塞,否则就阻塞。
Barrier也是一个比较少用的混合构造,用于处理多线程在分步骤的操作中协作问题。它内部维护着一个计数,该计数代表这次协作的参与者数量,当不同的线程调用SignalAndWait的时候会给这个计数加1并且把调用的线程阻塞,直到计数达到最大值的时候,才会释放所有被阻塞的线程。假设还是不明白的话就看一下MSND上面的示例代码
这里给Barrier初始化的参与者数量是3,同时每完成一个步骤的时候会调用委托,该方法是输出count的值步骤索引。参与者数量后来增加了两个又减少了一个。每个参与者的操作都是相同,给count进行原子自增,自增完则调用SgnalAndWait告知Barrier当前步骤已完成并等待下一个步骤的开始。但是第三次由于回调方法里抛出了一个异常,每个参与者在调用SignalAndWait的时候都会抛出一个异常。通过Parallel开始了一个并行操作。假设并行开的作业数跟Barrier参与者数量不一样就会导致在SignalAndWait会有非预期的情况出现。
接下来说两个Attribute,这个估计不算是同步构造,但是也能在线程同步中发挥作用
MethodImplAttribute这个Attribute适用于方法的,当给定的参数是MethodImplOptions.Synchronized,它会对整个方法的方法体进行加锁,凡是调用这个方法的线程在没有获得锁的时候就会被阻塞,直到拥有锁的线程释放了才将其唤醒。对静态方法而言它就相当于把该类的类型对象给锁了,即lock(typeof(ClassType));对于实例方法他就相当于把该对象的实例给锁了,即lock(this)。最开始对它内部调用了lock这个结论存在猜疑,于是用IL编译了一下,发现方法体的代码没啥异样,查看了一些源码也好无头绪,后来发现它的IL方法头跟普通的方法有区别,多了一个synchronized
于是网上找各种资料,最后发现"junchu25"的博客[1][2]里提到用WinDbg来查看JIT生成的代码。
调用Attribute的
调用lock的
对于用这个Attribute实现的线程同步连Jeffrey都不推荐使用。
System.Runtime.Remoting.Contexts.SynchronizationAttribute这个Attribute适用于类,在类的定义中加了这个Attribute并继承与ContextBoundOject的类,它会对类中的所有方法都加上同一个锁,对比MethodImplAttribute它的范围更广,当一个线程调用此类的任何方法时,如果没有获得锁,那么该线程就会被阻塞。有个说法是它本质上调用了lock,对于这个说法的求证就更不容易,国内的资源少之又少,里面又涉及到AppDomain,线程上下文,最后核心的就是由SynchronizedServerContextSink这个类去实现的。AppDomain应该要另立篇进行介绍。但是在这里也要稍微说一下,以前以为内存中就是有线程栈与堆内存,而这只是很基本的划分,堆内存还会划分成若干个AppDomain,在每个AppDomain中也至少有一个上下文,每个对象都会从属与一个AppDomain里面的一个上下文中。跨AppDomain的对象是不能直接访问的,要么进行按值封送(相当于深复制一个对象到调用的AppDomain),要么就按引用封送。对于按引用封送则需要该类继承MarshalByRefObject。对继承了这个类的对象进行调用时都不是调用类的本身,而是通过代理的形式进行调用。那么跨上下文的也需要进行按值封送操作。平常构造的一个对象都是在进程默认AppDomain下的默认上下文中,而使用了SynchronizationAttribute特性的类它的实例是属于另外的一个上下文中,继承了ContextBoundObject基类的类进行跨上下文访问对象时也是通过按引用封送的方式用代理访问对象,并非访问到对象本身。至于是否跨上下文访问对象可以通过的RemotingServices.IsObjectOutOfContext(obj)方法进行判断。SynchronizedServerContextSink是mscorlib的一个内部类。当线程调用跨上下文的对象时,这个调用会被SynchronizedServerContextSink封装成WorkItem的对象,该对象也mscorlib的中的一个内部类,SynchronizedServerContextSink就请求SynchronizationAttribute,Attribute根据现在是否有多个WorkItem的执行请求来决定当前处理的这个WorkItem会马上执行还是放到一个先进先出的WorkItem队列中按顺序执行,这个队列是SynchronizationAttribute的一个成员,队列成员入队出队时或者Attribute判断是否马上执行WorkItem时都需要获取一个lock的锁,被锁的对象也正是这个WorkItem的队列。这里面涉及到几个类的交互,鄙人现在还没完全看清,以上这个处理过程可能有错,待分析清楚再进行补充。不过通过这个Attribute实现的线程同步按鄙人的直觉也是不推荐使用的,主要是性能方面的损耗,锁的范围也比较大。