多執行緒程式設計的鎖問題解析(鎖競爭死鎖活鎖及Date Race等)

FreeeLinux發表於2017-01-05


本文是學習了 Guancheng 大神的文章後,根據文中的相關問題,進行程式碼分析,並且總結出這篇部落格。

原文地址:http://blog.csdn.net/freeelinux/article/details/54091140

大神文章地址:http://www.parallellabs.com/2011/10/02/lock-in-parallel-programming/#comment-1245


在並行程式中,鎖的使用主要會引發兩類難題,一類是諸如死鎖、活鎖等引起的多執行緒 bug;另一類是由鎖競爭引起的效能瓶頸。本文的分析主要是大神的分析,中間穿插我的驗證以及總結,可以說是一篇 ”讀博筆記“,可以直接點上方連結看原文。


1.用鎖來防止 Data Race


在進行並行程式設計時,我們常常需要使用鎖來保護共享變數,以防止多個執行緒同時對該變數進行更新時產生資料競跑(Data Race)。所謂資料競跑,是指兩個(或多個)執行緒同時對某個共享變數進行操作,且這些操作中至少有一個(即>=1)是寫操作時所造成的程式錯誤。例 1 中的兩個執行緒可能同時執行 “counter++” 而產生資料競跑,造成 counter 的最終值為 1(而不是正確值2)。

例 1:
#include <pthread.h>
int counter = 0;
void *func(void *params)
{
    counter++; //資料競跑
}
void main()
{
    pthread_t thread1, thread2;
    pthread_create(&thread1, 0, func, 0);
    pthread_create(&thread2, 0, func, 0);
    pthread_join(thread1, 0 );
    pthread_join(thread2, 0 );
}
這是因為 counter++ 本身是由三條彙編指令構成的(從主存中將 counter 的值讀到暫存器中;對暫存器進行加 1 操作;將暫存器中的新值寫回主存),所以例 1 中的兩個執行緒可能按如下交錯順序執行,導致 counter 的最終值為 1 。

例 2:
load [%counter], rax; // 執行緒1從counter讀取0到暫存器rax
add rax, 1; // 執行緒1對暫存器rax進行加1
load [%counter], rbx; // 執行緒2從counter讀取0到暫存器rbx
store rax [%counter]; // 執行緒1把1寫入counter的主存地址
add rbx, 1; // 執行緒2對暫存器rbx進行加1
store rbx, [%counter]; // 執行緒2把1寫入counter的主存地址
為了防止例1中的資料競跑現象,我們可以使用鎖來保證每個執行緒對counter++操作的獨佔訪問(即保證該操作是原子的)。在例3的程式中,我們使用mutex鎖將counter++操作放入臨界區中,這樣同一時刻只有獲取鎖的執行緒能訪問該臨界區,保證了counter++的原子性:即只有線上程1執行完counter++的三條指令之後執行緒2才能執行counter++操作,保證了counter的最終值必定為2。

下面是我的驗證:
在我的機器上,我一開始沒有查詢 CPU 資訊,執行例 1 中的程式,一直無法產生 Data Race,即便我把 counter++的次數改為 1000 次,開啟 5 個執行緒執行也不行。後來我檢視 CPU 資訊,命令:cat /proc/cpuinfo ,發現我的 vagrant 中安裝的 ubuntu 是單核的,我們知道,在單核 CPU 中,任意時刻只能有一個執行緒處於執行態。所以雖然啟動了 5 個執行緒,實際上對 counter++ 的操作還是序列的。於是我修改了 vagrantfile,將 CPU 改為四核心,然後重新驗證了例 1 的程式,發現在 counter++ 執行次數少的情況下,Data Race  出現的可能性很小(反正我的機器上執行兩個執行緒各執行一次 counter++,得到的結果總是2),在執行次數較多的情況下,Data Race 的情況的有較大的概率復現。
驗證程式碼如下:
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
#include <algorithm>

int counter = 0;
std::mutex mt; 

void thread_task()
{
//  mt.lock();
    for(int i=0; i<1000; ++i)
        counter++;
//  mt.unlock();
}

int main()
{
    std::vector<std::thread> workers;
    for(int i=0; i<5; ++i){
        auto t = std::thread(thread_task);
        workers.push_back(std::move(t));
    }   
    std::for_each(workers.begin(), workers.end(), std::mem_fn(&std::thread::join));
    std::cout<<counter<<std::endl;
    return 0;
}
注意,程式碼使用了C++11的執行緒庫,其實是我懶得寫 API了。不過由於它底層採用 pthread,所以都是一樣的。下面是我啟動 5 個執行緒,每個執行緒個執行 1000 次的情況:

