多线程内容大致分两部分,其一是异步操作,可通过专用,线程池,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 { vo int mFlag = 0; int mValue = 0; public void Thread1() { mValue = 5; mFlag = 1; } public void Thread2() { if (mFlag == 1) Console.WriteLine(mValue); } }</div>
在懂得多线程同步问题的同学们都会知道如果用两个线程分别去执行上面两个方法时,得出的结果有两个:
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</div>
就随便拿其中一个方法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