淺析c語言的volatile關鍵字及數據一致性

1 一些基本概念

1.1 現代處理器的存儲體系

現代處理器爲了提升並行性能通常採用多核結構,同時考慮到CPU訪問寄存器的速度遠高於訪問內存的速度,因此引入了緩存以提高訪存效率,且通常都有多級緩存,比較常見的緩存參數是:獨享的1級數據緩存、1級指令緩存、2級緩存,以及共享的3級緩存。結構圖如下:
在這裏插入圖片描述
一般緩存是可以失能(關閉)的,但除了操作系統內核啓動初期等一些特殊的時刻會暫時關閉緩存,一般都會開始緩存,因爲有/無緩存的性能差異實在是太大了。而在緩存使能的狀態下,CPU在訪存時通常是繞不開緩存的,以ARM體系結構的一條訪存指令爲例:

ldr r3, [sp, #4]

這條指令的作用是從sp + 4指向的棧內存(通常是一個局部變量)中讀取數據,讀到r3寄存器。執行這條指令使,CPU首先會檢查緩存,若緩存命中(緩存中保存了要讀取的值),則直接從緩存中取數;若緩存未命中,則去內存中將sp + 4指向的內存及其附近的數據一併填充到一個緩存行(cache line),此時數據已經在緩存中,於是CPU將該數加載到r3寄存器。

1.2 內存與I/O統一編址

對於CPU如何訪問外設,不同的體系結構有不同的設計。比如x86設計了專門的訪問外設的指令INOUT,而ARM則沒有這樣的指令,其採用了內存與I/O統一編址的設計,訪問外設在形式上和訪問內存是一樣的。通俗的解釋這種技術:

訪問外設通常需要特定的時序,軟件來實現比較困難,因此集成電路工程師專門設計了外設控制器來控制相應的外設,外設控制器一方面提供外設所需的接口和時序;一方面向CPU提供出一系列的寄存器,CPU只需要合理的設置外設控制器的寄存器就可以訪問相應的外設了。比如外設控制器提供了控制寄存器數據寄存器,CPU只要找到控制寄存器並向其中寫1就可以打開外設,寫0就可以關閉外設;而向數據寄存器寫數據就可以實現向外設發送數據,從數據寄存器讀取數據就可以實現從外設接收數據。現在只剩下一個問題:CPU如何找到外設控制器的一系列寄存器?答案很簡單:向尋址內存一樣即可。換句話說,外設控制器的寄存器被編址到了內存地址空間,舉個例子就更清楚了:

#define control_register (*(volatile unsigned long *)0x66666666)
control_register = 0; /* 關閉外設 */
control_register = 1; /* 打開外設 */

再用一幅圖來說明內存地址空間的情況:
在這裏插入圖片描述
如此一來,CPU訪問程序、數據和外設的方式得到了統一,有點一切皆訪存 的意味。

1.3 修改內存單元的兩條路子

有了上述鋪墊,我們就不難獲知有哪些修改內存單元的途徑,注意這裏的內存單元並不是指狹義的SDRAM存儲單元,而是指內存地址空間所有可以訪問的單元。假設計算機系統的CPU採用內存與I/O統一編,那麼修改內存單元有兩條途徑,如下圖所示:
在這裏插入圖片描述
分別做簡單的解釋:

  • 從CPU出發
    CPU執行類似str r3, [sp, #4]的寫內存單元指令時,會將寄存器中的數據寫到Cache中,並在時機合適的時候將Cache中的數據回寫到內存。
  • 從外設出發
    外設在某些時候,會修改相應外設控制器的寄存器,比如收到數據的時候,修改某個寄存器的某個位,標誌已經收到數據。

1.4 緩存帶來的數據一致性問題

對於多線程的程序來說,多核CPU可以做到真正意義上的並行,即每個核心同時運行一個線程,通常這種並行能夠帶來性能的提升,但由於每個核心有自己獨享的Cache,因此這種並行也帶來了數據一致性的問題。試想這樣一種場景,有兩個線程分別在CPU的兩個核心上運行,其中一個線程會修改全局變量flag,另外一個線程根據讀到的flag的值做出相應的反應。假設負責修改flag的線程做出了修改,根據上文,修改的值會先送到該線程所在覈心的Cache,而此時另一個核心的Cache以及內存中保留的還是flag的舊值,那麼flag的值就不一致了,進而影響程序的運行,如下圖:
在這裏插入圖片描述
幸運的是,設計Cache的工程師們早就意識到了這個問題,並給出瞭解決方案:緩存一致性協議(MESI)。MESI的內容比較複雜,這裏我僅參考網上的資料[1]簡單介紹一下遇到上圖所示的問題時,MESI如何工作。先給出一副圖:
在這裏插入圖片描述
接下來分別介紹core1、core2各自的動作:

  • core1修改flag爲1
    第1步:給core2發送Invalid消息
    第2步:把1寫入到自己的Store Buffer中。
    第3步:異步地在某個時刻將Store Buffer中的1寫入Cache,進而觸發失效操作,完成Cache同步。

  • core2接收到Invalid消息
    第1步:把收到的消息寫入自身的Invalidate Queue中。
    第2步:異步地將自身的Invalidate Queue設爲Invalid狀態。

由於CPU核如果要讀Cache中的數據,需要先掃描自身的Store Buffer,之後再讀取Cache。因此core1在執行完寫操作,即完成第2步之後(第三步是異步的),如果再讀flag,則總能讀到最新的值。又因爲CPU核在讀Cache時不掃描自身的Invalidate Queue,且core2無法掃描core1的Store Buffer,所以core2在Cache完成同步之前讀到的都是flag的舊值。不難看出,這種不一致持續的時間是core1完成第2步到完成第3之間的間隔。

到這裏就不難得出結論,MESI協議可以保證緩存的一致性,但是無法保證實時性。所以可能會有極短時間的髒讀問題。

1.5 訪存優化帶來的數據一致性問題

上文分析了緩存是如何造成數據一致性問題的,並且介紹了問題的解決方案——MESI(緩存一致性協議)。雖然MESI不算完美(存在極短時間的髒讀),但無傷大雅,在CPU核確實寫Cache的前提下,比如執行指令str r3, [sp, #4],那麼Cache早晚都會得到同步的,而且需要的時間並不長。這樣豈不是說,如果我們可以容忍那極短時間的髒讀,就可以用上文所說的全局變量flag在兩個線程之間做同步了(嚴格保證一個線程只讀,一個線程可讀可寫)。果真如此嗎?如果CPU根本沒有寫Cache呢,哪怕我們在C語言中寫了flag = 1;同時,執行讀flag的線程中也可能根本沒有從Cache讀,即便Cache同步能實時完成,上文中Thread2可能讀到的仍然是舊值。爲什麼會這樣呢?原因在於編譯器的訪存優化

訪存是比較耗時間的,不管是讀還是寫,因此CPU弄出了Cache,但Cache還不夠快,論速度,寄存器纔是CPU的真愛。編譯器處於優化性能的考慮,在它看來,只要不影響程序的正確性(注意是在編譯器看來),可能會將訪存操作推後,先把變量的值保存在寄存器中,對變量的修改也暫時只修改寄存器:

c程序 對應彙編
flag = 1 mov r3, #1 @ flag暫存在r3寄存器中,暫時只對r3賦1

這樣操作在Thread1來說沒有問題,比如後面要讀flag的時候,直接用r3就好了,r3裏面裝的就是flag的最新值。可還有Thread2呢!遺憾的是編譯器並沒有全局觀,它不會意識到Thread2和Thread1之間的微妙關聯。出於同樣的原因,Thread2也可能在優化下,暫存flag變量,每次讀flag時只是讀一個寄存器,而不會讀Cache。就這樣,在訪存優化的推動下,數據不一致的問題產生了。

1.6 內存與IO統一編址帶來的數據一致性問題

在上文1.3節已經介紹過,外設也可能修改內存單元,而且這種修改悄然無聲,緩存一致性協議也沒有辦法捕獲這種修改,也就是說,如果我們外設控制器的寄存器,不管有無訪存優化,都可能無法獲得相應寄存器的最新值。反過來,從CPU一側來看,如果我們要外設控制器的寄存器,也無法實現即時的寫入,這是因爲,即便不進行訪存優化而直接寫緩存,但緩存回寫內存的動作仍然不是即時的。

或許1.4節中提到的短時的髒讀問題我們尚且能夠忍受,但對硬件的訪問通常都要求時效性,若遲遲不能得到硬件的最新狀態,或不能即時的操作硬件做出反應,在某些工業場景下,這可能造成災難性的後果。

2 volatile的作用

2.1 volatile關鍵字

volatile關鍵字修飾變量時能夠影響變量的訪問屬性,具體的說,對於被volatile修飾的變量,每次對其進行讀寫時都要直接前往內存(通常是SDRAM),而不能由Cache或寄存器進行緩存。好奇心氾濫的我不禁會想,編譯器是怎麼實現volatile的這項語義的呢?簡單的寫了一段程序(帶有volatile修飾的變量),並做優化編譯(-O3)及反彙編,發現反彙編中對volatile修飾的變量的訪問總會生成訪存的指令,比如:

ldr r3, [sp, #4]
str r3, [sp, #4]

這些指令並不特殊,雖然肯定可以繞開寄存器,但怎麼保證一定能繞開緩存呢?我想到了一種可能,內存管理中,頁表的表項裏有一些描述頁面屬性或狀態的位,其中通常有一位用於描述是否禁止緩存。至於到底是不是這樣子實現的,我也不清楚,網上查了一些資料,但沒有找到介紹volatile語義實現的相關資料(講java的volatile語義實現的比較多)。因此先將這個疑問記錄下來,後面發現相關資料了,再來更新。當然,如果有路過的大神知道的,還請不吝賜教,不勝感激^_^!!

關於volatile對編譯器優化行爲的影響,這裏再多說一點。現有如下程序:

volatile int a = 0;

for (int i = 0; i < 100; ++i)
	++a;

假如沒有volatile修飾,則編譯器可能將變量a暫存到寄存器,然後在做循環時每次訪問的都是寄存器。甚至編譯器會更激進的將整個循環都優化掉,直接向暫存a的寄存器加上100,如:

add r3, r3, #100 @ a暫存在r3中

而有了volatile之後,哪怕開啓最高級別的優化,編譯器也只能老老實實的做循環,並且每次訪問a都要生產訪存的指令,如:

for循環:
ldr r3, [sp, #4]
add r3, r3, #1
str r3, [sp, #4]

挺久以前我在學習總線時序的時候,使用I/O口模擬時序,並用循環實現了一個簡單的延時,但測試發現時序不對,延時時間出乎意料的短,檢查了反彙編之後才發現,編譯器做了優化,延時時間就達不到了,用volatile修飾循環中的變量後,問題解決。當然,除了少數資源比較少的單片機,很少有用I/O口模擬時序、用循環實現延時的需求。

:volatile關鍵字在GCC對C語言的擴展中,還被用在內聯彙編中,以禁止編譯器對內聯彙編的優化,這部分內容可以看我的另一篇博客:淺析c程序中的屏障,其中有所介紹。

2.2 volatile解決數據一致性問題

volatile的語義使得其修飾的變量在存儲系統中的一致性得到了充分的保證。但它對編譯器優化的影響導致了效率的降低,因此除非不得已,我們應當避免使用volatile。那麼,什麼時候應該使用volatile呢?個人認爲,除非有什麼萬不得已的理由,應當僅在內存與I/O統一編址的場景下,需要對硬件進行訪問時使用volatile(這也是volatile被髮明的初衷)。

存儲系統中,MESI已經解決了一部分數據不一致的問題,儘管不能夠做到實時,但那一丟丟髒讀的時間似乎沒什麼不能容忍的理由。至於多線程併發訪問共享資源時,由於訪存優化避開了MESI,進而造成的數據不一致,在我看來,這個問題本就不應該用volatile來解決。爲什麼這麼說?請看下文。

2.3 volatile能作爲屏障嗎?

答案是不能,這不是我說的,這是GCC手冊說的:

Accesses to non-volatile objects are not ordered with respect to volatile accesses. You cannot use a volatile object as a memory barrier to order a sequence of writes to non-volatile memory.

2.4 volatile具有原子性嗎?

答案是不具有,不難實驗驗證,如下程序:

volatile int a = 0;
++a;

反彙編爲:

ldr	r3, [sp]
add	r3, r3, #1
str	r3, [sp]

2.5 volatile適合線程間的同步嗎?

volatile不能保證指令執行的順序,也不具有原子性,它能做的只是保證數據的一致性,可見volatile缺少同步所需的大部分要素。不難想象,強行用volatile來做同步,一定很蹩腳吧!關於這個問題的更多討論,可以看參考文獻[2]。那麼,完成線程間同步的正確姿勢是什麼呢?答曰:操作系統提供的同步原語。

3 總結

volatile關鍵字能夠用來解決內存與I/O統一編址時,對硬件的即時訪問。雖然volatile能解決數據一致性的問題,但它能做的也只有這個,因此將其用於多線程間的同步是不太合適的。

參考文獻

[1] 知乎用戶林林在問題有了緩存一致性協議爲什麼還需要多線程同步?中的回答
[2] 也來說說C/C++裏的volatile關鍵字
[3] GCC用戶手冊

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章