一、多处理器和单处理器中为什么需要原子操作?
图1 多CPU内存结构图
首先为什么要有原子操作?
1.1 多处理器中需要原子操作的原因
现在的计算机一般有多个CPU,一个CPU里又有多个核,当多个CPU同时读内存中的某个变量并个性这个变量时,会出现冲突,如两个核同时执行代码 "i++",即两个处理器同时读写同一块内存,出现错误是显然的。
1.2 单处理器中需要原子操作的原因
当计算机只有一个CPU且只有一个核时,是不是就不需要原子操作了呢?
当然不是!当一个核中运行两个线程同时对内存中的一个变量i进行“i++”操作时,底层其实执行了以下三条指令:
(1)lw $1 &i # 把变量i读入寄存器$1
(2)addi $1 $1 1 # 把寄存器$1中的值加1
(3)sw $1 &i # 把寄存器$1中的值写回内存
两个线程在单个处理器中执行以上三条指令会出错的原因是:
(1)每个线程在执行三条指令的任意一条结束后都可能产生线程切换;
(2)寄存器是线程的独立空间,线程间是不共享的。
基于以上两个条件,我们来分析,假设当前内存中i的值是0,单个处理器中出现以下情况:
(1)线程A执行了指令 (1)(lw $1 &i ),则寄存器A中的值是0;
(2)线程A执行了指令(1)之后产生线程切换,线程A保存当前上下文,也就是把所有寄存器的值保存把自己的内核栈空间。
(3)CPU把线程B调度出来运行,让线程B顺利执行完了指令(1)(2)(3),线程B执行完这三条指令后,内存中变量i的值变成了1 。
(4)接着,CPU重新把线程A调度出来运行,CPU需要重新加载线程A的硬件上下文,也就是步骤b)中保存在内核栈中的上下文,加载完成后,寄存器$1的值是0,然后线程A再执行(2)(3)两条指令,最后计算出结果寄存器$1的值为1,再把结果写回内存。
(5)可以看到,在线程A和线程B都执了以上三条指令之后,变量 i 的值是1 。而我们期望变量 i 的值是 2 。
二、原子操作的底层实现
2.1 基于volatile和CAS指令的原子操作
值得注意的是,单独的volatile和CAS操作是不能保证原子性的,因为多个CPU会使用自己独立的缓存。
而当我们把一个内存中的变量声明为volatile时,才可以使用CAS操作来实现原子操作。
Volatile用于修饰一个变量后,会显示的告诉编译器,让CPU每次访问(读或写)都直接访问内存中的变量,而不使用缓存中的值。
CAS:Compare and Set,就是在把变量写回内存之前先判断内存中的变量是否还是一开始读出来的值。
使用一个两个CPU同时做“i++”操作为例,即两个CPU同时执行以上三条指令。假设一开始i的值为0 :
(1)两个CPU同时把0值读入自己的寄存器 $1;
(2)两个CPU同时对寄存器做 $1 加1操作,并同时把值写回内存;
(3)当然,当两个CPU同时写同一块内存时,内存管理系统应该会使用什么机制让两个读操作串行执行,(这一部分作者还未调查清楚,也不是本文的重点内容)。假设没有volatile,则最后两个CPU得出的 i 值是1,是错误的。
对于以上这个场景,我们单一地引入volatile和CAS看看能不能解决原子操作的问题:
(1)把变量 i 声明成volatile:显然,不能解决以上出现的问题,两个CPU同时读入0,计算出1,最后同时把1写回内存。即使是单核单CPU的机器下运行,volatile也不能解决线程冲突问题。
(2)CAS:一段CAS的代码如下:
1 #include <stdio.h>
2 void add1(int * iAddr){
3 int i, a ;
4 do{
5 i = *iAddr ;
6 a = i + 1 ;
7 if( i == *iAddr ){ // CAS
8 *iAddr = a ;
9 break;
10 } // CAS
11 }while(1) ;
12 }
13 int main() {
14 int a = 0;
15 add1(&a) ;
16 return 0;
17 }
以上的代码很简单,假设有两个CPU同时执行以上的代码,要对变量b进行加1操作,在把增加1后得到的值写回内存前先判断一开始读出来的b与现在内存中的b是否相同,对于以上这个代码,有两个原因使其不能实现对变量b的并发修改:
1)由于两个CPU同时读取变量b,CPU的三级缓存是独立的(如图1所示),因此单个CPU第一次执行4~11行这个循环时,执行到第5行会把变量b读入缓存,而在第7行通过 *iAddr获得的是该CPU缓存中的值,因为该值不受其他CPU的影响,因此第7行的判断总是正确的。因此,我们在进行CAS操作时需要变量a是一个volatile变量。
2)代码中使用7~8进行CAS操作,而这一操作并非原子操作,它是一个由多条指令组成的复杂逻辑。对于CAS操作,需要硬件给我们提供一条专门的指令来实现。因此,现在的CPU都是提供CAS指令的。我们假设C语言提供了一个函数“int _cas_(int *addr, int old, int new)”来让我们调用CAS指令,则以上的代码应该改成:
1 #include <stdio.h>
2 void add1(volatile int * iAddr){
3 int i, a ;
4 do{
5 i = *iAddr ;
6 a = i + 1 ;
7 if( __cas__(iAddr, i, a) ) {
8 break;
9 }
10 }while(1) ;
11 }
12 int main() {
13 volatile int a = 0;
14 add1(&a) ;
15 return 0;
16 }
以上就是可以让多个CPU同时对变量a进行加1操作的代码。
2.2 单处理器的原子操作
在2.1中我们分析了在CAS中需要用到volatile声明是因为多个CPU的缓存是独立的,那在单个处理机的情况下,是不是就不需要CAS操作了呢?
作者现在想想,好像确实不需要,但是不确定,也没查到准确资料。
2.3 CAS指令的实现
我们知道,CAS指令假设为 chmxswp &a, $1, $2 可以用以下几条指令来完成:
lw $3 &a
bne $3 $1 end1
sw $2 &a
addi $2 1 # 返回值为1
jal end2
end1: addi $2 0 # 返回值为0
end2:
当然,对于单个处理器的计算机来说,这4个操作合成的 chmxswp就是原子操作,也就是这个处理器会执行完这4个操作,才会产生进程切换。但是在多处理器的CPU中,不同CPU在执行这4条指令时,对于内存来说就是顺序不确定的,这时chmxswp就会是线程不安全的。因此,一个完成的CAS操作会包含两条指令,
LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
前面的的LOCK_IF_MP(%4) 是一个宏定义,其实现为:
#define LOCK_IF_MP(mp) "cmp $0, " #mp "; je 1f; lock; 1: "
可以看到,LOCK_IF_MP(%4)的意思是如果当前环境是MP(multiple proccesors)就lock;1 。发出一个锁信号。这个锁信号用于锁住BUS总线(也有人说是锁北桥信号,这个有待作者确认)。
另外,在 理论上,多处理器计算机中,CAS的实现方法一般有以下两种:
(1)总线锁定
当一个处理器要操作共享变量时,在 BUS 总线上发出一个 Lock 信号,其他处理就无法操作这个共享变量了。
缺点很明显,总线锁定在阻塞其它处理器获取该共享变量的操作请求时,也可能会导致大量阻塞,从而增加系统的性能开销。
(2)缓存锁定
后来的处理器都提供了缓存锁定机制,也就说当某个处理器对缓存中的共享变量进行了操作,其他处理器会有个嗅探机制,将其他处理器的该共享变量的缓存失效,待其他线程读取时会重新从主内存中读取最新的数据,基于 MESI 缓存一致性协议来实现的。
现代的处理器基本都支持和使用的缓存锁定机制。
注意:
有如下两种情况处理器不会使用缓存锁定:
(1)当操作的数据跨多个缓存行,或没被缓存在处理器内部,则处理器会使用总线锁定。
(2)有些处理器不支持缓存锁定,比如:Intel 486 和 Pentium 处理器也会调用总线锁定。
2.3 CAS的问题
(1)循环时间长开销大:进程反复地尝试修改变量,CPU不停地转。
(2)ABA问题:有一个进程将变量从A改成B,又从B改成A,另一个进程意识不到。(这能引发什么实质性的问题呢???)
(3)只能保证一个共享变量的原子操作