1. 程式人生 > >順序性,一致性,原子性:現代多核體系結構與原子操作·CAS與自旋鎖·自旋鎖與併發程式設計的原語·語句原子性和程式設計邏輯的原子性·行鎖與資料庫事務原子性·binlog與資料庫同

順序性,一致性,原子性:現代多核體系結構與原子操作·CAS與自旋鎖·自旋鎖與併發程式設計的原語·語句原子性和程式設計邏輯的原子性·行鎖與資料庫事務原子性·binlog與資料庫同

順序性:

亂序執行·邏輯正確性 

現代體系結構的每一個核的指令流水是亂序執行的,但是他能夠保證其執行效果正確,即等同於順序執行。

不過這帶來的問題是對於一個核在主觀上它的執行狀態最終保證正確,但是對於別的核,如果在某一箇中間時間點需要觀察它呢?看到的是一個不正確的中間狀態對應的資料:

亂序中間態:

core1: 

asm write a=x(沒提交)

asm write b=y(已提交)

core2:

asm if(b==y)   

           assert(a==x) // 出錯了,因為core1亂序提交!

一般情況下,我們可以容忍這類問題發生。

但是當 write b=y 是一個非常重要的多核控制原語的時候,這類問題就無法容忍了。

區域性順序性與區域性正確中間態:

杜絕這種問題的關鍵是讓 write b=y操作滿足區域性順序性,從而在該操作上得到區域性正確中間態即該操作一旦執行成功,則前面的操作都執行成功。該操作如果沒有執行,那麼後面的操作也都沒有執行。  也即任何時刻只要b==y,那麼a==x。

一致性:

各核及執行緒快取·volatile

各核快取及執行緒快取不一致是影響併發平行計算正確性的一大問題。

如果上層程式設計邏輯需要使他們可見的值保持一致,則可以引入volatile。

原子性:

1 多核體系結構與多核原子操作

http://my.oschina.net/jcseg/blog/316726

 一. 何謂"原子操作":


原子操作就是: 不可中斷的一個或者一系列操作, 也就是不會被執行緒排程機制打斷的操作, 執行期間不會有任何的上下文切換(context switch).

多核原子操作:不可打斷(原子),不可干擾(互斥=》序列=》最高隔離)。在原子操作基礎上,不被其他核上執行的指令干擾的指令操作。如何不被其他核指令干擾?記憶體是多核共用的,所以當本核訪問記憶體的時候,其他核都不能訪問。下文會講到匯流排鎖。

二. 為什麼關注原子操作?
1. 如果確定某個操作是原子的, 就不用為了去保護這個操作而加上會耗費昂貴效能開銷的鎖. - (巧妙的利用原子操作和實現無鎖程式設計)
2. 藉助原子操作可以實現互斥鎖(mutex). (linux中的mutex_lock_t)


3. 藉助互斥鎖, 可以實現讓更多的操作變成原子操作. 

三. 單核CPU的原子操作:
在單核CPU中, 能夠在一個指令中完成的操作都可以看作為原子操作, 因為中斷只發生在指令間.

四. 多核CPU的原子操作:
在多核CPU的時代(確實moore定律有些過時了,我們需要更多的CPU,而不是更快的CPU,無法處理快速CPU中的熱量散發問題), 體系中執行著多個獨立的CPU, 即使是可以在單個指令中完成的操作也可能會被幹擾. 典型的例子就是decl指令(遞減指令), 它細分為三個過程: "讀->改->寫", 涉及兩次記憶體操作. 如果多個CPU執行的多個程序在同時對同一塊記憶體執行這個指令, 那情況是無法預測的

五. 硬體支援 & 多核原子操作:
軟體級別的原子操作是依賴於硬體支援的. 在x86體系中, CPU提供了HLOCK pin引線, 允許CPU在執行某一個指令(僅僅是一個指令)時拉低HLOCK pin引線的電位, 直到這個指令執行完畢才放開.  從而鎖住了匯流排, 如此在同一匯流排的CPU就暫時無法通過匯流排訪問記憶體了, 這樣就保證了多核處理器的原子性(個人理解:另外使得cpu強制序列性,該條指令不能和任何其他指令之間發生亂序提交). (想想這機制對效能影響挺大的).  

關於為什麼本文所講的“多核原子操作”要鎖匯流排