就像我說的,counter++ 執行的次數多了 Data Race 更容易復現,不過 Data Race 發生的情況比例也不是很大。
如果改用 mutex 則不會出現 Data Race,mutex 上述程式碼去掉註釋即可。

2.死鎖和活鎖


然而,鎖的使用非常容易導致多執行緒Bug,最常見的莫過於死鎖和活鎖。從原理上講,死鎖的產生是由於兩個(或多個)執行緒在試圖獲取正被其他執行緒佔有的資源時造成的執行緒停滯。在下例中,假設執行緒1在獲取mutex_a鎖之後正在嘗試獲取mutex_b鎖,而執行緒2此時已經獲取了mutex_b鎖並正在嘗試獲取mutex_a鎖,兩個執行緒就會因為獲取不到自己想要的資源、且自己正佔有著對方想要的資源而停滯,從而產生死鎖。
例4:
// 執行緒 1                     
void func1()                    
{                       
    LOCK(&mutex_a);                      
    LOCK(&mutex_b);//執行緒1停滯在此         
    counter++;                       
    UNLOCK(&mutex_b);                     
    UNLOCK(&mutex_a);                    
}                       
 
// 執行緒 2
void func2()
{
    LOCK(&mutex_b);
    LOCK(&mutex_a);//執行緒2停滯在此
    counter++;
    UNLOCK(&mutex_a);
    UNLOCK(&mutex_b);
}
例4中的死鎖其實是最簡單的情形,在實際的程式中,死鎖往往發生在複雜的函式呼叫過程中。在下面這個例子中,執行緒1在func1()中獲取了mutex_a鎖,之後呼叫func_call1()並在其函式體中嘗試獲取mutex_b鎖;與此同時執行緒2在func2()中獲取了mutex_b鎖之後再在func_call2()中嘗試獲取mutex_a鎖從而造成死鎖。可以想象,隨著程式複雜度的增加,想要正確的檢測出死鎖會變得越來越困難。
例5:
// 執行緒 1                     
void func1()                    
{                       
LOCK(&mutex_a);                      
...                     
func_call1();                
UNLOCK(&mutex_a);                
}                       
 
func_call1()                    
{                       
   LOCK(&mutex_b);               
   ...                       
   UNLOCK(&mutex_b);                 
   ...                       
}                       
 
// 執行緒 2
void func2()
{
    LOCK(&mutex_b);
    ...
    func_call2()
    UNLOCK(&mutex_b);
}
 
func_call2()
{
    LOCK(&mutex_a);
    ...
    UNLOCK(&mutex_b);
    ...
}
其實避免死鎖的方法非常簡單,其基本原則就是保證各個執行緒加鎖操作的執行順序是全域性一致的。例如,如果上例中的執行緒1和執行緒2都是先對mutex_a加鎖再對mutex_b進行加鎖就不會產生死鎖了。在實際的軟體開發中,除了嚴格遵守相同加鎖順序的原則防止死鎖之外,我們還可以使用RAII(Resource Acquisition Is Initialization,即“資源獲取即初始化”)的手段來封裝加鎖解鎖操作,從而幫助減少死鎖的發生[1]。

下面是我的驗證:
#include <iostream>
#include <unistd.h>
#include <thread>
#include <mutex>
#include <vector>
#include <algorithm>

int counter = 0;
std::mutex mt_1;
std::mutex mt_2;

void thread_task1()
{
    mt_1.lock();
    sleep(1);    //review the deadlock
    mt_2.lock();
    counter++;
    mt_2.unlock();
    mt_1.unlock();
}

void thread_task2()
{
    mt_2.lock();
    sleep(1);
    mt_1.lock();
    counter++;
    mt_1.unlock();
    mt_2.unlock();
}

int main()
{
    std::thread t1(thread_task1);
    std::thread t2(thread_task2);
    t1.join();
    t2.join();
    return 0;
}
在驗證中為了更容易復現死鎖,我採用了 sleep(1) 的方法,這樣容易保證出現持有且交叉申請的情況,即出現死鎖。如果不適用 sleep(1) 的情況,死鎖很難出現。我執行了多遍都沒有出現,可見如果由於死鎖的存在,你開發的程式很可能一直測試都是好的,有一天執行突然死鎖了,就是這種低概率事件發生了(悲哀)。
保持加鎖順序的一致性可以避免死鎖,這點我也測試過了,與上述程式碼僅改動順序即可,就不復制貼上了。

