volatile和內存屏障

介紹

volatile關鍵字的目的是防止編譯器對變量訪問作任何優化,由於這些變量可能會以編譯器沒法肯定的方式被修改。html

聲明爲volatile的變量不會被優化,由於它們的值隨時可能被當前代碼範圍以外的代碼修改。系統老是從內存讀取變量的當前值,而不會使用寄存器中的值,即便上條指令剛操做過此數據。(volatile的影響遠不止是否使用寄存器值這麼簡單)程序員

應用場景

當變量的值可能發生意外變化時,應該將其聲明爲volatile。實際上,只有三種狀況:objective-c

  1. 內存映射外圍設備寄存器
  2. 由中斷處理程序修改的全局變量
  3. 被多個線程訪問的共享變量

第一種狀況,外圍設備寄存器的值隨時可能被外部改變,顯然超出了代碼的範圍。第二種狀況,中斷處理程序的執行模式不一樣於普通程序,當中斷到來時,當前線程掛起,執行中斷處理程序,以後恢復代碼的執行。能夠認爲,中斷處理程序與當前程序是並行的,獨立於正常代碼執行序列以外。第三種狀況比較常見,就是通常的併發編程。編程

原理

編譯器假設變量值變化的惟一方式是被代碼修改。多線程

int a = 24;
複製代碼

如今編譯器會認爲 a的值一直是24,除非遇到修改a值的語句。若是後面有代碼:併發

int b = a + 3;
複製代碼

編譯器會認爲,既然已經知道a的值是24,所以b的值確定是27,因此不須要生成計算a + 3的指令。app

若是a的值在兩條語句中間被修改了,那麼編譯的結果就會出錯。然而,a的值爲何會忽然被修改呢?不會的。函數

若是a是一個棧變量,除非傳遞一個指向它的引用,不然它的值是不會改變的。例如:工具

doSomething(&a);
複製代碼

函數doSomething有一個指向a的指針,意味着a的值可能會被修改,此行代碼以後a的值可能就再也不是24了。若是這樣寫:oop

int a = 24;
doSomething(&a);
int b = a + 3;
複製代碼

編譯器將不會優化掉a + 3的計算。誰知道doSomething以後a的值是多少呢?編譯器顯然不知道。

對於全局變量或者對象的實例變量,問題會更復雜一些。這些變量不在棧上,而是在堆中,這意味着不一樣的線程能夠訪問它們。

// Global Scope
int a = 0;

void function() {
  a = 24;
  b = a + 3;
}
複製代碼

b會是27嗎?極可能是的,不過其它線程也可能在兩條語句之間修改了a的值,儘管這種可能性比較小。編譯器會意識到這一點兒嗎?不會的。由於C語言自己並不知道關於線程的任何東西——至少過去是這樣的(最新的C標準終於知道了native線程,不過以前全部的線程功能都是由操做系統提供的API,而不是C語言自己的特性)。所以C編譯器依然會認爲b的值是27,並將計算優化掉,這會致使錯誤的結果。

這就是volatile的用武之地了。若是標記變量爲volatile:

volatile int a = 0;
複製代碼

咱們告訴編譯器:a的值可能隨時會忽然改變。對於編譯器來講,這意味着它不能假設a的值,哪怕1皮秒以前它仍是那個值,而且看起來也沒有代碼修改它。每次訪問a時,老是讀取它的當前值。

過分使用volatile會阻礙許多編譯器優化,可能會顯著下降計算代碼的速度,並且人們常常在沒必要要的狀況下使用volatile。例如,編譯器不會跨越內存屏障進行值假設。內存屏障是什麼超出了本文的討論範圍,只須要知道典型的同步結構都是內存屏障,例如鎖、互斥或信號量等。對於下面代碼:

// Global Scope
int a = 0;

void function() {
  a = 24;
  pthread_mutex_lock(m);
  b = a + 3;
  pthread_mutex_unlock(m);
}
複製代碼

pthread_mutex_lock是一個內存屏障(pthread_mutex_unlock也是),所以不須要將a聲明爲volatile,編譯器不會跨越內存屏障假設a的值,永遠不會

Objective-C在全部方面都很像C,畢竟它只是一個帶有運行時的擴展版的C。須要指出的一點是,atomic屬性是內存屏障,所以不須要爲屬性聲明volatile。若是須要在多個線程中訪問屬性,那麼能夠將屬性聲明爲atomic的(若是不聲明nonatomic,默認也是atomic)。若是不須要在多個線程訪問,標記爲nonatomic會使屬性的訪問更快,不過只有在頻繁訪問屬性時纔會表現出來(不是指一分鐘訪問10次那種,而是一秒鐘須要訪問數千次以上)。

Obj-C代碼何時須要使用volatile呢?

@implementation SomeObject {
  volatile bool done;
}

- (void)someMethod {
  done = false;

  // Start some background task that performes an action
  // and when it is done with that action, it sets `done` to true.
  // ...

  // Wait till the background task is done
  while (!done) {
    // Run the runloop for 10 ms, then check again
    [[NSRunLoop currentRunLoop] 
      runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01]
    ];
  }
}
@end
複製代碼

若是沒有volatile,編譯器可能會愚蠢地認爲,done不會改變,所以簡單地用true替換done,以至於造成一個死循環。

