C++ 局部靜態初始化不是線程安全!

http://www.cppblog.com/lymons/archive/2010/08/01/120638.html

http://blogs.msdn.com/b/oldnewthing/archive/2004/03/08/85901.aspx


在塊作用域中的靜態變量的規則 (與之相對的是全局作用域的靜態變量) 是, 程序第一次執行到他的聲明的時候進行初始化.

察看下面的競爭條件:

int ComputeSomething()
{
   static int cachedResult = ComputeSomethingSlowly();
   return cachedResult;
}

這段代碼的意圖是在該函數第一次被調用的時候去計算一些費用, 並且把結果緩衝起來待函數將來再被調用的時候則直接返回這個值即可.

這個基本技巧的變種,在網絡上也被叫做 避免 "static initialization order fiasco". ( fiasco這個詞 在這個網頁上有非常棒的描述,因此我建議大家去讀一讀然後去理解它.)

這段代碼的問題是非線程安全的. 在局部作用域中的靜態變量是編譯時會在編譯器內部轉換成下面的樣子:

int ComputeSomething()
{
  static bool cachedResult_computed = false;
  static int cachedResult;
  if (!cachedResult_computed) {
     cachedResult_computed = true;
     cachedResult = ComputeSomethingSlowly();
  }
  return cachedResult;
}

現在競爭條件就比較容易看到了.

假設兩個線程在同一時刻都調用這個函數. 第一個線程在執行 cachedResult_computed = true 後, 被搶佔. 第二個線程現在看到的 cachedResult_computed 是一個真值( true ),然後就略過了if分支的處理,最後該函數返回的是一個未初始化的變量.

現在你看到的東西並不是一個編譯器的bug, 這個行爲 C++ 標準所要求的.

你也能寫一個變體來產生一個更糟糕的問題:

class Something { ... };
int ComputeSomething()
{
   static Something s;
   return s.ComputeIt();
}

同樣的在編譯器內部它會被重寫 (這次, 我們使用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"還沒有被構造前就使用它. 

甚至更糟糕的情況, 第一個線程很可能在s_contructed 條件判定 之後,在他被設置成"true"之前被搶佔. 在這種場合下, 對象s就會被雙重構造雙重析構

這樣就不是很好.

但是等等, 這並不是全部, 現在(原文是Not,我認爲是Now的筆誤)看看如果有兩個運行期初始化局部靜態變量的話會發生什麼: 

class Something { ... };
int ComputeSomething()
{
static Something s(0);
static Something t(1);
return s.ComputeIt() + t.ComputeIt();
}

上面的代碼會被編譯器轉化爲下面的僞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();
}

爲了節省空間, 編譯器會把兩個"x_constructed" 變量放到一個 bitfield 中. 現在這裏在變量"construted"上就有多個無內部鎖定的讀-改-存操作.

現在考慮一下如果一個線程嘗試去執行 "constructed |= 1", 而在同一時間另一個線程嘗試執行 "constructed |= 2".

在x86平臺上, 這條語句會被彙編成

  or constructed, 1
...
or constructed, 2
並沒有 "lock" 前綴. 在多處理機器上, 很有可能發生兩個存儲都去讀同一個舊值並且互相使用衝突的值進行碰撞(clobber).

在 ia64 和 alpha平臺上, 這個碰撞將更加明顯,因爲它們麼沒有這樣的讀-改-存的單條指令; 而是被編碼成三條指令:

  ldl t1,0(a0)     ; load
addl t1,1,t1     ; modify
stl t1,1,0(a0)   ; store

如果這個線程在 load 和 store之間被搶佔, 這個存儲的值可能將不再是它曾經要寫入的那個值.

因此,現在考慮下面這個有問題的執行順序:

  • 線程A 在測試 "constructed" 條件後發現他是零, 並且正要準備把這個值設定成1, 但是它被搶佔了.
  • 線程B 進入同樣的函數, 看到 "constructed" 是零並繼續去構造 "s" 和 "t", 離開時 "constructed" 等於3.
  • 線程A 繼續執行並且完成它的 讀-改-存 的指令序列, 設定 "constructed" 成 1, 然後構造 "s" (第二次).
  • 線程A 然後繼續去構造 "t" (第二次) 並設定 "constructed" (最終) 成 3.

現在, 你可能會認爲你能用臨界區 (critical section) 來封裝這個運行期初始化動作:

int ComputeSomething()
{
EnterCriticalSection(...);
static int cachedResult = ComputeSomethingSlowly();
LeaveCriticalSection(...);
return cachedResult;
}

因爲你現在把這個一次初始化放到了臨界區裏面,而使它線程安全.

但是如果從同一個線程再一次調用這個函數會怎樣? ("我們跟蹤了這個調用; 它確實是來自這個線程!") 如果 ComputeSomethingSlowly() 它自己間接地調用 ComputeSomething()就會發生這個狀況.

結論: 當你看見一個局部靜態變量在運行期初始化時, 你一定要小心.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章