除死鎖外,多個執行緒的加鎖、解鎖操作還可能造成活鎖。在下例中,程式設計師為了防止死鎖的產生而做了如下處理:當執行緒1在獲取了mutex_a鎖之後再嘗試獲取mutex_b時,執行緒1通過呼叫一個非阻塞的加鎖操作(類似pthread_mutex_trylock)來嘗試進行獲得mutex_b:如果執行緒1成功獲得mutex_b,則trylock()加鎖成功並返回true,如果失敗則返回false。執行緒2也使用了類似的方法來保證不會出現死鎖。不幸的是,這種方法雖然防止了死鎖的產生,卻可能造成活鎖。例如,線上程1獲得mutex_a鎖之後嘗試獲取mutex_b失敗,則執行緒1會釋放mutex_a並進入下一次while迴圈;如果此時執行緒2線上程1進行TRYLOCK(&mutex_b)的同時執行TRYLOCK(&mutex_a),那麼執行緒2也會獲取mutex_a失敗,並接著釋放mutex_b及進入下一次while迴圈;如此反覆,兩個執行緒都可能在較長時間內不停的進行“獲得一把鎖、嘗試獲取另一把鎖失敗、再解鎖之前已獲得的鎖“的迴圈,從而產生活鎖現象。當然,在實際情況中,因為多個執行緒之間排程的不確定性,最終必定會有一個執行緒能同時獲得兩個鎖,從而結束活鎖。儘管如此,活鎖現象確實會產生不必要的效能延遲,所以需要大家格外注意。

例6:
// 執行緒 1                     
void func1()                    
{                       
    int done = 0;                   
    while(!done) {               
        LOCK(&mutex_a);                      
        if (TRYLOCK(&mutex_b)) {               
            counter++;                   
            UNLOCK(&mutex_b);                        
            UNLOCK(&mutex_a);                        
            done = 1;                        
        }                          
        else {                     
            UNLOCK(&mutex_a);                   
        }                          
    }                        
}                       
 
// 執行緒 2
void func2()
{
    int done = 0;
    while(!done) {
        LOCK(&mutex_b);
        if (TRYLOCK(&mutex_a)) {
            counter++;
            UNLOCK(&mutex_a);
            UNLOCK(&mutex_b);
            done = 1; 
        }
        else {
            UNLOCK(&mutex_b);
        }
    }
}

下面是我對活鎖的驗證:
#include <iostream>
#include <unistd.h>
#include <thread>
#include <mutex>
#include <vector>
#include <algorithm>
#include <functional>
#include <chrono>

int counter = 0;
std::mutex mutex_1;
std::mutex mutex_2;

void thread_task1()
{
    int done = 0;
    while(!done){
        mutex_1.lock();
        if(mutex_2.try_lock()) {//return -1 on success
            counter++;
            mutex_2.unlock();
            mutex_1.unlock();
            done = 1;
        }   
        else{
            std::cout<<"is task 1 alive?"<<std::endl;
            mutex_1.unlock();
        }   
    }   
}

void thread_task2()
{
    int done = 0;
    while(!done){
        mutex_2.lock();
        if(mutex_1.try_lock()){
            counter++;
            mutex_1.unlock();
            mutex_2.unlock();
            done = 1;
        }
        else{
            std::cout<<"is task 2 alive?"<<std::endl;
            mutex_2.unlock();
        }
    }
}

int main()
{
    std::thread t1(thread_task1);
    std::thread t2(thread_task2);
    t1.join();
    t2.join();
    return 0;
}
我使用 Python 的指令碼去執行這段程式碼編譯的可執行檔案,基本上都是出現活鎖。但是自己手動執行 ./main,活鎖則很少出現,直到手動 N 次之後(手快殘廢了),活鎖才出現,截圖如下:

不停的 try lock 之後,兩個執行緒各自獲得了自己想要的鎖,然後程式執行完畢了。不過由上述的 is task 1 alive 執行了很多遍,並且 is task 2 alive 也執行了多遍(截圖沒有展示出來,多遍有多少?反正我的螢幕一直往上滑都幾螢幕都是這幾句話),兩個執行緒才獲得各自的鎖,程式才執行完畢,不用說,使用 try lock 可能造成的活鎖對效能產生了不必要的延遲。

3. 鎖競爭效能瓶頸


