一條C語言語句不一定是原子操作,但是一個彙編指令是原子操作嗎?

比如

一個C語言語句`i++;`:它涉及到三個動作,讀取i變數到寄存器,對其加1,然後保存結果到i變數,它不是原子性的,要保證原子性需要用到鎖,

但是

一個彙編指令`addl (%edx), %eax`:它也有類似的讀取、計算及存儲步驟(流水線),那這樣的一個彙編指令是原子性的嗎?


不保證。不然也不用lock前綴了


為了回答這個問題,我把塵封好幾年的Intel Volume 2A-- Instruction Set Reference都翻出來了。想當年都是枕著它入睡的,有失眠的童鞋么,下一本睡前看看,保證看幾眼就能感覺到那綿綿不絕的睡意撲面而來。

雖然題主用的不是x86的彙編,但是道理是一樣的啦。

我記得以前寫彙編,最喜歡這樣用

mov ecx, 40000h

xor eax, eax

rep stosd

在stosd前面加rep,就是把從ES:[(E)DI] 起,長度為0x40000的一個區域全部初始化成0.

這條rep stosd的指令當然不是原子性的啦,它可以被中斷或異常打斷。

講到LOCK,它也是蠻有用的。

在多處理器環境下,為了保護處理器間的共享資源,在訪問這些共享資源的時候,前面就要加LOCK這個指令前綴。

The LOCK prefix (F0H) forces an operation that ensures exclusive use of shared

memory in a multiprocessor environment. See 「LOCK—Assert LOCK# Signal Prefix」

in Chapter 3, 「Instruction Set Reference, A-M,」 for a description of this prefix.

但不是所有的彙編指令都允許在其前面加上LOCK的。

在x86彙編里,只有以下指令是允許加LOCK的:

ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC,

NEG, NOT, OR, SBB, SUB, XOR, XADD, XCHG.

加在其它的指令前面,會產生異常。講了這麼多理論知識,給一個具體的例子好了。

下圖是處理SMI ISR的一段代碼,SMI是System Management Interrupt,它有一個特點就是要等所有的CPU都處理完了才能退出中斷。進入SMI中斷後,CPU就切換到一個特殊的模式,叫做SMM (System Management Mode)。最先進入SMM的CPU叫做BSP,後面進來的叫AP。下圖中那個bNoOfCPUsInSMM和bSMISyncByte就是BSP和AP間共享的變數,所以在對它進行訪問時就要在指令前面加LOCK。


像題主這樣用彙編直接對內存的操作,在現代CPU里無法保證寫原子性。

在這個問題里:一個簡單的ia32的CPU指令重排序與cache問題,我的推算為什麼得不出示例的結果? - CPU 指令集,就是一個極端的例子。

大概意思是:

多核心的CPU上有兩個線程同時操作臨近的內存。假設兩個內存[_x]和[_y]里原始值都是0。

然後線程1對[_x]賦值,線程2對[_y]賦值,然後兩個線程再分別通過寄存器獲得[_x]的值和[_y]的值,在極端情況下,可能得到結果是0的情況。

因為不同核心使用的緩存是不同的,其中一個核心對緩存的操作,在沒有手工刷新緩存的情況下,通常要等待幾個到幾十時鐘周期左右才會同步其它核心中,如果要訪問的內存數據在同一塊緩存上,那麼就會出現結果不一致

所以,表面上看操作似乎是原子的,但實際上數據未必就是正確的,CPU到內存之間隔著多道緩存,不加鎖前綴的情況下無法保證數據一致性。

即使是單核心CPU,也不一定真就能保證正確性,萬一有DMA或者中斷呢?

唯一能確定是原子操作的,恐怕只有讀寫寄存器了。

另外,跟指令周期無關,跟是什麼指令無關,跟流水線無關,跟偽指令無關,跟對齊有一定關係,但不是直接原因


不一定。

