?????? 并不是我故意想弄糟你的心情,但是在這期專欄里,我們將討論多線程編程這一話題。正如上一期Generic里所說的,編寫異常安全(exception-safe)的程序是非常困難的,但是和編寫多線程程序比起來,那簡直就是兒戲。
?????? 多線程的程序是出了名的難編寫、難驗證、難調試、難維護,這通常是件苦差事。不正確的多線程程序可能可以運行很多年也不出一點錯,直到滿足某些臨界的條件時,才出現意想不到的奇怪錯誤。
?????? 不用說,編寫多線程程序的程序員需要使用可能得到的所有幫助。這期專欄將專注于討論競爭條件(race conditions)——這通常是多線程程序中各種麻煩的根源——深入了解它并提供一些工具來防止競爭。令人驚異的是,我們將讓編譯器盡其所能來幫助你做這些事。
?????? 僅僅一個不起眼的關鍵字。
????? 盡管C和C++標準對于線程都明顯的“保持沉默”,但它們以volatile關鍵字的形式,確實為多線程保留了一點特權。
?????? 就象大家更熟悉的const一樣,volatile是一個類型修飾符(type modifier)。它是被設計用來修飾被不同線程訪問和修改的變量。如果沒有volatile,基本上會導致這樣的結果:要么無法編寫多線程程序,要么編譯器失去大量優化的機會。下面我們來一個個說明。
考慮下面的代碼:
代碼:
class Gadget
{
public:
void Wait()
{
while (!flag_)
{
Sleep(1000); // sleeps for 1000 milliseconds
}
}
void Wakeup()
{
flag_ = true;
}
...
private:
bool flag_;
};
上面代碼中Gadget::Wait的目的是每過一秒鐘去檢查一下flag_成員變量,當flag_被另一個線程設為true時,該函數才會返回。至少這是程序作者的意圖,然而,這個Wait函數是錯誤的。
假設編譯器發現Sleep(1000)是調用一個外部的庫函數,它不會改變成員變量flag_,那么編譯器就可以斷定它可以把flag_緩存在寄存器中,以后可以訪問該寄存器來代替訪問較慢的主板上的內存。這對于單線程代碼來說是一個很好的優化,但是在現在這種情況下,它破壞了程序的正確性:當你調用了某個Gadget的Wait函數后,即使另一個線程調用了Wakeup,Wait還是會一直循環下去。這是因為flag_的改變沒有反映到緩存它的寄存器中去。編譯器的優化未免有點太……樂觀了。
在大多數情況下,把變量緩存在寄存器中是一個非常有價值的優化方法,如果不用的話很可惜。C和C++給你提供了顯式禁用這種緩存優化的機會。如果你聲明變量是使用了volatile修飾符,編譯器就不會把這個變量緩存在寄存器里——每次訪問都將去存取變量在內存中的實際位置。這樣你要對Gadget的Wait/Wakeup做的修改就是給flag_加上正確的修飾:
class Gadget
{
public:
... as above ...
private:
volatile bool flag_;
};
大多數關于volatile的原理和用法的解釋就到此為止,并且建議你用volatile修飾在多個線程中使用的原生類型變量。然而,你可以用volatile做更多的事,因為它是神奇的C++類型系統的一部分。
把volatile用于自定義類型
volatile修飾不僅可以用于原生類型,也可以用于自定義類型。這時候,volatile修飾方式類似于const(你也可以對一個類型同時使用const和volatile)。
與const不同,volatile的作用對于原生類型和自定義類型是有區別的。就是說,原生類型有volatile修飾時,仍然支持它們的各種操作(加、乘、賦值等等),然而對于class來說,就不是這樣。舉例來說,你可以把一個非volatile的int的值賦給一個volatile的int,但是你不能把一個非volatile的對象賦給一個volatile對象。
讓我們舉個例子來說明自定義類型的volatile是怎么工作的。
代碼:
class Gadget
{
public:
void Foo() volatile;
void Bar();
...
private:
String name_;
int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;
如果你認為volatile對于對象來說沒有什么作用的話,那你可要大吃一驚了。
volatileGadget.Foo(); // ok, volatile fun called for
// volatile object
regularGadget.Foo(); // ok, volatile fun called for
// non-volatile object
volatileGadget.Bar(); // error! Non-volatile function called for
// volatile object!
從沒有volatile修飾的類型到相應的volatile類型的轉換是很平常的。但是,就象const一樣,你不能反過來把volatile類型轉換為非volatile類型。你必須用類型轉換運算符:
Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar(); // ok
一個有volatile修飾的類只允許訪問其接口的一個子集,這個子集由類的實現者來控制。用戶只有用const_cast才可以訪問這個類型的全部接口。而且,象const一樣,類的volatile屬性會傳遞給它的成員(例如,volatileGadget.name_和volatileGadget.state_也是volatile變量)。
volatile,臨界區和競爭條件
多線程程序中最簡單也是最常用的同步機制要算是mutex(互斥對象)了。一個mutex只提供兩個基本操作:Acquire和Release。一旦某個線程調用了Acquire,其他線程再調用Acquire時就會被阻塞。當這個線程調用Release后,剛才阻塞在Acquire里的線程中,會有一個且僅有一個被喚醒。換句話說,對于一個給定的mutex,只有一個線程可以在Acquire和Release調用之間獲取處理器時間。在Acquire和Release調用之間執行的代碼叫做臨界區(critical section)。(Windows的用語可能會引起一點混亂,因為Windows把mutex本身叫做臨界區,而Windows的mutex實際上指進程間的mutex。如果把它們分別叫作線程mutex和進程mutex可能會好些。)
Mutex是用來避免數據出現競爭條件。根據定義,所謂競爭條件就是這樣一種情況:多個線程對數據產生的作用要依賴于線程的調度順序的。當兩個線程競相訪問同一數據時,就會發生競爭條件。因為一個線程可以在任意一個時刻打斷其他線程,數據可能會被破壞或者被錯誤地解釋。因此,對數據的修改操作,以及有些情況下的訪問操作,必須用臨界區保護起來。在面向對象的編程中,這通常意味著你在一個類的成員變量中保存一個mutex,然后在你訪問這個類的狀態時使用這個mutex。
多線程編程高手看了上面兩個段落,可能已經在打哈欠了,但是它們的目的只是提供一個準備練習,我們現在要和volatile聯系起來了。我們將把C++的類型和線程的語義作一個對比。
在一個臨界區以外,任意線程會在任何時間打斷別的線程;這是不受控制的,所以被多個線程訪問的變量容易被改得面目全非。這和volatile的原意[1]是一致的——所以需要用volatile來防止編譯器無意地緩存這樣的變量。
在由一個mutex限定的臨界區里,只有一個線程可以進入。因此,在臨界區中執行的代碼有和單線程程序有相同的語義。被控制的變量不會再被意外改變——你可以去掉volatile修飾。
簡而言之,線程間共享的數據在臨界區之外是volatile的,而在臨界區之內則不是。
你通過對一個mutex加鎖來進入一個臨界區,然后你用const_cast去掉某個類型的volatile修飾,如果我們能成功地把這兩個操作放到一起,那么我們就在C++類型系統和應用程序的線程語義建立起聯系。這樣我們可以讓編譯器來幫我們檢測競爭條件。
LockingPtr
我們需要有一個工具來做mutex的獲取和const_cast兩個操作。讓我們來設計一個LockingPtr類,你需要用一個volatile的對象obj和一個mutex對象mtx來初始化它。在LockingPtr對象的生命期中,它會保證mtx處于被獲取狀態,而且也提供對去掉volatile修飾的obj的訪問。對obj的訪問類似于smart pointer,是通過operator->和operator*來進行的。const_cast是在LockingPtr內部進行。這個轉化在語義上是正確的,因為LockingPtr在其生存期中始終擁有mutex。
首先,我們來定義和LockingPtr一起工作的Mutex類的框架:
代碼:
class Mutex
{
public:
void Acquire();
void Release();
...
};
為了使用LockingPtr,你需要用操作系統提供的數據結構和底層函數來實現Mutex。
LockingPtr是一個模板,用被控制變量的類型作為模板參數。例如,如果你希望控制一個Widget,你就要這樣寫LockingPtr <Widget>。
LockingPtr的定義很簡單,它只是實現了一個單純的smart pointer。它關注的焦點只是在于把const_cast和臨界區操作放在一起。
代碼:
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&);
};
盡管很簡單,LockingPtr對于編寫正確的多線程代碼非常有用。你應該把線程間共享的對象聲明為volatile,但是永遠不要對它們使用const_cast——你應該始終是用LockingPtr的自動對象(automatic objects)。讓我們舉例來說明。
比如說你有兩個線程需要共享一個vector<char>對象:
代碼:
class SyncBuf {
public:
void Thread1();
void Thread2();
private:
typedef vector<char> BufT;
volatile BufT buffer_;
Mutex mtx_; // controls access to buffer_
};
在一個線程的函數里,你只需要簡單地使用一個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 ...
}
}
你不能訪問buffer_的任何函數,除非你進行了const_cast或者用LockingPtr。這兩者的區別是LockingPtr提供了一個有規則的方法來對一個volatile變量進行const_cast。
LockingPtr有非常好的表達力。如果你只需要調用一個函數,你可以創建一個無名的臨時LockingPtr對象,然后直接使用它:
代碼:
unsigned int SyncBuf::Size() {
return LockingPtr<BufT>(buffer_, mtx_)->size();
}
回到原生類型
我們已經看到了volatile對于保護對象免于不受控的訪問是多么出色,并且看到了LockingPtr是怎么提供了一個簡單有效的辦法來編寫線程安全的代碼。現在讓我們回到原生類型,volatile對它們的作用方式是不同的。
讓我們來考慮一個多個線程共享一個int變量的例子。
代碼:
class Counter
{
public:
...
void Increment() { ++ctr_; }
void Decrement() { --ctr_; }
private:
int ctr_;
};
如果Increment和Decrement是在不同的線程里被調用的,上面的代碼片斷里就有bug。首先,ctr_必須是volatile的。其次,即使是一個看上去是原子的操作,比如++ctr_,實際上也分為三個階段。內存本身是沒有運算功能的,當對一個變量進行增量操作時,處理器會:
把變量讀入寄存器
對寄存器里的值加1
把結果寫回內存
這個三步操作稱為RMW(Read-Modify-Write)。在一個RMW操作的Modify階段,大多數處理器都會釋放內存總線,以使其他處理器能夠訪問內存。
如果在這個時候另一個處理器對同一個變量也進行RMW操作,我們就遇到了一個競爭條件:第二次寫入會覆蓋掉第一次的值。
為了防止這樣的事發生,你又要用到LockingPtr:
代碼:
class Counter
{
public:
...
void Increment() { ++*LockingPtr<int>(ctr_, mtx_); }
void Decrement() { --*LockingPtr<int>(ctr_, mtx_); }
private:
volatile int ctr_;
Mutex mtx_;
};
現在這段代碼正確了,但是和SyncBuf相比,這段代碼的質量要差一些。為什么?因為對于Counter,編譯器不會在你錯誤地直接訪問ctr_(沒有對它加鎖)時產生警告。雖然ctr_是volatile的,但是編譯器還是可以編譯++ctr_,盡管產生的代碼絕對是不正確的。編譯器不再是你的盟友了,你只有自己留意競爭條件。
那么你該怎么做呢?很簡單,你可以用一個高層的結構來包裝原生類型的數據,然后對那個結構使用volatile。這有點自相矛盾,直接用volatile修飾原生類型是一個不好的用法,盡管這是volatile最初期望的用法!
volatile成員函數
到現在為止,我們討論了具有volatile數據成員的類;現在讓我們來考慮設計這樣的類,它會作為更大的對象的一部分并且在線程間共享。這里,volatile的成員函數可以幫很大的忙。
在設計類的時候,你只對那些線程安全的成員函數加volatile修飾。你必須假定外面的代碼會在任何地方任何時間調用volatile成員函數。不要忘記:volatile相當于自由的多線程代碼,并且沒有臨界區;非volatile相當于單線程的環境或者在臨界區內。
比如說,你定義了一個Widget類,它用兩個方法實現了同一個操作——一個線程安全的方法和一個快速的不受保護的方法。
代碼:
class Widget
{
public:
void Operation() volatile;
void Operation();
...
private:
Mutex mtx_;
};
注意這里的重載(overloading)用法。現在Widget的用戶可以用一致的語法調用Operation,對于volatile對象可以得到線程安全性,對于普通對象可以得到速度。用戶必須注意把共享的Widget對象定義為volatile。
在實現volatile成員函數時,第一個操作通常是用LockingPtr對this進行加鎖,然后其余工作可以交給非volatile的同名函數做:
代碼:
void Widget::Operation() volatile
{
LockingPtr<Widget> lpThis(*this, mtx_);
lpThis->Operation(); // invokes the non-volatile function
}
小結
在編寫對線程程序的時候,使用volatile將對你十分有益。你必須堅持下面的規則:
把所有共享對象聲明為volatile
不要對原生類型直接使用volatile
定義共享類時,用volatile成員函數來表示它的線程安全性。
如果你這么做了,而且用了簡單的通用組件LockingPtr,你就可以寫出線程安全的代碼,并且大大減少對競爭條件的擔心,因為編譯器會替你操心,并且勤勤懇懇地為你指出哪里錯了。
在我參與的幾個項目中,使用volatile和LockingPtr產生了很大效果。代碼十分整潔,也容易理解。我記得遇到過一些死鎖的情況,但是相對于競爭條件,我寧愿對付死鎖的情況,因為它們調試起來容易多了。那些項目實際上根本沒有碰到過有關競爭條件的問題。
致謝
非常感謝James Kanze和Sorin Jianu提供了很有洞察力的意見。
致謝
非常感謝James Kanze和Sorin Jianu提供了很有洞察力的意見。
附:濫用volatile的本質?[2]
在上一期的專欄《Generic<Programming>: volatile — Multithreaded Programmer's Best Friend》發表以后,我收到很多反饋意見。就像是注定的一樣,大部分稱贊都是私人信件,而抱怨都發到USENET新聞組comp.lang.c++.moderated和 comp.programming.threads里去了。隨后引起了很長很激烈的討論,如果你對這個主題有興趣,你可以去看看這個討論,它的標題是“volatile, was: memory visibility between threads.”。
我知道我從這個討論中學到了很多東西。比如說,文章開頭的Widget的例子不太切題。長話短說,在很多系統(比如POSIX兼容的系統)中,volatile修飾是不需要的,而在另一些系統中,即使加了volatile也沒有用,程序還是不正確。
關于volatile correctness,最重要的一個問題是它依賴于類似POSIX的mutex,如果在多處理器系統上,光靠mutex就不夠了——你必須用memory barriers。
另一個更哲理性的問題是:嚴格來說通過類型轉換把變量的volatile屬性去掉是不合法的,即使volatile屬性是你自己為了volatile correctness而加上去的。正如Anthony Williams指出的,可以想象一個系統可能把volatile數據放在一個不同于非volatile數據的存儲區中,在這種情況下,進行地址變換會有不確定的行為。
另一個批評是volatile correctness雖然可以在一個較低層次上解決競爭條件,但是不能正確的檢測出高層的、邏輯的競爭條件。例如,你有一個mt_vector模版類,用來模擬std::vector,成員函數經過正確的線程同步修正。考慮這段代碼:
volatile mt_vector<int> vec;
…
if (!vec.empty()) {
vec.pop_back();
}
這段代碼的目的是刪除vector里的最后一個元素,如果它存在的話。在單線程環境里,他工作地很好。然而如果你把它用在多線程程序里,這段代碼還是有可能拋出異常,盡管empty和pop_back都有正確的線程同步行為。雖然底層的數據(vec)的一致性有保證,但是高層操作的結果還是不確定的。
無論如何,經過辯論之后,我還是保持我的建議,在有類POSIX的mutex的系統上,volatile correctness還是檢測競爭條件的一個有價值的工具。但是如果你在一個支持內存訪問重新排序的多處理器系統上,你首先需要仔細閱讀你的編譯器的文檔。你必須知己知彼。
最后,Kenneth Chiu提到了一篇非常有趣的文章http://theory.stanford.edu/~freunds/race.ps,猜猜題目是什么?“Type-Based Race Detection for Java”。這篇文章講了怎么對Java的類型系統作一點小小的補充,從而讓編譯器和程序員一起在編譯時檢測競爭條件。