記憶體屏障的結果,是在操作原子性基礎上實現核間高度隔離以及區域性順序性
1. 核間高度隔離:鎖對其他核上的記憶體操作(不管R/W)互斥,從而為本操作提供最高級別隔離性。
2. 區域性順序性:如上文,加匯流排鎖之後還會使得本核的指令流水在此序列化,防止本指令相對之前和之後的其他指令發生亂序提交,提供區域性狀態順序性。
http://blog.codingnow.com/2007/12/fence_in_multi_core.html


六. 哪些操作可以確定為原子操作了?
對於非long和double基本資料型別的"簡單操作"都可以看作是原子的. 例如: 賦值和返回. 大多數體系中long和double都佔據8個位元組, 作業系統或者JVM很可能會將寫入和讀取操作分離為兩個單獨的32位的操作來執行, 這就產生了在一個讀取和寫入過程中一個上下文切換(context switch), 從而導致了不同任務執行緒看到不正確結果的的可能性.

遞增, 遞減不是原子操作: i++反彙編的彙編指令: (需要三條指令操作, 和兩個記憶體訪問, 一次暫存器修改)
?
1 2 3 movl i, %eax                            //記憶體訪問, 讀取i變數的值到cpu的eax暫存器 addl $1, %eax                         //增加暫存器中的值 movl %eax, i                            //寫入暫存器中的值到記憶體


七. 如何實現++i和i++的原子性: 
1. 單CPU, 使用鎖或則禁止多執行緒排程, 因為本身單核CPU的併發就是偽併發. (在單核CPU中, 在沒有阻塞的程式中使用多執行緒是沒必要的).
2. 多核CPU, 就需要藉助上面說道的CPU提供的Lock, 鎖住匯流排. 防止在"讀取, 修改, 寫入"整個過程期間其他CPU訪問記憶體. (那麼“讀寫,修改,寫入”這個操作會不會在在單核中發生執行緒的切換呢?)

八. Linux提供的兩個原子操作介面:
1. 原子整數操作針對整數的原子操作只能對atomic_t型別的資料處理。這裡沒有使用C語言的int型別,主要是因為:
1) 讓原子函式只接受atomic_t型別運算元,可以確保原子操作只與這種特殊型別資料一起使用.
2) 使用atomic_t型別確保編譯器不對相應的值進行訪問優化. (原理為: 變數被volatile修飾了)
3) 使用atomic_t型別可以遮蔽不同體系結構上的資料型別的差異。儘管Linux支援的所有機器上的整型資料都是32位,但是使用atomic_t的程式碼只能將該型別的資料當作24位來使用。這個限制完全是因為在SPARC體系結構上,原子操作的實現不同於其它體系結構:32位int型別的低8位嵌入了一個鎖,因為SPARC體系結構對原子操作缺乏指令級的支援,所以只能利用該鎖來避免對原子型別資料的併發訪問。

原子整數操作最常見的用途就是實現計數器。原子整數操作列表在中定義。原子操作通常是內斂函式,往往通過內嵌彙編指令來實現。如果某個函式本來就是原子的,那麼它往往會被定義成一個巨集。

在編寫核心時,操作demo如下:

?
1 2 3 4 atomic_t cnt; atomic_set(&cnt, 2); atomic_add(4, &cnt); atomic_inc(cnt);


2. 原子位操作:
原子位操作定義在檔案中。令人感到奇怪的是位操作函式是對普通的記憶體地址進行操作的。原子位操作在多數情況下是對一個位元組長的記憶體(注1)訪問,因而位號該位於0-31之間(在64位機器上是0-63之間),但是對位號的範圍沒有限制。

注1:作業系統可以確保,在同一時刻,只有一個CPU的一個程序訪問特定的某個位元組,再加上單核中的原子性(基本資料型別的簡單操作),所以單位元組記憶體的簡單操作是具有天生的多核原子性的。

編寫核心程式碼,把要操作的資料的指標給操作函式,就可以進行位操作了:

?
1 2 3 4 5 unsigned long var = 0; set_bit(0, &var);           /*set the 0th bit*/ set_bit(1, &var);           /*set the 1th bit*/ clear_bit(1, &var);         /*clear the 1th bit*/ change_bit(0, &var);        /*change the 1th bit*/