彙編指令也也只是在描述CPU的行為,而沒有具體到每一個細節步驟。操作是否是原子的,不是由彙編指令決定的,而是由CPU如何處理這些指令決定的。

1.有些彙編指令實際上是偽指令,可能對應多條真實的二進位指令,或者只是另外一個指令的語法糖。

2.對於實際的物理硬體可以認為是由時序驅動的,在一個時鐘周期里硬體的各個部分可以並行工作——結果就是多步驟行為完全是能夠在一個時鐘周期里被完成的。而且不能排除一個功能需要多個時鐘周期才能完成。

3.中斷的實現方式也會影響這一結果。CPU究竟在什麼時刻,通過什麼方式來響應中斷,也會影響一項操作是否會被中斷打斷。


請考慮一下多核、多級緩存的情況。


我感覺並不是。比如我上課學的一個晶元。執行(這裡只說執行時間,不包括取指時間) MOV [2000H] AX。要經歷兩個匯流排周期。因為這塊晶元的數據匯流排只有八位,所以一次只能傳輸一個位元組。AX是兩個位元組的寄存器,所以要花費兩個匯流排周期。這明顯就不是一個原子操作了。


這個要看CPU本身的實現了,也只有連流水線都沒有的老386一定可以保證所有ADD的原子性


只有少數指令是,這些指令隱患lock操作,如swap,或者不需要操作匯流排,如sti,cli,dec %rax。

其他操作內存的都不是原子操作,要加前綴lock.

另外是否原子跟指令周期無關,即便是一個指令周期,實際上cpu操作時候也是分很多步驟的。一般也不是直接操作內存的,是操作cpu緩存的。即便是一個周期完成,讀取的緩存可能在同一時刻被另外的cpu修改了。會發生不可預期的後果。


看了這麼多答案,大家都答得不錯。可惜的是沒有一個能讓我覺得滿意的,因為所有的答案都浮於表面,無法觸及本質。

首先,我們需要明白,在什麼情況下討論原子操作和非原子操作才是有意義的。

(1) 只有存在需要訪問共享的資源的時候,原子操作和非原子操作才有差別。當兩段訪問相同資源的代碼(A和B,你也可以認為是兩個CPU,不影響陳述)造成競爭時,原子操作能夠保證結果不是A就是B,也就是說它最終狀態是確定的,而非原子性操作不能保證--例如,A需要寫地址[0--100],而B也需要寫地址[0-100],A寫完[0--50]時,然後B開始寫[0--100],最後A繼續寫[51--100],結果就成了前50位元組為B的結果,後50位元組為A結果。

(2) 當不存在需要訪問共享資源的時候,所有的操作都是原子操作。假設A只需要寫的地址為[0--100],B只需要寫的地址為[200--400],當A和B都運行完成的時候,地址[0--100]以及[200--400]的內容都是唯一確定的。誰敢說他們不是原子性的。如果你要提中斷,那麼很明顯,如果中斷也需要操作[0--100],那麼中斷程序在運行的時候也就變成了(1)中的B了;若中斷程序不需要訪問[0--100],那即使它打斷了A程序,但是等A程序運行完的時候,它的結果也是唯一確定的(我稱它為可打斷的原子操作),與不可打斷的原子操作結果是一樣的。【實際上關於原子操作的確是可以打斷的,它的不可打斷是在邏輯層面上的。】

因此,當不存在資源競爭時,談原子操作和非原子操作沒有意義,因為所有操作都是原子操作。

此時,我們就得到了第1條用於判斷一個操作是否是原子操作的規則:

1 不存在任何資源競爭的操作都是原子操作

這條規則能夠說明,對於不會與其他人競爭資源的操作,它一定是原子的。但是它不能確定與別人存在資源競爭的操作是否是原子的。所以,需要額外的規則。

2 每一個load、stroe到硬體層面都是有序的,因此如果單一store/load指令就能完成的操作肯定是原子性的

