原子性和线程安全 (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 | |
0 |
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 | |
0 |
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
| |
0 |
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);
}
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
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上,这样呢就不用担心因为多核而产生的线程安全问题;