volatile的介紹
volatile類似於大家所熟知的const也是一個類型修飾符。volatile是給編譯器的指示來說明對它所修飾的對象不應該執行優化。volatile的作用就是用來進行多線程編程。在單線程中那就是只能起到限制編譯器優化的作用。所以單線程的童鞋們就不用浪費精力看下面的了。
沒有volatile的結果
如果沒有volatile,你將無法在多線程中並行使用到基本變量。下面舉一個我開發項目的實例(這個實例采用的是C#語言但不妨礙我們討論C++)。在學校的一個.Net項目的開發中,我曾經在多線程監控中用到過一個基本變量Int32型的,我用它來控制多線程中監控的一個條件。考慮到基本變量是編譯器自帶的而且無法用lock鎖上,我想當然的以為是原子操作不會有多線程的問題,可實際運行后發現程序的運行有時正常有時異常,改為用Dictionary對象處理並加鎖以后才徹底正常。現在想來應該是多線程同時操作該變量了,具體的將在下面說清。
volatile的作用
如果一個基本變量被volatile修飾,編譯器將不會把它保存到寄存器中,而是每一次都去訪問內存中實際保存該變量的位置上。這一點就避免了沒有volatile修飾的變量在多線程的讀寫中所產生的由於編譯器優化所導致的災難性問題。所以多線程中必須要共享的基本變量一定要加上volatile修飾符。當然了,volatile還能讓你在編譯時期捕捉到非線程安全的代碼。我在下面還會介紹一位大牛使用智能指針來順序化共享區代碼的方法,在此對其表示感謝。
泛型編程中曾經說過編寫異常安全的代碼是很困難的,可是相比起多線程編程的困難來說這就太小兒科了。多線程編程中你需要證明它正確,需要去反復地枯燥地調試並修復,當然了,資源競爭也是必須注意的,最可恨的是,有時候編譯器也會給你點顏色看看。。。
class Student { public: void Wait() //在北航排隊等吃飯實在是很痛苦的事情。。。 { while(!flag) { Sleep(1000); // sleeps for 1000 milliseconds } } void eat() { flag = true; } ... private: bool flag; };
好吧,多線程中你就等着吃飯吧,可在這個地方估計你是永遠等不到了,因為flag被編譯器放到寄存器中去了,哪怕在你前面的那位童鞋告訴你flag=true了,可你就好像瞎了眼看不到這些了。這么詭異的情況的發生時因為你所用到的判斷值是之前保存到寄存器中的,這樣原來的地址上的flag值更改了你也沒有獲取。該怎么辦呢?對了,改成volatile就解決了。
volatile對基本類型和對用戶自定義類型的使用與const有區別,比如你可以把基本類型的non-volatile賦值給volatile,但不能把用戶自定義類型的non-volatile賦值給volatile,而const都是可以的。還有一個區別就是編譯器自動合成的復制控制不適用於volatile對象,因為合成的復制控制成員接收const形參,而這些形參又是對類類型的const引用,但是不能將volatile對象傳遞給普通引用或const引用。
如何在多線程中使用好volatile
在多線程中,我們可以利用鎖的機制來保護好資源臨界區。在臨界區的外面操作共享變量則需要volatile,在臨界區的里面則non-volatile了。我們需要一個工具類LockingPtr來保存mutex的采集和volatile的利用const_cast的轉換(通過const_cast來進行volatile的轉換)。
首先我們聲明一個LockingPtr中要用到的Mutex類的框架:
class Mutex { public: void Acquire(); void Release(); ... };
接着聲明最重要的LockingPtr模板類:
template <typename T> class LockingPtr { public: // Constructors/destructorsLockingPtr(volatile T& obj, Mutex& mtx) : pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) { mtx.Lock(); } ~LockingPtr() { pMtx_->Unlock(); } // Pointer behavior T& operator*() {return *pObj_; } T* operator->() { return pObj_; } private: T* pObj_; Mutex* pMtx_; LockingPtr(const LockingPtr&); LockingPtr& operator=(const LockingPtr&); };
盡管這個類看起來簡單,但是它在編寫爭取的多線程程序中非常的有用。你可以通過對它的使用來使得對多線程中共享的對象的操作就好像對volatile修飾的基本變量一樣簡單而且從不會使用到const_cast。下面來給一個例子:
假設有兩個線程共享一個vector<char>對象:
class SyncBuf { public: void Thread1(); void Thread2(); private: typedef vector<char> BufT; volatile BufT buffer_; Mutex mtx_; // controls access to buffer_ };
在函數Thread1中,你通過lockingPtr<BufT>來控制訪問buffer_成員變量:
void SyncBuf::Thread1() { LockingPtr<BufT> lpBuf(buffer_, mtx_); BufT::iterator i = lpBuf->begin(); for (; i != lpBuf->end(); ++i) { ... use *i ... } }
這個代碼很容易編寫和理解。只要你需要用到buffer_你必須創建一個lockingPtr<BufT>指針來指向它,並且一旦你這么做了,你就獲得了容器vector的整個接口。而且你一旦犯錯,編譯器就會指出來:
void SyncBuf::Thread2() { // Error! Cannot access 'begin' for a volatile objectBufT::iterator i = buffer_.begin(); // Error! Cannot access 'end' for a volatile object for (; i != lpBuf->end(); ++i) { ... use *i ... } }
這樣的話你就只有通過const_cast或LockingPtr來訪問成員函數和變量了。這兩個方法的不同之處在於后者提供了順序的方法來實現而前者是通過轉換為volatile來實現。LockingPtr是相當好理解的,如果你需要調用一個函數,你就創建一個未命名的暫時的LockingPtr對象並直接使用:
unsigned int SyncBuf::Size() { return LockingPtr<BufT>(buffer_, mtx_)->size(); }
LockingPtr在基本類型中的使用
在上面我們分別介紹了使用volatile來保護對象的意外訪問和使用LockingPtr來提供簡單高效的多線程代碼。現在來討論比較常見的多線程處理共享基本類型的一種情況:
class Counter { public: ... void Increment() { ++ctr_; } void Decrement() { —-ctr_; }private: int ctr_; };
這個時候可能大家都能看出來問題所在了。1.ctr_需要是volatile型。2.即便是++ctr_或--ctr_,這在處理中仍是需要三個原子操作的(Read-Modify-Write)。基於上述兩點,這個類在多線程中會有問題。現在我們就來利用LockingPtr來解決:
class Counter { public: ... void Increment() { ++*LockingPtr<int>(ctr_, mtx_); } voidDecrement() { —?*LockingPtr<int>(ctr_, mtx_); } private: volatile int ctr_; Mutex mtx_; };
volatile成員函數
關於類的話,首先如果類是volatile則里面的成員都是volatile的。其次要將成員函數聲明為volatile則同const一樣在函數最后聲明即可。當你設計一個類的時候,你聲明的那些volatile成員函數是線程安全的,所以那些隨時可能被調用的函數應該聲明為volatile。考慮到volatile等於線程安全代碼和非臨界區;non-volatile等於單線程場景和在臨界區之中。我們可以利用這個做一個函數的volatile的重載來在線程安全和速度優先中做一個取舍。具體的實現此處就略去了。
總結
在編寫多線程程序中使用volatile的關鍵四點:
1.將所有的共享對象聲明為volatile;