3 查找對應指令文檔,若裡面說明這條指令使原子性的,即使它消耗的時間足足為一個小時(僅僅是為了表達長時間的意思),並且需要和所有的其他指令競爭資源,那麼它還是原子操作

【註:對於判定位是的情況,1、2組合以及獨立的3就能得到結果,但是對於判非的情況,必然需要1、2判定為非且3也判定為非】

好了,有了以上三個規則,我們判斷一個操作是不是原子操作就很簡單了。接下來,用幾個例子來說明。

i++,很明顯它會訪問內存(存在與別人競爭),因此第1條規則並不能決定它是否原子性;它的作用是,使用當前值並對i進行+1操作,很明顯,存在兩次內存訪問,因此1和2得到的結論是它不是原子性的。那麼我們是不是就能認為它就不是原子性的呢?肯定不行,因為還沒看過C標準(C的指令文檔)裡面怎麼說的。C的標準裡面並沒有說明它是原子的,因此我們最終可以得到它是非原子操作。

addl (%edx), %eax【addl %eax,(%edx)】,很明顯存在內存操作,需要1和2一定判定。由於它是需要加然後存儲,存在兩次內存訪問,因此1和2得到的結果是不是原子操作。類似前一個問題,intel的文檔裡面沒有說它是原子操作。綜合以上,它不是原子操作。

CMPXCHG x,y,[z],很明顯,1、2得到的結果是非原子操作。但是intel說了,它就是原子操作。綜上,它是原子操作。

li $2,1

li $0,1

addu $s0,$0,$s2

addu $s1,$s0,$2

addu $s2,$s1,$0

addu $s0,$s2,$1

這一段MIPS代碼是不是原子操作?絕對沒人敢說它不是。雖然中斷程序可能會使用這些寄存器,但是它會保護好現場(也就是不會與本段代碼競爭資源),所以最終回來之後,狀態是確定的。

========================================================================

對於intel的lock前綴,就是因為intel的文檔說明在指令進行的動作完成之前匯流排都會被鎖住,因此才有人敢說帶有它作為前綴的指令是原子操作,否則誰敢保證呢?


在多處理器下,顯然不是,因為普通的指令不會鎖住其他CPU,否則還要多處理器幹嘛。。。。

在單處理器下,也不一定,比如缺頁中斷就會在某些指令期間響應


有些是單周期指令,有些是多周期指令,顯然,不一定是。


不能保證。

這個取決於很多東西。

1. 不同的CPU可能把ASSEMBLY轉換成machine code是不同的, 有的可能是一條, 有的可能是幾條。

2. 就像樓上說的對齊問題, 如果不是對齊的data,那麼可能需要load幾次。

總而言之如果那條instruction不能再被拆分, 那麼才是atomic的


好久遠的問題。其他答主已經從理論方面解釋過「一條彙編指令不一定是原子操作」了,那我就貼一下我的實驗代碼,證明一下在多核機器上「一條彙編指令不是原子操作」,而在單核機器上「一條彙編指令原子操作」(實驗系統是 windows 和 linux,編譯器 gcc)。

我為了做這個實驗,還專門去某雲租了一個單核的雲伺服器(還好有學生價==)。

做一個假設:對於單核和多核機器,一條彙編指令是原子操作。

在多核和單核機器上分別實驗。

實驗思路是,有一個保證可見性的全局變數 a,初值為0 ,在主線程里對其進行循環 1000 次 「自減」 操作,同時再開一個線程對其進行與主線程相反的循環 1000 次 「自增」 操作,這兩個線程會同時對 a 進行操作,如果假設成立,那麼最後 a 應該還是0。

於是有了下面的代碼, 「自減」 操作和 「自增」 操作分別用 addl 和 subl 這兩條彙編指令實現。

#include &
#include &
#include &

