原子性和线程安全 (xingchongatgmaildotcom)
指令原子性
在单核时代,每条指令的执行都是原子的,即指令执行的中间状态是外部不可见的。这是cpu必须要保证的。从逻辑上来说,cpu只需要保证中断/异常触发的时候,当时要执行的指令要么完成,要么看起来和没开始一样。这叫做状态一致 (state consistent). 保证了状态一致性也就保证了原子性。
在多核时代,情况就不一样了,一条指令的执行就不一定是原子的。比如x86上的inc 指令可以将一个memory值加1; inc [mem]; // 对mem进行 +1 操作; 我们可以把这条指令的执行理解成三步: 1. load [mem] -> reg; 2. reg = reg+1; 3. store reg -> [mem];
假设一个多线程程序,两个线程分别运行在CPU0和CPU1上,都去对同一个内存执行inc操作,可能发生下面的情况:
CPU0 | CPU1 | |
Begin: [mem] = 0 | ||
1 | load [mem] -> reg // reg = 0 | |
2 | load [mem] -> reg // (reg = 0) | |
3 | reg = reg + 1 // reg = 1 | reg = reg + 1 // reg = 1 |
4 | store reg -> [mem] // [mem] = 1 | |
5 | store reg -> [mem] // [mem] = 1 | |
6 | End: [mem] = 1 |
上表中++ 操作在两个CPU上交替进行,+1 操作在两个CPU上执行,但结果确是 +1; 而非+2。这就是由于指令执行的非原子性造成的。 为了解决这个问题呢, x86 cpu引入了支持多核的原子操作:lock前缀。 lock inc [mem]; 加了lock之后了,在指令执行中间其他cpu核就不能访问那块内存。 下表显示了两个CPU分别执行 lock inc [mem] 的过程
CPU0 | CPU1 |
Begin: [mem] = 0 | |
1 | load [mem] -> reg // reg = 0 |
2 | reg = reg + 1 // reg = 1 |
3 | store reg -> [mem] // [mem] = 1 |
4 | load [mem] -> reg // reg = 1 |
5 | reg = reg + 1 // reg = 2 |
6 | store reg -> [mem] // [mem] = 2 |
7 | End: [mem] = 2 |
原子性与多核:
inc [mem] | lock inc [mem] | |
单核 | 原子 | 原子 |
多核 | 非原子 | 原子 |
如果你的多线程程序要支持在多核上运行;lock前缀是必要的;
gvar++线程安全吗?
gvar 是一个全局变量,gvar++线程安全吗?多核上?单核上? 首先我们要看编译器是怎么编译gvar++,大体来说有两种情况: 第一种:编译成,inc [gvar]; inc指令可以直接对memory操作; 第二种:先把memory值加载到寄存器,寄存器+1,然后写回内存。 load reg = [gvar]; reg = reg + 1; store [gvar] = reg; 前面讨论过,对于inc [gvar],CPU内部实现也是三步操作:load,add,store; 但是从原子性的角度考虑是有差别的,第一种情况只有一条指令,在单核上是原子的;第二种情况有三条指令,即使在单核上也是非原子的。
试想如果线程切换发生在load/store中间,会发生什么呢?看下表的执行流程 (单CPU)
线程0 | 线程1 |
Begin: gvar [mem] = 0 | |
1 | load [mem] -> reg // reg = 0 |
2 | 调度发生,切换到线程1 |
3 | load [mem] -> reg // reg = 0 |
4 | reg = reg + 1 // reg = 1 |
5 | store reg -> [mem] // [mem] = 1 |
6 | 调度发生,切换到线程0 |
7 | reg = reg + 1 // reg = 1 |
8 | store reg -> [mem] // [mem] = 1 |
9 | End: gvar [mem] = 1 |
结论是: gvar ++ 多核上必然非原子的 单核上就看编译出来的代码了
程序怎么写来保证gvar++原子性
三种方法: 1. 通过另外的锁;(这不是本文要介绍的内容;) 2. 写汇编(内嵌汇编)来使用"lock"前缀; 3. 使用编译器builtin函数。编译器实现了很多内置函数,包括这些院子操作,供程序调用。具体参看 ( http://gcc.gnu.org/onlinedocs/gcc-4.4.2/gcc/Atomic-Builtins.html)。对于inc操作,可以调用__sync_fetch_and_add; 看如下的示例代码: $ cat main.c
int func(int i)
{
return __sync_fetch_and_add(&i, 1);
}
$ gcc main.c -o main -c -O2 $ objdump -d main 00000000 <func>:
0: 55 push %ebp
1: b8 01 00 00 00 mov $0x1,%eax
6: 89 e5 mov %esp,%ebp
8: f0 0f c1 45 08 lock xadd %eax,0x8(%ebp)
d: 5d pop %ebp
e: c3 ret
__sync_fetch_and_add函数被inline进了调用函数,也是通过lock前缀来实现的:lock xadd %eax,0x8(%ebp)
推荐使用编译器builtin函数,自己写汇编的话容易出错;
Affinity: 如果你不想用原子操作,但是也想保证多线程程序正确性,还有一招:affinity ( http://www.kernel.org/doc/man-pages/online/pages/man2/sched_setaffinity.2.html) 通过使用affinity, 可以让某一个程序或者线程邦定在某个CPU上,这样呢就不用担心因为多核而产生的线程安全问题;