九. spinlock CPU同步:
spin lock必須基於CPU的資料匯流排鎖定, 它通過讀取一個記憶體單元(spinlock_t)來判斷這個spinlock是否已經被別的CPU鎖住. 如果否, 它寫進一個特定值, 表示鎖定了匯流排, 然後返回. 如果是, 它會重複以上操作直到成功, 或者spin次數超過一個設定值. 記住上面提及到的: 鎖定資料匯流排的指令只能保證一個指令操作期間CPU獨佔資料匯流排. (spinlock在鎖定的時侯, 不會睡眠而是會持續的嘗試).

2. 原子操作CAS與自旋鎖

spinlock又稱自旋鎖,執行緒通過busy-wait-loop的方式來獲取鎖,任時刻只有一個執行緒能夠獲得鎖,其他執行緒忙等待直到獲得鎖。spinlock在多處理器多執行緒環境的場景中有很廣泛的使用,一般要求使用spinlock的臨界區儘量簡短,這樣獲取的鎖可以儘快釋放,以滿足其他忙等的執行緒。Spinlock和mutex不同,spinlock不會導致執行緒的狀態切換(使用者態->核心態),但是spinlock使用不當(如臨界區執行時間過長)會導致cpu busy飆高。
spinlock與mutex對比

 優缺點比較

  spinlock不會使執行緒狀態發生切換,mutex在獲取不到鎖的時候會選擇sleep。
  mutex獲取鎖分為兩階段,第一階段在使用者態採用spinlock鎖匯流排的方式獲取一次鎖,如果成功立即返回;否則進入第二階段,呼叫系統的futex鎖去sleep,當鎖可用後被喚醒,繼續競爭鎖。
  Spinlock優點:沒有昂貴的系統呼叫,一直處於使用者態,執行速度快。
  Spinlock缺點:一直佔用cpu,而且在執行過程中還會鎖bus匯流排,鎖匯流排時其他處理器不能使用匯流排。
  Mutex優點:不會忙等,得不到鎖會sleep。
  Mutex缺點:sleep時會陷入到核心態,需要昂貴的系統呼叫。

  Spinlock使用準則:臨界區儘量簡短,控制在100行程式碼以內,不要有顯式或者隱式的系統呼叫,呼叫的函式也儘量簡短。例如,不要在臨界區中呼叫read,write,open等會產生系統呼叫的函式,也不要去sleep;strcpy,memcpy等函式慎用,依賴於資料的大小。

spinlock系統實現

  spinlock的實現方式有多種,但是思想都是差不多的,glibc-2.9中的實現方法:
int pthread_spin_lock (lock) pthread_spinlock_t *lock;
{
asm ("\n"
"1:\t" LOCK_PREFIX "decl %0\n\t"
"jne 2f\n\t"
".subsection 1\n\t"
".align 16\n"
"2:\trep; nop\n\t"
"cmpl $0, %0\n\t"
"jg 1b\n\t"
"jmp 2b\n\t"
".previous"
: "=m" (*lock)
: "m" (*lock));
return 0;
}
  執行過程:
  1,lock_prefix 即 lock。lock decl %0,鎖匯流排將%0(即lock變數)減一。Lock可以保證接下來一條指令的原子性。
  2, 如果lock=1,decl的執行結果為lock=0,ZF標誌位為1,直接跳到return 0;否則跳到標籤2。也許要問,為啥能直接跳到return 0呢?因為subsection和previous之間的程式碼被編譯到別的段中,因此jne之後緊接著的程式碼就是 return 0 (leaveq;retq)。Rep nop在經過編譯器編譯之後被編譯成 pause。
  3, 如果跳到標籤2,說明獲取鎖不成功,迴圈等待lock重新變成1,如果lock為1跳到標籤1重新競爭鎖。
  該實現採用的是AT&T的彙編語法,更詳細的執行流程解釋可以參考“五竹”大牛的文件。
  3.2,系統自帶(glibc-2.3.4)spinlock反彙編程式碼:
  系統環境:
