http://www.c-view.org/journal/006/gp_aa.htm
并不是我故意想弄糟你的心情,但是在這期專欄里,我們將討論多線程編程這一話題。正如上一期Generic里所說(shuō)的,編寫異常安全(exception-safe)的程序是非常困難的,但是和編寫多線程程序比起來(lái),那簡(jiǎn)直就是兒戲。
多線程的程序是出了名的難編寫、難驗(yàn)證、難調(diào)試、難維護(hù),這通常是件苦差事。不正確的多線程程序可能可以運(yùn)行很多年也不出一點(diǎn)錯(cuò),直到滿足某些臨界的條件時(shí),才出現(xiàn)意想不到的奇怪錯(cuò)誤。
不用說(shuō),編寫多線程程序的程序員需要使用可能得到的所有幫助。這期專欄將專注于討論競(jìng)爭(zhēng)條件(race conditions)——這通常是多線程程序中各種麻煩的根源——深入了解它并提供一些工具來(lái)防止競(jìng)爭(zhēng)。令人驚異的是,我們將讓編譯器盡其所能來(lái)幫助你做這些事。
僅僅一個(gè)不起眼的關(guān)鍵字
盡管C和C++標(biāo)準(zhǔn)對(duì)于線程都明顯的“保持沉默”,但它們以volatile關(guān)鍵字的形式,確實(shí)為多線程保留了一點(diǎn)特權(quán)。
就象大家更熟悉的const一樣,volatile是一個(gè)類型修飾符(type modifier)。它是被設(shè)計(jì)用來(lái)修飾被不同線程訪問和修改的變量。如果沒有volatile,基本上會(huì)導(dǎo)致這樣的結(jié)果:要么無(wú)法編寫多線程程序,要么編譯器失去大量?jī)?yōu)化的機(jī)會(huì)。下面我們來(lái)一個(gè)個(gè)說(shuō)明。
考慮下面的代碼:
1
class Gadget
2

{
3
public:
4
void Wait()
5
{
6
while (!flag_)
7
{
8
Sleep(1000); // sleeps for 1000 milliseconds
9
}
10
}
11
void Wakeup()
12
{
13
flag_ = true;
14
}
15

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