更详细的锁介绍可以先看看这篇文章: 多线程锁详解之【序章】
正文:
原子操作,顾名思义它的操作具有原子性。所谓原子性就是指动作不可分割且独占数据读写。
什么是操作不可分割?
我们知道系统的线程数总是大于CPU数,系统其实是把这些线程对象放到了一个队列里面,然后轮流把这些线程对象放到CPU核心上,执行对象一部分的代码,周而复始,直至所有的线程代码执行完毕。由于CPU计算速度极快,肉眼上是看不出线程的轮流执行的,但事实上每一个线程对象,确实是轮流地,断断续续地执行,不是连续的。而线程对象这种断断续续执行的特性,我们称之为线程对象的动作是可分割。
既然原子性动作不可分割,那就说明原子操作的动作是一次性完成的。它不会被任何事情打断,执行期间线程不会被切换,线程也不会被挂起什么的,只有等原子操作完成了,线程对应哪个CPU的中断等动作才会被响应,而线程对象这个时候才会被切换或挂起(其实切换和挂起就是通过中断实现的)。
什么是独占数据读写?
假设我有全局变量 g,还有线程 a 和线程 b ,这个时候默认a 和 b 是可以同时访问变量 g ,能够同时访问这种情况叫共享数据读写。
而且独占数据读写的意思就是,在同一个时间,要么只有a 在读写 g, 要么只有 b 在读写 g。如果两个线程同时发起访问,必然只有一个线程能够立刻访问成功,而另外一个会等待上一个访问成功后,再进行访问。
为什么要独占数据读写?
看起来独占数据读写效率更低,为什么原子性不使用共享数据读写呢?因为共享读写会造成脏操作!
比如有代码:
void addup(void) {
static int g = 0;
g++; //a等于多少
}
请问在单线程下调用addup一次 a 等于多少?等于 1,没错。
再请问,在两个线程下,同时各调用addup一次 a 等于多少?等于 2 吗?不一定哦。
我们看看反汇编
void addup(void) {
00852340 push ebp
00852341 mov ebp,esp
00852343 sub esp,0C0h
00852349 push ebx
0085234A push esi
0085234B push edi
0085234C lea edi,[ebp-0C0h]
00852352 mov ecx,30h
00852357 mov eax,0CCCCCCCCh
0085235C rep stos dword ptr es:[edi]
0085235E mov ecx,offset _E0AE75B4_Timer@cpp (085E029h)
00852363 call @__CheckForDebuggerJustMyCode@4 (0851375h)
static int g = 0;
g++; //g等于多少
00852368 mov eax,dword ptr [g (085C138h)] //从内存取 g 值拷贝到 寄存器 eax
0085236D add eax,1 //寄存器 exa 值加 1
00852370 mov dword ptr [g (085C138h)],eax //把寄存器 eax 的值拷贝到内存 g
}
大家理解最后三行代码即可,最后三行就是 g++的汇编代码。可以看出一行C代码,编译后不一定只有一条指令,大部分时候都是多行指令。而且多线程的执行顺序默认是不可控的,若我们对多线程的动作做一个时间排序,大家考虑下面的执行步骤:
- 线程 a 从内存取 g 值放到 cpu1 的eax寄存器,这个时候 cpu1-eax 寄存器值为 0
- 线程 b 从内存取 g 值放到 cpu2 的eax寄存器,这个时候 cpu2-eax 寄存器值为 0
- 线程 a 把 cpu1-eax 值加 1 , 这个时候 cpu1-eax 寄存器值为 1
- 线程 b 把 cpu2-eax 值加 1 , 这个时候 cpu2-eax 寄存器值为 1
- 线程 a 把 cpu1-eax 的值拷贝到内存 g,这个时候内存 g 的值为 1
- 线程 b 把 cpu2-eax 的值拷贝到内存 g,这个时候内存 g 的值为 1
上面的流程是多线程可能出现的情况之一,由此可见,虽然两个线程都对 g 进行了加 1 操作,但 g 最终的结果却未必是 2 。这是因为内存的访问是共享的,只要改成内存独占访问,上面的脏操作就会迎刃而解。
改成独占数据读写后的假想情况:
- cpu1 进行总线锁定,独占内存访问权限
- cpu2 请求进行总线锁定,由于总线被占用,请求失败
- 线程 a 从内存取 g 值放到 cpu1 的eax寄存器,这个时候 cpu1-eax 寄存器值为 0
- cpu2 请求进行总线锁定,由于总线被占用,请求失败
- 线程 a 把 cpu1-eax 值加 1 , 这个时候 cpu1-eax 寄存器值为 1
- cpu2 请求进行总线锁定,由于总线被占用,请求失败
- 线程 a 把 cpu1-eax 的值拷贝到内存 g,这个时候内存 g 的值为 1
- cpu2 请求进行总线锁定,由于总线被占用,请求失败
- cpu1 解锁总线锁定,释放内存访问权限
- cpu2 进行总线锁定,独占内存访问权限
- 线程 b 从内存取 g 值放到 cpu2 的eax寄存器,这个时候 cpu2-eax 寄存器值为 1
- 线程 b 把 cpu2-eax 值加 1 , 这个时候 cpu2-eax 寄存器值为 2
- 线程 b 把 cpu2-eax 的值拷贝到内存 g,这个时候内存 g 的值为 2
- cpu2 解锁总线锁定,释放内存访问权限
独占数据读写后,得到了我们想要的结果!
总线锁定
上面所介绍的独占数据访问方式,术语叫做总线锁定。它限制了其他cpu核心访问内存的权限,保证了数据不会被脏操作,但是总线锁定阻塞了其他cpu访问内存的权限,导致开销极大,那么有没有更好的独占方式呢?
缓存行锁定
由于总线锁定开销极大,所以后面人们发明了缓存行锁定。这里的缓存,指的是CPU自带的缓冲,而并非我们的内存条。我们现在基本都是用x64CPU了,所以一行的缓存就是8个字节(x86CPU是32字节,而且x86CPU有可能不支持缓存行锁定)。而上面的讲述中,我其实省略了一个步骤,那就是
00852368 mov eax,dword ptr [g (085C138h)] //从内存取 g 值拷贝到 寄存器 eax
这句代码,其实它的动作不是直接从内存拷贝的寄存器,而是从内存拷贝到CPU缓存,CPU缓存再拷贝到寄存器。讲述总线锁定的时候,不需要CPU缓存这个知识点,但缓存行锁定就需要了。
那总线锁定跟缓存行锁定的区别是什么呢?
总线锁定它是锁定整个内存条访问。
缓存行锁定是锁定了cpu缓存中的某一行字节(8个字节),它锁定的是一行的缓存。也就是只有多个CPU同时访问这一行的缓存,才会发生访问受限的情况。
情况十分明显,缓存行锁定比总线锁定要高效得多。但缓存行锁定也有自身的限制条件:
一是操作的数据只能锁定一行,锁定多行时处理器换切换为总线锁定。
二是旧的处理器未必支持缓存行锁定功能。
目前不支持缓存行锁定的电脑日常基本看不到了,而且原子操作的参数都是long 或者 longlong,一行CPU缓存足以容纳。所以我们可以说目前的原子操作都是用缓存行锁定完成的。
缓存锁定源码实现
理论说了这么多,我们来看看缓存锁定原子操作的实现源码。
//原子加1
long __stdcall InterlockedIncrement(long volatile* Target)
{
__asm {
mov eax, 1
mov ecx, Target
lock xadd[ecx], eax //相加后交换位置,交换位置是为了把进行运算那一刻的原始值保存起来
inc eax //进行运算的原始值保存到了eax, 执行加1指令作为返回值
//虽然eax里面的原始值还需要加1,但 ecx 里面的值早就加1成功了
}
}
//原子减1
long __stdcall InterlockedDecrement(long volatile* Target)
{
__asm {
mov eax, -1
mov ecx, Target
lock xadd[ecx], eax //相加后交换位置
dec eax //加2指令
}
}
//原子交互
long __stdcall InterlockedExchange(long volatile* Target, long Value)
{
__asm {
mov ecx, Target
mov eax, Value
lock xchg[ecx], eax; //交换位置
}
}
//原子相加
long __stdcall InterlockedExchangeAdd(long volatile* Target, long Value)
{
__asm {
mov ecx, Target
mov eax, Value
lock xadd[ecx], eax; //相加后交换位置
}
}
//原子比较,后交互
long __stdcall InterlockedCompareExchange(long volatile* Destination, long Exchange, long Comperand)
{
__asm
{
mov ecx, Destination;
mov edx, Exchange;
mov eax, Comperand;
lock cmpxchg[ecx], edx; //比较后交换位置
}
}
以 InterlockedIncrement(原子加1函数) 为例,很明显是依赖了lock指令来执行缓存锁定。然而细心的同学可能发现了,lock指令都是在move指令之后,move 内存到寄存器时,仍会出现两个cpu寄存器累计前值都是 0 的风险啊,既然此时两个cpu寄存器的值都是 0, 那么最终计算值就是 1,即时后面的xadd指令加锁也无补于事了。
确实,如果lock指令执行的是互斥锁定这种操作,在这里根本保证不了数据的一致性。其实这是对lock指令理解偏差的问题,我认为把 lock 指令理解为 check (检查)更合适,因为xadd是有回写数据到缓冲这个操作,它在回写前先判断数据是否已经被改写过,如果被改写过,则重新加载缓存到寄存器再计算一遍。也就是说lock xadd 包含了“累加,交换,回写,检查”四个操作。
总线锁定是禁止其他cpu访问资源,会造成其他CPU陷入访问等待的问题,它属于互斥访问,属于悲观锁。
缓存锁定可以理解为乐观锁。乐观锁一般是通过设置单个数据的版本号实现的。首先数据读取到缓冲,这个时候它的版本号是 0 (CPU的原子性数据只有 0 版本号,因为它只能被修改一次),当数据先被 cpu1 读取,再被 cpu2 读取,这个时候cpu2先修改完成,回写到缓冲,那么缓冲的版本就是 1 了,然后cpu1操作完成后,回写时发现缓冲的版本号居然是 1 而不是 0 ,就知道它被修改了,那么这个数据会被重新加载,然后重置版本为 0 ,cpu1 会把 xadd 指令再重新执行一遍,这样数据的原子性就保证了。
网上资料参考
我相信总线锁是比较容易理解的,而缓存行锁的机制却很复杂,所以摘录了网上的一些技术文献:
缓存锁是采用“缓存锁定”将原子操作放在cpu缓存中进行(L1、L2、L3高速缓存)。“缓存锁定”指当发生共享内存的锁定,处理器不会在总线上声言LOCK#信号,而是修改内存地址,并通过缓存一致性机制保证原子性。因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓行的数据时,会使缓存行无效
缓存一致性机制?
缓存一致性机制就整体来说,是当某块CPU对缓存中的数据进行操作了之后,就通知其他CPU放弃储存在它们内部的缓存,或者从主内存中重新读取。
MESI协议?
是以缓存行(缓存的基本数据单位,在Intel的CPU上一般是64字节)的几个状态来命名的(全名是Modified、Exclusive、 Share or Invalid)。该协议要求在每个缓存行上维护两个状态位,使得每个数据单位可能处于M、E、S和I这四种状态之一,各种状态含义如下:
M:被修改的。处于这一状态的数据,只在本CPU中有缓存数据,而其他CPU中没有。同时其状态相对于内存中的值来 说,是已经被修改的,且没有更新到内存中。
E:独占的。处于这一状态的数据,只有在本CPU中有缓存,且其数据没有修改,即与内存中一致。
S:共享的。处于这一状态的数据在多个CPU中都有缓存,且与内存一致。
I:无效的。本CPU中的这份缓存已经无效。
总结四种状态:可分为两种 独占(M和E)共享(S和I)。
独占:M是只有本cpu有,而且缓存已被修改,与内存不一致;E是只有本cpu有,缓存未修改和内存一致。
共享:S是多cpu缓存中都有,该缓存未修改与内存一致;I是多cpu缓存中都有,该缓存修改与内存不一致,该缓存失效。
一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回CPU。
一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。
一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S。
当CPU需要读取数据时,如果其缓存行的状态是I的,则需要从内存中读取,并把自己状态变成S,如果不是I,则可以直接读取缓存中的值,但在此之前,必须要等待其他CPU的监听结果,如其他CPU也有该数据的缓存且状态是M,则需要等待其把缓存更新到内存之后,再读取。
当CPU需要写数据时,只有在其缓存行是M或者E的时候才能执行,否则需要发出特殊的RFO指令(Read Or Ownership,这是一种总线事务),通知其他CPU置缓存无效(I),这种情况下性能开销是相对较大的。在写入完成后,修改其缓存状态为M。