天天看點

原子性和線程安全

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

繼續閱讀