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/destructors
LockingPtr(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 object
BufT::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_); }
void Decrement() { —?*LockingPtr<int>(ctr_, mtx_); }
private:
volatile int ctr_;
Mutex mtx_;
};
VOLATILE成員函數
關於類的話,首先如果類是volatile則裡面的成員都是volatile的。其次要將成員函式宣告為volatile則同const一樣在函數最後聲明即可。當你設計一個類的時候,你聲明的那些volatile成員函數是安全執行緒的,所以那些隨時可能被調用的函數應該聲明為volatile。考慮到volatile等於安全執行緒代碼和非臨界區;non-volatile等於單線程情境和在臨界區之中。我們可以利用這個做一個函數的volatile的重載來線上程安全和速度優先中做一個取捨。具體的實現此處就略去了。
總結
在編寫多線程程式中使用volatile的關鍵四點:
1.將所有的共用對象聲明為volatile;
2.不要將volatile直接作用於基本類型;
3.當定義了共用類的時候,用volatile成員函數來保證安全執行緒;
4.多多理解和使用volatile和LockingPtr!(強烈建議)