在塊作用域中的靜態(tài)變量的規(guī)則 (與之相對的是全局作用域的靜態(tài)變量) 是, 程序第一次執(zhí)行到他的聲明的時候進(jìn)行初始化.
察看下面的競爭條件:
int ComputeSomething()
{
static int cachedResult = ComputeSomethingSlowly();
return cachedResult;
}
這段代碼的意圖是在該函數(shù)第一次被調(diào)用的時候去計算一些費用, 并且把結(jié)果緩沖起來待函數(shù)將來再被調(diào)用的時候則直接返回這個值即可.
這個基本技巧的變種,在網(wǎng)絡(luò)上也被叫做 避免 "static initialization order fiasco". ( fiasco這個詞 在這個網(wǎng)頁上有非常棒的描述,因此我建議大家去讀一讀然后去理解它.)
這段代碼的問題是非線程安全的. 在局部作用域中的靜態(tài)變量是編譯時會在編譯器內(nèi)部轉(zhuǎn)換成下面的樣子:
int ComputeSomething()
{
static bool cachedResult_computed = false;
static int cachedResult;
if (!cachedResult_computed) {
cachedResult_computed = true;
cachedResult = ComputeSomethingSlowly();
}
return cachedResult;
}
現(xiàn)在競爭條件就比較容易看到了.
假設(shè)兩個線程在同一時刻都調(diào)用這個函數(shù). 第一個線程在執(zhí)行 cachedResult_computed = true 后, 被搶占. 第二個線程現(xiàn)在看到的 cachedResult_computed 是一個真值( true ),然后就略過了if分支的處理,最后該函數(shù)返回的是一個未初始化的變量.
現(xiàn)在你看到的東西并不是一個編譯器的bug, 這個行為 C++ 標(biāo)準(zhǔn)所要求的.
你也能寫一個變體來產(chǎn)生一個更糟糕的問題:
class Something { ... };
int ComputeSomething()
{
static Something s;
return s.ComputeIt();
}
同樣的在編譯器內(nèi)部它會被重寫 (這次, 我們使用C++偽代碼):
class Something { ... };
int ComputeSomething()
{
static bool s_constructed = false;
static uninitialized Something s;
if (!s_constructed) {
s_constructed = true;
new(&s) Something; // construct it
atexit(DestructS);
}
return s.ComputeIt();
}
// Destruct s at process termination
void DestructS()
{
ComputeSomething::s.~Something();
}
注意這里有多重的競爭條件. 就像前面所說的, 一個線程很可能在另一個線程之前運行并且在"s"還沒有被構(gòu)造前就使用它.
甚至更糟糕的情況, 第一個線程很可能在s_contructed 條件判定 之后,在他被設(shè)置成"true"之前被搶占. 在這種場合下, 對象s就會被雙重構(gòu)造和雙重析構(gòu).
這樣就不是很好.
但是等等, 這并不是全部, 現(xiàn)在(原文是Not,我認(rèn)為是Now的筆誤)看看如果有兩個運行期初始化局部靜態(tài)變量的話會發(fā)生什么:
class Something { ... };
int ComputeSomething()
{
static Something s(0);
static Something t(1);
return s.ComputeIt() + t.ComputeIt();
}
上面的代碼會被編譯器轉(zhuǎn)化為下面的偽C++代碼:
class Something { ... };
int ComputeSomething()
{
static char constructed = 0;
static uninitialized Something s;
if (!(constructed & 1)) {
constructed |= 1;
new(&s) Something; // construct it
atexit(DestructS);
}
static uninitialized Something t;
if (!(constructed & 2)) {
constructed |= 2;
new(&t) Something; // construct it
atexit(DestructT);
}
return s.ComputeIt() + t.ComputeIt();
}
為了節(jié)省空間, 編譯器會把兩個"x_constructed" 變量放到一個 bitfield 中. 現(xiàn)在這里在變量"construted"上就有多個無內(nèi)部鎖定的讀-改-存操作.
現(xiàn)在考慮一下如果一個線程嘗試去執(zhí)行 "constructed |= 1", 而在同一時間另一個線程嘗試執(zhí)行 "constructed |= 2".
在x86平臺上, 這條語句會被匯編成
or constructed, 1
...
or constructed, 2
并沒有 "lock" 前綴. 在多處理機(jī)器上, 很有可能發(fā)生兩個存儲都去讀同一個舊值并且互相使用沖突的值進(jìn)行碰撞(clobber).
在 ia64 和 alpha平臺上, 這個碰撞將更加明顯,因為它們么沒有這樣的讀-改-存的單條指令; 而是被編碼成三條指令:
ldl t1,0(a0) ; load
addl t1,1,t1 ; modify
stl t1,1,0(a0) ; store
如果這個線程在 load 和 store之間被搶占, 這個存儲的值可能將不再是它曾經(jīng)要寫入的那個值.
因此,現(xiàn)在考慮下面這個有問題的執(zhí)行順序:
- 線程A 在測試 "constructed" 條件后發(fā)現(xiàn)他是零, 并且正要準(zhǔn)備把這個值設(shè)定成1, 但是它被搶占了.
- 線程B 進(jìn)入同樣的函數(shù), 看到 "constructed" 是零并繼續(xù)去構(gòu)造 "s" 和 "t", 離開時 "constructed" 等于3.
- 線程A 繼續(xù)執(zhí)行并且完成它的 讀-改-存 的指令序列, 設(shè)定 "constructed" 成 1, 然后構(gòu)造 "s" (第二次).
- 線程A 然后繼續(xù)去構(gòu)造 "t" (第二次) 并設(shè)定 "constructed" (最終) 成 3.
現(xiàn)在, 你可能會認(rèn)為你能用臨界區(qū) (critical section) 來封裝這個運行期初始化動作:
int ComputeSomething()
{
EnterCriticalSection(...);
static int cachedResult = ComputeSomethingSlowly();
LeaveCriticalSection(...);
return cachedResult;
}
因為你現(xiàn)在把這個一次初始化放到了臨界區(qū)里面,而使它線程安全.
但是如果從同一個線程再一次調(diào)用這個函數(shù)會怎樣? ("我們跟蹤了這個調(diào)用; 它確實是來自這個線程!") 如果 ComputeSomethingSlowly() 它自己間接地調(diào)用 ComputeSomething()就會發(fā)生這個狀況.
結(jié)論: 當(dāng)你看見一個局部靜態(tài)變量在運行期初始化時, 你一定要小心.