多線程程序常見Bug剖析(上)

來源:http://www.parallellabs.com/tag/deadlock/

多線程程序常見Bug剖析(上)


編寫多線程程序的第一準則是先保證正確性,再考慮優化性能。本文重點分析多線程編程中除死鎖之外的兩種常見Bug:違反原子性(Atomicity Violation)和違反執行順序(Ordering Violation)。現在已經有很多檢測多線程Bug的工具,但是這兩種Bug還沒有工具能完美地幫你檢測出來,所以到目前爲止最好的辦法還是程序員自己有意識的避免這兩種Bug。本文的目的就是幫助程序員瞭解這兩種Bug的常見形式和常見解決辦法。

閱讀全文>>


編寫多線程程序的第一準則是先保證正確性,再考慮優化性能。本文重點分析多線程編程中除死鎖之外的另兩種常見Bug:違反原子性(Atomicity Violation)和違反執行順序(Ordering Violation)。現在已經有很多檢測多線程Bug的工具,但是這兩種Bug還沒有工具能完美地幫你檢測出來,所以到目前爲止最好的辦法還是程序員自己有意識的避免這兩種Bug。本文的目的就是幫助程序員瞭解這兩種Bug的常見形式和常見解決辦法。

1. 多線程程序執行模型

在剖析Bug之前,我們先來簡單回顧一下多線程程序是怎麼執行的。從程序員的角度來看,一個多線程程序的執行可以看成是每個子線程的指令交錯在一起共同執行的,即Sequential Consistency模型。它有兩個屬性:每個線程內部的指令是按照代碼指定的順序執行的(Program Order),但是線程之間的交錯順序是任意的、不確定的(Non deterministic)。

我原來舉過一個形象的例子。伸出你的雙手,掌心面向你,兩個手分別代表兩個線程,從食指到小拇指的四根手指頭分別代表每個線程要依次執行的四條指令。
(1)對每個手來說,它的四條指令的執行順序必須是從食指執行到小拇指
(2)你兩個手的八條指令(八個手指頭)可以在滿足(1)的條件下任意交錯執行(例如可以是左1,左2,右1,右2,右3,左3,左4,右4,也可以是左1,左2,左3,左4,右1,右2,右3,右4,也可以是右1,右2,右3,左1,左2,右4,左3,左4等等等等)

好了,現在讓我們來看看程序員在寫多線程程序時是怎麼犯錯的。

2. 違反原子性(Atomicity Violation)

何謂原子性?簡單的說就是不可被其他線程分割的操作。大部分程序員在編寫多線程程序員時仍然是按照串行思維來思考,他們習慣性的認爲一些簡單的代碼肯定是原子的。

例如:

01
02
03
04
05
    Thread 1                        Thread 2
S1: if (thd->proc_info)              ...
{                           S3: thd->proc_info=NULL;
  S2: fputs(thd->proc_info,...)
}

這個來自MySQL的Bug的根源就在於程序員誤認爲,線程1在執行S1時如果從thd->proc_info讀到的是一個非空的值的話,在執行S2時thd->proc_info的值肯定也還是非空的,所以可以調用fputs()進行操作。事實上,{S1,S2}組合到一起之後並不是原子操作,所以它們可能被線程2的S3打斷,即按S1->S3->S2的順序執行,從而導致線程1運行到S2時出錯(注意,雖然這個Bug是因爲多線程程序執行順序的不確定性造成的,可是它違反的是程序員對這段代碼是原子的期望,所以這個Bug不屬於違反順序性的Bug)。

這個例子的對象是兩條語句,所以很容易看出來它們的組合不是原子的。事實上,有些看起來像是原子操作的代碼其實也不是原子的。最著名的莫過於多個線程執行類似“x++”這樣的操作了。這條語句本身不是原子的,因爲它在大部分硬件平臺上其實是由三條語句實現的:

01
02
03
mov eax,dword ptr [x]
add eax,1
mov dword ptr [x],eax

同樣,下面這個“r.Location = p”也不是原子的,因爲事實上它是兩個操作:“r.Location.X = p.X”和“r.Location.Y = p.Y”組成的。

01
02
03
04
05
06
07
struct RoomPoint {
   public int X;
   public int Y;
}
 
RoomPoint p = new RoomPoint(2,3);
r.Location = p;

從根源上來講,如果你想讓這段代碼真正按照你的心意來執行,你就得在腦子裏仔細考慮是否會出現違反你本意的執行順序,特別是涉及的變量(例如thd->proc_info)在其他線程中有可能被修改的情況,也就是數據競爭(Data Race)[注1]。如果有兩個線程同時對同一個內存地址進行操作,而且它們之中至少有一個是寫操作,數據競爭就發生了。

有時候數據競爭可是隱藏的很深的,例如下面的Parallel.For看似很正常:

01
02
Parallel.For(0, 10000,
    i => {a[i] = new Foo();})

實際上,如果我們去看看Foo的實現:

01
02
03
04
05
06
07
08
class Foo {
    private static int counter;
    private int unique_id;
    public Foo()
       {
        unique_id = counter++;
       }
}

同志們,看出來哪裏有數據競爭了麼?是的,counter是靜態變量,Foo()這個構造函數裏面的counter++產生數據競爭了!想避免Atomicity Violation,其實根本上就是要保證沒有數據競爭(Data Race Free)。

3. Atomicity Violation的解決方案

解決方案大致有三(可結合使用):
(1)把變量隔離起來:只有一個線程可以訪問它(isolation)
(2)把變量的屬性定義爲immutable的:這樣它就是隻讀的了(immutability)
(3)同步對這個變量的讀寫:比如用鎖把它鎖起來(synchronization)

例如下面這個例子裏面x是immutable的;而a[]則通過index i隔離起來了,即不同線程處理a[]中不同的元素;

01
02
03
04
Parallel.For(1,1000,
i => {
    a[i] = x;
});

例如下面這個例子在構造函數中給x和y賦值(此時別的線程不能訪問它們),保證了isolation;一旦構造完畢x和y就是隻讀的了,保證了immutability。

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
public class Coordinate
{
   private double x, y;
 
   public Coordinate(double a,
                     double b)
   {
      x = a;
      y = b;
   }
   public void GetX() {
      return x;
   }
   public void GetY() {
      return y;
   }
}

而我最開始提到的關於thd->proc_info的Bug可以通過把S1和S2兩條語句用鎖包起來解決(同志們,千萬別忘了給S3加同一把鎖,要不然還是有Bug!)。被鎖保護起來的臨界區在別的線程看來就是“原子”的,不可以被打斷的。

01
02
03
04
05
06
07
    Thread 1                        Thread 2
LOCK(&lock)
S1: if (thd->proc_info)              LOCK(&lock);
{                           S3: thd->proc_info=NULL;
  S2: fputs(thd->proc_info,...)      UNLOCK(&lock);
}
UNLOCK(&lock)

還有另一個用鎖來同步的例子,即通過使用鎖(Java中的synchronized關鍵字)來保證沒有數據競爭:

“Java 5 中提供了 ConcurrentLinkedQueue 來簡化併發操作。但是有一個問題:使用了這個類之後是否意味着我們不需要自己進行任何同步或加鎖操作了呢?
也就是說,如果直接使用它提供的函數,比如:queue.add(obj); 或者 queue.poll(obj);,這樣我們自己不需要做任何同步。”但是,兩個原子操作合起來可就不一定是原子操作了(Atomic + Atomic != Atomic),例如:

01
02
03
if(!queue.isEmpty()) { 
   queue.poll(obj); 

事實情況就是在調用isEmpty()之後,poll()之前,這個queue沒有被其他線程修改是不確定的,所以對於這種情況,我們還是需要自己同步,用加鎖的方式來保證原子性(雖然這樣很損害性能):

01
02
03
04
05
synchronized(queue) { 
    if(!queue.isEmpty()) { 
       queue.poll(obj); 
    

但是注意了,使用鎖也會造成一堆Bug,死鎖就先不說了,先看看初學者容易犯的一個錯誤(是的,我曾經也犯過這個錯誤),x在兩個不同的臨界區中被修改,加了鎖跟沒加一樣,因爲還是有數據競爭:

01
02
03
04
05
06
07
08
09
10
11
12
int x = 0;
pthread_mutex_t lock1;
pthread_mutex_t lock2;
 
pthread_mutex_lock(&lock1);
x++;
pthread_mutex_unlock(&lock1);
...
...
pthread_mutex_lock(&lock2);
x++;
pthread_mutex_unlock(&lock2);

事實上,類似x++這樣的操作最好的解決辦法就是使用類似java.util.concurrent.atomic,Intel TBB中的atomic operation之類的方法完成,具體的例子可以參考這篇文章

總結一下,不管是多條語句之間的原子性也好,單個語句(例如x++)的原子性也好都需要大家格外小心,有這種意識之後很多跟Atomicity Violation相關的Bug就可以被避免了。其實歸根結底,我們最終是想讓多線程程序按照你的意願正確的執行,所以在清楚什麼樣的情形可能讓你的多線程程序不能按你所想的那樣執行之後我們就能有意識的避免它們了(或者更加容易的修復它們)。下一篇文章我們再來仔細分析下Ordering Violation。

[注1] 嚴格意義上來講,Data Race只是Atomicity Violation的一個特例,Data Race Free不能保證一定不會出現Atomicity Violation。例如文中Java實現的那個Concurrent Queue的例子,嚴格意義上來講它並沒有data race,因爲isEmpty()和poll()都是線程安全的調用,只不過它們組合起來之後會出現違反程序員本意的Atomicity Violation,所以要用鎖保護起來。

P.S. 參考文獻中的前兩篇是YuanYuan Zhou教授的得意門生Dr. Shan Lu的論文,後者現在已經是Wisconsin–Madison的教授了。


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