當Apple還在使用GCC 2.x時,若是上面的代碼沒有使用volatile,的確會致使死循環(僅在開啓優化的release編譯模式,debug模式並不會)。在現代編譯器上沒有驗證過這一點,或許當前版本的clang更智能一些。不過咱們顯然不能期望編譯器足夠聰明來正確處理這一點。 同時也取決於啓動後臺任務的方式。若是dispatch一個block,編譯器很容易知道done是否會被改變。若是向某處傳遞一個指向done的指針,編譯器知道done的值可能會被修改,所以不會對其值進行任何假設。

Memory barriers

若是你看過蘋果在<libkern/OSAtomic.h>文件或atomic 的手冊頁中提供的原子操做,那麼或許你已經注意到,每一個操做有兩個版本:一個x和一個xBarrier(例如,OSAtomicAdd32OSAtomicAdd32Barrier)。如今你知道了,名字中帶有「Barrier」的是一個內存屏障,而另外一個不是。

內存屏障不只適用於編譯器,也適用於CPU(有些CPU指令被認爲是內存屏障)。CPU須要知道這些屏障,由於CPU會對指令從新排序,以便流水線化亂序執行。例如:

a = x + 3; // (1)
b = y * 5; // (2)
c = a + b; // (3)
複製代碼

假設加法器的流水線正忙,而乘法器的流水線還有空閒,那麼CPU可能會在(1)以前先執行(2),畢竟執行順序並不會影響最終的運算結果。這能夠防止管道停滯。固然,CPU也足夠聰明地知道(3)的執行不能早於(1)或(2),由於(3)的結果依賴於(1)和(2)的結果。

流水線,簡單來講就是一個CPU核心有多套運算器,每條指令分爲幾個階段,多條指令並行執行。

​ load instruction ​ decode load instruction ​ load data decode load instruction ​ operation load data decode ​ save data operation load data ​ save data operation ​ save data

然而,某些類型的順序更改會破壞代碼或程序員的意圖。考慮以下代碼:

x = y + z; // (1)
a = 1; // (2)
複製代碼

加法器流水線正忙,所以爲何不在(1)以前先執行(2)呢?它們沒有依賴關係,所以順序可有可無,對吧?看狀況。假設有一個線程正在監聽a的變化,當a變爲1時,讀取x的值,若是按序執行的話值應該是y + z。但若是CPU調整了執行順序,x的值就仍是此段代碼執行以前的值,此時另外一個線程獲取到的值是不符合程序員指望的。

對於這種狀況,順序是很重要的,這就是爲何CPU也須要屏障:CPU不會跨越屏障從新排序指令。所以,指令(2)須要是一個屏障指令(或者在(1)和(2)之間有一個屏障指令,取決於具體的CPU)。

從新排序指令是現代CPU的特性,還有一個更老的問題是延遲內存寫操做。若是CPU延遲對內存的寫操做(對於一些CPU來講很常見,由於內存訪問速度相對於CPU來講實在太慢了),它將確保全部延遲的寫操做在跨越內存屏障以前被執行並完成,所以當其它線程訪問時,全部的內存都處於正確的狀態(知道「內存屏障」這個詞的出處了吧)。

與內存屏障打交道的地方可能比咱們意識到的要多不少(GCD - Grand Central Dispatch處處都是內存屏障,以及基於GCD的NSOperation/NSOperationQueue),這就是爲何咱們只須要在很是少的、特殊的狀況才真正須要使用volatile。可能你寫了100個App都不須要用到一次。然而,若是咱們須要編寫大量低層的、多線程的代碼,並指望達到最高的性能,那麼或早或晚會遇到必須使用volatile才能確保功能正確的狀況。若是此種狀況不使用volatile,可能致使死循環或變量值不正確卻沒法解釋的問題。若是遇到了這樣的問題,尤爲是隻在release模式下才會出現,那麼極可能是由於缺失了volatile或內存屏障。

總結

爲了優化代碼性能,編譯器默認狀況下會根據當前代碼的上下文推斷變量的值,以減小沒必要要的計算。在單線程、正常執行的狀況下,不會有什麼問題。可是在中斷處理程序、多線程併發和內存映射I/O的狀況,變量的值可能在當前代碼範圍以外忽然被修改,這些狀況超出了編譯器的意識範圍。所以,須要咱們顯式地告訴編譯器,不要推斷這些變量的值,由於它們隨時可能被當前代碼範圍以外的代碼或硬件修改。

另外,編譯器不會跨越內存屏障推斷變量的值。在實際編程中,不少內存屏障是隱性的,由於常見的同步工具已帶有內存屏障功能,如鎖、互斥和信號量等,iOS並行編程中最經常使用的GCD處處都是內存屏障,atomic屬性也是一個內存屏障。

屏障不只適用於編譯器,也適用於CPU。絕大多數現代CPU都引入了流水線,亂序並行執行多條指令。當咱們想要確保指令執行順序時,也須要使用屏障指令。CPU不會跨越屏障重排指令順序。

須要注意的是,C語言自己並無線程的概念,線程是操做系統提供的API,所以編譯器不會假設全局變量隨時會被其它線程修改。固然,編譯器的智能化在不斷提升,C語言自己也在進化。不過,咱們仍是不要依賴於編譯器的聰明程度爲好。

參考資料

相關文章
相關標籤/搜索