2.6.9-89.ELsmp #1 SMP x86_64 x86_64 x86_64 GNU/Linux
(gdb) disas pthread_spin_lock
Dump of assembler code for function pthread_spin_lock:
//eax暫存器清零,做返回值
0x0000003056a092f0 <pthread_spin_lock+0>: xor %eax,%eax
//rdi存的是lock鎖地址,原子減一
0x0000003056a092f2 <pthread_spin_lock+2>: lock decl (%rdi)
//杯了個催的,加鎖不成功,跳轉,開始busy wait
0x0000003056a092f5 <pthread_spin_lock+5>: jne 0x3056a09300 <pthread_spin_lock+16>
//終於夾上了…加鎖成功,返回
0x0000003056a092f7 <pthread_spin_lock+7>: retq
……………………………………….省略若干nop……………………………………….
0x0000003056a092ff <pthread_spin_lock+15>: nop
//pause指令降低CPU功耗
0x0000003056a09300 <pthread_spin_lock+16>: pause
//檢查鎖是否可用
0x0000003056a09302 <pthread_spin_lock+18>: cmpl $0×0,(%rdi)
//回跳,重新鎖匯流排獲取鎖
0x0000003056a09305 <pthread_spin_lock+21>: jg 0x3056a092f2 <pthread_spin_lock+2>
//長夜漫漫,愛上一個不回家的人,繼續等~
0x0000003056a09307 <pthread_spin_lock+23>: jmp 0x3056a09300 <pthread_spin_lock+16>
0x0000003056a09309 <pthread_spin_lock+25>: nop
……………………………………….省略若干nop……………………………………….
End of assembler dump.
Glibc的彙編程式碼還是很簡潔的,沒有多餘的程式碼。

總結

CAS是最常見的保證原子性的WAR(write after read)指令。如上文所述:指令原子性意味著即使在多核cpu上,通過鎖匯流排的方式,能夠保證該指令執行過程中不會有其他衝突的R/W指令並行執行。 自旋鎖一般實現方法是: 

SpinLock:

= while(true){ CAS(&volatile t) } 檢視上文glibc原始碼,這個函式是用嵌入彙編實現的,可能看不到有CAS指令,但有同義的原子操作,如單核的關搶佔,多核的鎖匯流排指令。
 //https://github.com/wh5a/jos/commit/8223e70a9e8c9942f2fd02b6d4e046c7e6da34ed
 +spinlock_acquire(struct spinlock *lk)
 +{
 +	if(spinlock_holding(lk))
 +		panic("recursive spinlock_acquire");
 +
 +	// The xchg is atomic.
 +	// It also serializes,
 +	// so that reads after acquire are not reordered before it. 
 +	while(xchg(&lk->locked, 1) != 0)
 +		pause();	// let CPU know we're in a spin loop
 +
 +	// Record info about lock acquisition for debugging.
 +	lk->cpu = cpu_cur();
 +	debug_trace(read_ebp(), lk->eips);
 +}

 // Atomically set *addr to newval and return the old value of *addr.
 +static inline uint32_t
 +xchg(volatile uint32_t *addr, uint32_t newval)
 +{
 +<span style="white-space:pre">	</span>uint32_t result;
 +
 +<span style="white-space:pre">	</span>// The + in "+m" denotes a read-modify-write operand.
 +<span style="white-space:pre">	</span>asm volatile("lock; xchgl %0, %1" :
 +<span style="white-space:pre">	</span>       "+m" (*addr), "=a" (result) :
 +<span style="white-space:pre">	</span>       "1" (newval) :
 +<span style="white-space:pre">	</span>       "cc");
 +<span style="white-space:pre">	</span>return result;
 +}
 +
 +/* While a spinlock will work if you just do nothing in the loop,
 +   Intel has defined a special instruction called PAUSE that notifies
 +   the processor that a spin loop is in progress and can improve
 +   system performance in such cases, especially on "hyper-threaded"
 +   processors that multiplex a single execution unit among multiple
 +   virtual CPUs.
 +*/
 +static inline void
 +pause(void)
 +{
 +<span style="white-space:pre">	</span>asm volatile("pause" : : : "memory");
 +}

SpinUnlock:

= t<=xx

3 自旋鎖與併發程式設計原語

通過while(true){CAS}/自旋鎖的加鎖解鎖可以實現“對多執行緒/多程序 保持原子性的臨界區程式碼”,這些臨界區程式碼通常可以是併發程式設計庫裡最關鍵的併發原語(原語:原子性statement),如加鎖、去鎖、睡眠、喚醒、操作訊號量,這些原語用於實現更高層的併發機制如互斥鎖、訊號量,以及併發資料結構。

4 語句原子性和程式設計邏輯的原子性

考慮 這樣一個WAR的程式設計邏輯: if(read(Vector[x])==xx) write(vector[x]) vector號稱是多執行緒安全的,也即每一條vector的讀寫語句read(),write()都是多執行緒互斥的(即原子的)。 但當這些語句組合成程式設計邏輯的時候,整個邏輯並不是對多執行緒原子的,中間可能被打斷或被幹擾。所以需要對整個邏輯加鎖,實現程式設計邏輯原子性。