在多執行緒程式中鎖競爭是最主要的效能瓶頸之一。在前面我們也提到過,通過使用鎖來保護共享變數能防止資料競跑,保證同一時刻只能有一個執行緒訪問該臨界區。但是我們也注意到,正是因為鎖造成的對臨界區的序列執行導致了並行程式的效能瓶頸。

3.1阿姆達爾法則(Amdahl’s Law)

在介紹鎖競爭引起的效能瓶頸之前,讓我們先來了解一下阿姆達爾法則。我們知道,一個並行程式是由兩部分組成的:序列執行的部分和可以並行執行的部分。假設序列部分的執行時間為S,可並行執行部分的執行時間為P,則整個並行程式使用單執行緒(單核)序列執行的時間為S+P。阿姆達爾法則規定,可並行執行部分的執行時間與執行緒數目成反比:即如果有N個執行緒(N核CPU)並行執行這個可並行的部分,則該部分的執行時間為P/N。由此我們可以得到並行程式總體執行時間的公式:
  總體執行時間T = S + P/N
根據這個公式,我們可以得到一些非常有意思的結論。例如,如果一個程式全部程式碼都可以被並行執行,那麼它的加速比會非常好,即隨著執行緒數(CPU核數)的增多該程式的加速比會線性遞增。換句話說,如果單執行緒執行該程式需要16秒鐘,用16個執行緒執行該程式就只需要1秒鐘。
然而,如果這個程式只有80%的程式碼可以被並行執行,它的加速比卻會急劇下降。根據阿姆達爾法則,如果用16個執行緒並行執行次程式可並行的部分,該程式的總體執行時間T = S + P/N = (16*0.2) + (16*0.8)/16 = 4秒,這比完全並行化的情況(只需1秒)足足慢了4倍!實際上,如果該程式只有50%的程式碼可以被並行執行,在使用16個執行緒時該程式的執行時間仍然需要8.5秒!
從阿姆達爾法則我們可以看到,並行程式的效能很大程度上被只能序列執行的部分給限制住了,而由鎖競爭引起的序列執行正是造成序列效能瓶頸的主要原因之一。

3.2鎖競爭的常用解決辦法


3.2.1 避免使用鎖

為了提高程式的並行性,最好的辦法自然是不使用鎖。從設計角度上來講,鎖的使用無非是為了保護共享資源。如果我們可以避免使用共享資源的話那自然就避免了鎖競爭造成的效能損失。幸運的是,在很多情況下我們都可以通過資源複製的方法讓每個執行緒都擁有一份該資源的副本,從而避免資源的共享。如果有需要的話,我們也可以讓每個執行緒先訪問自己的資源副本,只在程式的後講各個執行緒的資源副本合併成一個共享資源。例如,如果我們需要在多執行緒程式中使用計數器,那麼我們可以讓每個執行緒先維護一個自己的計數器,只在程式的最後將各個計數器兩兩歸併(類比二叉樹),從而最大程度提高並行度,減少鎖競爭。

讓每個執行緒使用自己的有用的資源可以使用執行緒區域性儲存(TLS),下面是一個例子:
#include <iostream>
#include <thread>
#include <mutex>
#include <string>
#include <unistd.h>

thread_local int counter = 0;
std::mutex mt; 

void thread_task(const std::string& thread_name)
{
    ++counter;
    {   
        std::lock_guard<std::mutex> lock(mt);
        std::cout<<"counter for "<<thread_name<<" :"<<counter<<std::endl;
    }   
}

int main()
{
    std::thread t1(thread_task, "t1");
    std::thread t2(thread_task, "t2");
    
    {   
        std::lock_guard<std::mutex> lock(mt);
        std::cout<<"counter for main: "<<counter<<std::endl;  //注意同步cout,因為一個語句多次向緩衝區寫,期間不能有其他執行緒寫入
    }   

    t1.join();
    t2.join();

    return 0;
}
輸出:



3.2.2 使用讀寫鎖

