《ANSI/ISO C++ Professional Programmer's Handbook 》是這樣說的
explicit Constructors
A constructor that takes a single argument is, by default, an implicit conversion operator, which converts its argument to
an object of its class (see also Chapter 3, "Operator Overloading"). Examine the following concrete example:
class string
{
private:
int size;
int capacity;
char *buff;
public:
string();
string(int size); // constructor and implicit conversion operator
string(const char *); // constructor and implicit conversion operator
~string();
};
Class string has three constructors: a default constructor, a constructor that takes int, and a constructor that
constructs a string from const char *. The second constructor is used to create an empty string object with an
initial preallocated buffer at the specified size. However, in the case of class string, the automatic conversion is
dubious. Converting an int into a string object doesn't make sense, although this is exactly what this constructor does.
Consider the following:
int main()
{
string s = "hello"; //OK, convert a C-string into a string object
int ns = 0;
s = 1; // 1 oops, programmer intended to write ns = 1,
}
In the expression s= 1;, the programmer simply mistyped the name of the variable ns, typing s instead. Normally,
the compiler detects the incompatible types and issues an error message. However, before ruling it out, the compiler first
searches for a user-defined conversion that allows this expression; indeed, it finds the constructor that takes int.
Consequently, the compiler interprets the expression s= 1; as if the programmer had written
s = string(1);
You might encounter a similar problem when calling a function that takes a string argument. The following example
can either be a cryptic coding style or simply a programmer's typographical error. However, due to the implicit
conversion constructor of class string, it will pass unnoticed:
int f(string s);
int main()
{
f(1); // without a an explicit constructor,
//this call is expanded into: f ( string(1) );
//was that intentional or merely a programmer's typo?
}
'In order to avoid such implicit conversions, a constructor that takes one argument needs to be declared explicit:
class string
{
//...
public:
explicit string(int size); // block implicit conversion
string(const char *); //implicit conversion
~string();
};
An explicit constructor does not behave as an implicit conversion operator, which enables the compiler to catch the
typographical error this time:
int main()
{
string s = "hello"; //OK, convert a C-string into a string object
int ns = 0;
s = 1; // compile time error ; this time the compiler catches the typo
}
Why aren't all constructors automatically declared explicit? Under some conditions, the automatic type conversion is
useful and well behaved. A good example of this is the third constructor of string:
string(const char *);
The implicit type conversion of const char * to a string object enables its users to write the following:
string s;
s = "Hello";
The compiler implicitly transforms this into
string s;
//pseudo C++ code:
s = string ("Hello"); //create a temporary and assign it to s
On the other hand, if you declare this constructor explicit, you have to use explicit type conversion:
class string
{
//...
public:
explicit string(const char *);
};
int main()
{
string s;
s = string("Hello"); //explicit conversion now required
return 0;
}
Extensive amounts of legacy C++ code rely on the implicit conversion of constructors. The C++ Standardization
committee was aware of that. In order to not make existing code break, the implicit conversion was retained. However, a
new keyword, explicit, was introduced to the languageto enable the programmer to block the implicit conversion
when it is undesirable. As a rule, a constructor that can be invoked with a single argument needs to be declared
explicit. When the implicit type conversion is intentional and well behaved, the constructor can be used as an
implicit conversion operator.
http://www.c-view.org/journal/006/gp_aa.htm
并不是我故意想弄糟你的心情,但是在這期專欄里,我們將討論多線程編程這一話題。正如上一期Generic里所說的,編寫異常安全(exception-safe)的程序是非常困難的,但是和編寫多線程程序比起來,那簡直就是兒戲。
多線程的程序是出了名的難編寫、難驗證、難調試、難維護,這通常是件苦差事。不正確的多線程程序可能可以運行很多年也不出一點錯,直到滿足某些臨界的條件時,才出現意想不到的奇怪錯誤。
不用說,編寫多線程程序的程序員需要使用可能得到的所有幫助。這期專欄將專注于討論競爭條件(race conditions)——這通常是多線程程序中各種麻煩的根源——深入了解它并提供一些工具來防止競爭。令人驚異的是,我們將讓編譯器盡其所能來幫助你做這些事。
僅僅一個不起眼的關鍵字
盡管C和C++標準對于線程都明顯的“保持沉默”,但它們以volatile關鍵字的形式,確實為多線程保留了一點特權。
就象大家更熟悉的const一樣,volatile是一個類型修飾符(type modifier)。它是被設計用來修飾被不同線程訪問和修改的變量。如果沒有volatile,基本上會導致這樣的結果:要么無法編寫多線程程序,要么編譯器失去大量優化的機會。下面我們來一個個說明。
考慮下面的代碼:
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的目的是每過一秒鐘去檢查一下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相當于單線程的環境或者在臨界區內。
比如說,你定義了一個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的類型系統作一點小小的補充,從而讓編譯器和程序員一起在編譯時檢測競爭條件。
Eppur si muove.[3]
譯注
[1] Volatile原意為易變的,反復無常的。
[2] 這是Andrei在他的下一篇Generic<Programming>;里對本文作的補充說明。
[3] 這是伽利略在被迫放棄他一直信仰的哥白尼的地動說時所說過的一句辯解的話,意思是“它(地球)畢竟仍在運動”。