原子性和線程安全 (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上,這樣呢就不用擔心因為多核而産生的線程安全問題;