如果對共享資源的訪問多數為讀操作,少數為寫操作,而且寫操作的時間非常短,我們就可以考慮使用讀寫鎖來減少鎖競爭。讀寫鎖的基本原則是同一時刻多個讀執行緒可以同時擁有讀者鎖並進行讀操作;另一方面,同一時刻只有一個寫程式可以擁有寫者鎖並進行寫操作。讀者鎖和寫者鎖各自維護一份等待佇列。當擁有寫者鎖的寫程式釋放寫者鎖時,所有正處於讀者鎖等待佇列裡的讀執行緒全部被喚醒並被授予讀者鎖以進行讀操作;當這些讀執行緒完成讀操作並釋放讀者鎖時,寫者鎖中的第一個寫程式被喚醒並被授予寫者鎖以進行寫操作,如此反覆。換句話說,多個讀執行緒和一個寫執行緒將交替擁有讀寫鎖以完成相應操作。這裡需要額外補充的一點是鎖的公平排程問題。例如,如果在寫者鎖等待佇列中有一個或多個寫執行緒正在等待獲得寫者鎖時,新加入的讀執行緒會被放入讀者鎖的等待佇列。這是因為,儘管這個新加入的讀執行緒能與正在進行讀操作的那些讀執行緒併發讀取共享資源,但是也不能賦予他們讀許可權,這樣就防止了寫執行緒被新到來的讀執行緒無休止的阻塞。
需要注意的是,並不是所有的場合讀寫鎖都具備更好的效能,大家應該根據Profling的測試結果來判斷使用讀寫鎖是否能真的提高效能,特別是要注意寫操作雖然很少但很耗時的情況。(Profiling 請參考:(1)使用 GNU profiler 來提高程式碼執行速度(2)Linux下gprof和oprofiling效能測試工具

3.2.3 保護資料而不是操作

在實際程式中,有不少程式設計師在使用鎖時圖方便而把一些不必要的操作放在臨界區中。例如,如果需要對一個共享資料結構進行刪除和銷燬操作,我們只需要把刪除操作放在臨界區中即可,資源銷燬操作完全可以在臨界區之外單獨進行,以此增加並行度。
正是因為臨界區的執行時間大大影響了並行程式的整體效能,我們必須儘量少在臨界區中做耗時的操作,例如函式呼叫,資料查詢,I/O操作等。簡而言之,我們需要保護的只是那些共享資源,而不是對這些共享資源的操作,儘可能的把對共享資源的操作放到臨界區之外執行有助於減少鎖競爭帶來的效能損失。


3.2.4 儘量使用輕量級的原子操作

在例3中,我們使用了mutex鎖來保護counter++操作。實際上,counter++操作完全可以使用更輕量級的原子操作來實現,根本不需要使用mutex鎖這樣相對較昂貴的機制來實現。在今年程式設計師第四期的《volatile與多執行緒的那些事兒》中我們就有對Java和C/C++中的原子操作做過相應的介紹。


3.2.5 粗粒度鎖與細粒度鎖

為了減少序列部分的執行時間,我們可以通過把單個鎖拆成多個鎖的辦法來較小臨界區的執行時間,從而降低鎖競爭的效能損耗,即把“粗粒度鎖”轉換成“細粒度鎖”。但是,細粒度鎖並不一定更好。這是因為粗粒度鎖程式設計簡單,不易出現死鎖等Bug,而細粒度鎖程式設計複雜,容易出錯;而且鎖的使用是有開銷的(例如一個加鎖操作一般需要100個CPU時鐘週期),使用多個細粒度的鎖無疑會增加加鎖解鎖操作的開銷。在實際程式設計中,我們往往需要從程式設計複雜度、效能等多個方面來權衡自己的設計方案。事實上,在計算機系統設計領域,沒有哪種設計是沒有缺點的,只有仔細權衡不同方案的利弊才能得到最適合自己當前需求的解決辦法。例如,Linux核心在初期使用了Big Kernel Lock(粗粒度鎖)來實現並行化。從效能上來講,使用一個大鎖把所有操作都保護起來無疑帶來了很大的效能損失,但是它卻極大的簡化了並行整個核心的難度。當然,隨著Linux核心的發展,Big Kernel Lock已經逐漸消失並被細粒度鎖而取代,以取得更好的效能。(大核心鎖參考:Linux 大核心鎖原理


3.2.6 使用無鎖演算法、資料結構

首先要強調的是,筆者並不推薦大家自己去實現無鎖演算法。為什麼別去造無鎖演算法的輪子呢?因為高效能無鎖演算法的正確實現實在是太難了。有多難呢?Doug Lea提到java.util.concurrent庫中一個Non Blocking的演算法的實現大概需要1個人年,總共約500行程式碼。事實上,我推薦大家直接去使用一些並行庫中已經實現好了的無鎖演算法、無鎖資料結構,以提高並行程式的效能。典型的無鎖演算法的庫有java.util.concurrent,Intel TBB等,它們都提供了諸如Non-blocking concurrent queue之類的資料結構以供使用。


有關C++11 thread 用法請參考:C++11 併發指南三(std::mutex 詳解)




相關文章