一、 引言
多线程编程给我们带来了很好的用户体验,同时也充分了利用了CPU,但带好的好处的同时,我们又要是付出代价的,代价就是我们要考虑很多的问题,例如当多个线程同时竞争共享资源时,则很有可能对共享资源的状态进行破坏,因此我们需要采取一些策略—线程同步来解决这一问题。关于线程同步是一个很深很长的话题,我只是在初探其皮毛时,写一下学习笔记,巩固记忆,关于同步话题打算用多往篇来说明,在此节我们主要介绍在线程同步中相关一些重要概念。
二、 基元用户模式和内核模式构造
1、基元
所谓基元,是指可以在代码中使用的最简单的构造。
有两种基元构造:用户模式和内核模式
2、用户模式
基元用户模式,使用了特殊的CPU指令来协调线程,线程之间的协调是发生在硬件中的,所以它的主要特点是快(相比内核模式)但也意味着OS永远检测不到一个线程在一个基元用户模式中构造上阻塞了,由于用户模式的基元构造上阻塞的一个线程池永远不会认为是已经阻塞,所以线程池不会创建一个新建线程来替换这种临时阻塞的线程,除此之外这些CPU指令只是阻塞极短的一段时间。
常用的技术有:Volatile、Thread.VolatileWrite/Thread.VolatileRead、Interlocked等
优点:
1)、速度快
2)、阻塞时间极短
3)、线程池不会创建一个新的线程(节省资源)
缺点:
只有Windows操作系统内核才能停止一个线程的运行(以避免浪费CPU时间)。在用户模式中运行的线程可能被抢占,但线程会以最快的速度再次调度。所以,想要取得一个资源但又暂时取不到一个线程会一直在用户模式中运行。
因此对于在一个构造上等待的线程,如果拥有这个构造的线程不释放他,前者就可能一直阻塞,如果这是一个用户模式的构造,线程将一直在一个CPU上运行,这种情况称为“活锁”
活锁即浪费CPU又浪费时间,相比内核模式中的死锁更加可怕
3、内核模式:
内核模式的构造是由Windows操作系统自身提供的。它们要求在应用程序的线程中调用操作系统内核的函数。将线程从用户模式切换成内核模式(或相反)会导致巨大的性能损失,这也是为什么要避免使用内核模式的原因。
常用的技术有:Monitor、派生自WaitHanle的AutoRestEvent、Semaphore、Mutex等
优点:一个线程使用一个内核模式的构造获取一个其它线程拥有的资源时,Windows会阻塞线程,使它不浪费CPU的时间。然后当资源变得可用时,Windows会恢复线程,允许它访问资源
缺点:
1)、速度慢,这是因为在内核对象上调用的每个方法都会造成调用线程从托管代码转换为本地用户模式代码,再转换为本地内核模式代码,然后还要朝相反的方向一路返回,这些转换需要大量的CPU时间
2) 、类似于用户模式,会造成“死锁“
4、非阻止同步
非阻止同步构造主要用于完成一些简单的操作而不刻意的用Wndows的内核模式去阻止线程,它涉及如何严格的执行一些原子操作(读、写),我们后续章节介绍的 Thread.VolatileWrite/VolatileRead、Interlocked等都是利用原子操作来达到一个“阻塞”的效果,但他不是真正意义上的“阻塞”而是类似的一个“自旋”操作,它仍然会占用CPU,使用非阻止同步时,不会新增新的线程,这样会减少创建线程的开锁。
5、阻止同步
基元内核模式中的一些方法,例如AutoResetEevent、ManualResetEvent、Semaphore、Mutex、Monitor等都是阻止同步,它利用操作系统的内核,对线程进行真正意义上的阻塞,来保证同一时间,只有一个线程能访问临界区,阻止同步的特点是,减少CPU的利用率,代价是用户模式和内核模式的切换,会带来新的巨大性能损失
三、原子操作
什么是原子操作?原子操作通俗的讲,是一条单独不可分割的一个CPU指令,在多线程访问资源时能够确保所有其他的线程在同一时间访问相同的资源,原子操作是不指不会被线程调度机制打断的操作,这种操作一旦开始,就一直运行到结束,中间不会有任何contextswitch
通常所说的原子操作是相对CPU位数而言
例如在32位CPU中执行以下代码:
class AtomicitySample
{
static int x, y;
static long z;
static void Set()
{
long i;
x = 3; // 原子的
z = 3; // 非原子的 (z是64位)
i = z; // 非原子的(z是64位)
y += x;// 非原子的(读和写的操作)
x++;// 非原子的(读和写的操作)
}
}
对于一个操作是否是原子操作,一般可通过以下方式判断:
1、 一次操作中包含读和写不属于原子操作(自加/减,+=等)
2、 当操作数的所占位数大于CPU的位数时,不属于原子操作,例如32位CPU中
Int64 i=3;
现在我们来总结一下原子操作中几个重要的概念:
1、物理CPU和逻辑CPU
单核CPU的计算机一次只能做一件事情,所以,Windows必须在所有线程(逻辑CPU)之间共享物理CPU,在给定的任何时刻,Windows线程只将一个线程分配给一个CPU,那个线程允许一个“时间片”一旦时间片到期,Windows就会上下文切换到另一个线程,上下文切换完成后,CPU执行所选的线程,直到他的时间片到期,然后又会发生另一个上下文切换。
2、单核CPU中的原子操作
在单核CPU中,我们在使用多线程编程时,是将线程做为一个逻辑CPU来运作的,但实际对应的只有一个逻辑计算单元,即一个物理处理器,操作系统利用线程调度器来调度各个线程,一个线程占用CPU后,经过一个时间片(极短的时间,30毫秒?)后,调度另一个线程,这是线程强占式操作系统的一个特点,这提高了CPU的利用率,更友好了用户体验。
在罗索的重复这些基本的概念主要目的是在于,我们可以知道,在单核CPU中,不会存在两个线程同一时刻占用CPU,比如线程1在进行一个原子性写入时,即原子操作,在CPU执行这个过程时,永远不会进行上下文切换(切换到另一个线程)
我们假设线程1执行这样的操作,Int32 n=0x12345678;
线程2从内存中读取n的值
因为是在单CPU中,因此某一个时刻(尽管持续的时间极短)只有线程1独占CPU,我们假设这个时刻是线程1在执行写入n操作,翻译成汇编指令 MOV DWORD PTR[EBP-40H] 12345678h,我们假如无限放大这个写入时间,比如要1秒钟才能将0x12345678完整的写入内存之中,现在CPU执行到这条指令,正在写入,假如执行了0.5秒钟,这时线程1的时间片结束,理论上来讲,CPU是要切换上下文去执行线程2的操作的,但由于这个写入操作是原子操作(这条指令语句不可分割),因此线程2要等到线程1执行完后续的0.5秒操作后才切换,当然我这个假设是不成立的,而且也不能这样假设,因为原子操作所处的层(layer)的更高层是不能发现其内部实现与结构,但我们可以通过这个假设的去了解什么时候线程进行上下文切换时机的选择
如下图:
之前Int32 n=0x12345678是原子操作,也许上面的那个图,我们可能还是不太理解,那么我现在举一个非原子操作的例子,相信大家会有所了解了
假设:
线程1执行这样的操作:Int64 n=0x0123456789abcdef(在32位CPU中此操作是非原子操作,对于此操作,要产生两条汇编指令)
线程2执行读取n操作(非原子操作,读取时也会产生两条汇编指令)
假设某一时刻,线程1独占CPU,线程1在对0x0123456789abcdef进行写入内存,因为CPU是32位的,因此这个写入操作要产生两条汇编指令,当线程1只执行了一条写入指令,还有另一条指令未执行时,切换到线程2对n进行读取时会产生与我们期望完全不同的结果,例如只取出0x0000000789abcdef
如下图:
通过上面假设的两个例子,我们现在应该明白什么是原子操作,以及非原子操作在多线程中造成的线程同步问题,其实在单CPU中的原子性操作是基于单条指令不可分割这一原则来保证的(但在多核CPU中,由于系统中有多个处理器在独立地运行,即使能在单条指令中完成的操作也有可能受到干扰)
在线程同步中,我们需要某种机制来保证某些操作拥有原子操作的功能,例如使Int64 n=0x0123456789abcdef 这种写入操作变成一个原子操作,只有完全执行完两条汇编指令才能切换上下文,.NET中利用Thread.VolatileWrite/Thread.VolatileRead、或者锁机制来实现这样的功能,这个我们在后续章节详细介绍
3、 多核CPU中的原子操作
在单核系统里,单个的机器指令可以看成是原子操作(如果有编译器优化、乱序执行等情况除外),在任意一个时刻,只能有一条指令在执行,因此这里的原子操作,是不需要任何锁限制,但如果要保证一段代码的临界区的原子性(例如,读写Int64)由于操作系统抢占式切换线程时,这里就可能要用到锁的限制
但在多核系统中,单个的机器指令就不是原子操作,因为多核系统里是多指令流并行运行的,一个核在执行一个指令时,其他核同时执行的指令有可能操作同一块内存区域,从而出现数据竞争现象。
例如在双核系统某一时刻,一个在读A区域的内存,另一个在写A区域的内存,这里就必须用到锁的机制,两条指令具体怎么协调(顺序)是由硬件层面进行仲裁的。
我自己通俗的理解是:当两条指令同时到达硬件(主存)时,硬件有一个类似缓冲区的机制,还是按自己的仲裁维护一个运行时序。在读取操作时,写操作进入一个自旋锁,反之亦然。
思考:
“多核CPU中是怎么保证某些原子操作,比如两个线程分别在两个CPU中运行,并且在同一时刻对同一内存区域进行操作,那么多核CPU是怎么保证线程同步“
有一个网友给我的回复我觉得比较靠谱,回复如下:
“单核CPU因为任何时刻都只能有一条指令,原理上不存在需要锁,但操作系统采用可抢占式时间轮片调度的方式,有可能造成某个线程在进入某块临界区代码时是抢占了,所以提供了一个标志锁。
多核在任何时刻都可以有一条以上的指令在同步执行,这个必须要用到锁。我知道的方式是关中断,然后锁数据总线,利用汇编xchg指令的原子性实现自旋锁。“
如果有网友有自己的理解,非常希望留言交流分享。
四、 CLR内存模型
关于CLR内存模型,我推荐 Angel Lucifer 的 CLR 2.0 Memory Model
参考:
《CLR VIA C#》
http://www.cnblogs.com/JimmyZheng/archive/2012/08/19/2646495.html