#ifdef __WIN32
#include &
#elif defined(__linux)
#include &
#define Sleep(us) usleep((us)*1000)
#endif /* __WIN32 or __linux */

volatile int a = 0; // 用於驗證的全局變數 a

// 加1000次1
void * __attribute__((noinline))
add_loop(void * str)
{
for (int i = 0; i &< 1000; i++) { __asm__ __volatile__ ("addl $1, %0" : "+r"(a)); // a = a + 1; Sleep(1); } } // 減1000次1 void sub_loop() { for (int i = 0; i &< 1000; i++) { __asm__ __volatile__ ("subl $1, %0" : "+r"(a)); // a = a - 1; Sleep(1); } } int main() { pthread_t add_tid; pthread_create(add_tid, NULL, add_loop, NULL); // 加1000次1 sub_loop(); // 減1000次1 pthread_join(add_tid, NULL); // join printf("a = %d ", a); // 最後輸出 a 的值,如果假設成立的話,應當是0 return EXIT_SUCCESS; }

在多核機器上執行10次:

輸出的 a 大多數時候都不為0,結論:在多核機器上假設不成立。

接下來在單核機器上實驗:

令人長出一口氣啊喵~

可以看見10次輸出的 a 都為 0,結論:在單核機器上假設成立。

總結

在單核機器上有說法「一條彙編指令是原子操作」,但對於多核機器該說法不成立。

其實想想就知道了, a = a + 1 、 a = a - 100 這倆不都是一條 add 或 sub 彙編指令實現的嘛,匯流排上有多次的數據流動,在多核環境下怎麼可能是原子操作,單核就沒問題了,就一個核,對匯流排操作的核只有它一個,沒核跟它搶匯流排,當然沒問題啦。

所以現實中要實現原子操作的話,就做個判斷,單核就不用加 lock ,多核(Multi-Processor)就加 lock,把匯流排鎖住。


cpu 執行指令的過程:

  1. 根據寄存器中pc計數器的值,從內存中獲取要執行的指令命令

2. 解析指令

3. cpu 執行指令

4. 訪問內存獲取數據

5. 將結果寫回 內存/寄存器

以上,4和5 訪問內存不是必須的,如果4,5中包含多次內存的操作,顯然就不會是原子的。因此,結論是不一定。

參考CPU的工作過程 | 英特爾? 軟體


所有的中斷都不能算吧?


彙編語言-&>機器語言-&>微程序-&>微指令-&>邏輯電路

簡單的說,一條彙編語言指令對應的是一段由CPU生產商寫好並固化在CPU內部的程序。所以,它可以在非常多的地方被打斷。具體情況取決於CPU實現方式。


CPU在收到一條指令後也得按一定步驟進行運算呀,就像一個add指令就得把兩個數分別輸入到ALU然後再給ALU說要進行什麼運算,然後把運算結果放到目的地。

硬體設計發展到現在越來越依賴軟體的方法,每一個指令都對應CPU的ROM上的一個微程序,這個微程序由很多微操作構成,把基本的硬體功能完成之後就按照指令集編寫相應的微程序放到ROM里,這塊兒ROM一次就寫死出廠後就不變了。


有指令是 有的指令不是,因為有的指令是批量傳輸數據,Linux kernel 裡面就有 linus寫的


訪問對齊內存零次或一次的單條指令是原子的。lock前綴的可以保證多次讀寫內存原子。


也不一定吧?看intel的定義了??isa..x86


推薦閱讀:

c語言函數是如何獲取傳入的數組(指針)的指針所指向內容的長度的,有辦法嗎?
C語言零基礎想要自學,有什麼書可以推薦一下嗎?
寫操作系統只能用彙編和 C 語言嗎?
不同的IDE在編譯代碼時是否存在區別?如果有,那請問區別是什麼?
C語言為什麼要有 main 函數?具體作用是什麼?

TAG:編程 | C編程語言 | 彙編語言 | 編譯器 | CPU指令集 |