5 鎖與資料庫事務原子性

資料庫事務:

事務是指對系統進行的一組操作,為了保證系統的完整性,事務需要具有ACID特性,具體如下:

1. 原子性(Atomic)

     一個事務包含多個操作,這些操作要麼全部執行,要麼全都不執行。實現事務的原子性,要支援回滾操作,在某個操作失敗後,回滾到事務執行之前的狀態。
     回滾實際上是一個比較高層抽象的概念,大多數DB在實現事務時,是在事務操作的資料快照上進行的(比如,MVCC),並不修改實際的資料,如果有錯並不會提交,所以很自然的支援回滾。
     而在其他支援簡單事務的系統中,不會在快照上更新,而直接操作實際資料。可以先預演一邊所有要執行的操作,如果失敗則這些操作不會被執行,通過這種方式很簡單的實現了原子性。

2.隔離性(Isolation)

     併發事務之間互相影響的程度,比如一個事務會不會讀取到另一個未提交的事務修改的資料。在事務併發操作時,可能出現的問題有:
髒讀:事務A修改了一個數據,但未提交,事務B讀到了事務A未提交的更新結果,如果事務A提交失敗,事務B讀到的就是髒資料。
不可重複讀:在同一個事務中,對於同一份資料讀取到的結果不一致。比如,事務B在事務A提交前讀到的結果,和提交後讀到的結果可能不同。不可重複讀出現的原因就是事務併發修改記錄,要避免這種情況,最簡單的方法就是對要修改的記錄加鎖,這回導致鎖競爭加劇,影響效能。另一種方法是通過MVCC可以在無鎖的情況下,避免不可重複讀。
幻讀:在同一個事務中,同一個查詢多次返回的結果不一致。事務A新增了一條記錄,事務B在事務A提交前後各執行了一次查詢操作,發現後一次比前一次多了一條記錄。幻讀是由於併發事務增加記錄導致的,這個不能像不可重複讀通過記錄加鎖解決,因為對於新增的記錄根本無法加鎖。需要將事務序列化,才能避免幻讀。
     事務的隔離級別從低到高有:
Read Uncommitted:最低的隔離級別,什麼都不需要做,一個事務可以讀到另一個事務未提交的結果。所有的併發事務問題都會發生。
Read Committed:只有在事務提交後,其更新結果才會被其他事務看見。可以解決髒讀問題
Repeated Read:在一個事務中,對於同一份資料的讀取結果總是相同的,無論是否有其他事務對這份資料進行操作,以及這個事務是否提交。可以解決髒讀、不可重複讀
 Serialization:事務序列化執行,隔離級別最高,犧牲了系統的併發性。可以解決併發事務的所有問題
     通常,在工程實踐中,為了效能的考慮會對隔離性進行折中。

3. 一致性(Consistency)

     一致性是指事務使得系統從一個一致的狀態轉換到另一個一致狀態。事務的一致性決定了一個系統設計和實現的複雜度。事務可以不同程度的一致性:
強一致性:讀操作可以立即讀到提交的更新操作。
弱一致性:提交的更新操作,不一定立即會被讀操作讀到,此種情況會存在一個不一致視窗,指的是讀操作可以讀到最新值的一段時間。
最終一致性:是弱一致性的特例。事務更新一份資料,最終一致性保證在沒有其他事務更新同樣的值的話,最終所有的事務都會讀到之前事務更新的最新值。如果沒有錯誤發生,不一致視窗的大小依賴於:通訊延遲,系統負載等。
     其他一致性變體還有:
單調一致性:如果一個程序已經讀到一個值,那麼後續不會讀到更早的值。
會話一致性:保證客戶端和伺服器互動的會話過程中,讀操作可以讀到更新操作後的最新值。       SQL的R/W普通語句構成了資料庫事務。通過加鎖,保證了語句原子性:不可干擾。

6 資料庫事務原子性與binlog、主從同步讀寫分離

通過加鎖(表鎖、行鎖),使得資料庫操作變成一條條原子性的事務和普通語句。 這些原子操作可能是亂序並行執行的,但執行效果卻可以保證是等同於序列提交的。 binlog按照提交順序(通過提交時的時間戳)記錄這些原子操作,能夠保證重放binlog即可復原當前資料庫。 因此binlog是資料庫復原、主從同步的最主要依據。