歡迎光臨
每天分享高質量文章

由一個執行緒例子引發的思考

在談這個例子之前先貼上行程與執行緒的記憶體結構,方便對執行緒有一個更深的理解。(如果覺得前面的介紹很煩,可以直接跳到最後看問題的分析和最終解決方法的代碼

行程的記憶體結構

下圖是在Linux/x86-32中典型的行程記憶體結構,從圖中的地址分佈可以看出,內核態占1G空間,用戶態占3G空間 

關於行程的虛擬地址空間可以參考 http://blog.csdn.net/slvher/article/details/8831885 
更詳細的瞭解,可以查閱《深入理解計算機系統》虛擬儲存器章節和《操作系統教程–Linux實體分析》。

擁有2個執行緒的行程的記憶體空間

下圖沒有畫出內核態 

可以看出執行緒和行程的一個明顯的區別執行緒記憶體空間並不會獨立於創建他的行程,執行緒是運行在行程的地址空間中的。同一程式中的所有執行緒共享同一份全域性記憶體區域,其中包括初始化資料段,未初始化資料段,以及堆記憶體段。

執行緒例子

這個程式想要實現的是: 
  計算3個執行緒總共迴圈了多少次,main_counter 是直接計算總的迴圈次數,counter[i] 是計算第 i 號執行緒迴圈的次數。sum 是3個執行緒各自迴圈次數的總和。所以,理論上main_counter 和 sum 值應該是相等的,因為都是在計算總迴圈次數。

代碼1
#include
#include
#include
#include
#include
#include
#define MAX_THREAD  3  //執行緒個數
unsigned long long   main_counter,counter[MAX_THREAD]={0};
void* thread_worker(void*  arg)
{
//將指標先強轉為int* 再賦值
   
int  thread_num = *(int*)arg;
//    printf(“thread_id:%lu   counter[%d]\n”,pthread_self(),thread_num);
   
for(;;)
   {
       counter[thread_num]++;    
//本執行緒的counter 加 1
       main_counter++;
   }
}
int main(int argc,char* argv[])
{    
int    i,rtn,ch;
   pthread_t      pthread_id[MAX_THREAD] =  {
0};  //存放執行緒
   
for(i=0;i//傳 &i;
       pthread_create(&pthread;_id[i],NULL,thread_worker,&i;);
   }
   do
   {        
        unsigned long long   sum = 0;
       for(i=0;i
           printf(“No.%d: %llu\n”,i,counter[i]);
       }        
        printf(“%llu/%llu\n”,main_counter,sum);
   }
while((ch = getchar())!=‘q’);
   return 0;
}

這個程式執行後,加上主執行緒共有4個執行緒在運行,子執行緒執行的都是thread_worker 函式中的內容: 

在這塊,其實對於子執行緒共享了主執行緒的哪些資源,不必死記硬背。既然子執行緒運行的是函式中的內容,我們不妨就把子執行緒的運行想象成在呼叫函式。只是與我們平時寫的單執行緒的程式不同的是,thread_worker 函式被呼叫了3次,而且3個函式在同時被執行。main_counter 和 counter[] 陣列是全域性的,所以3個子執行緒可以直接使用和改變它們。而thread_num 是函式內的區域性變數,所以執行緒之間互相不可見。

下來看看在這個程式中我們可能會遇到哪些問題?

問題1傳參很詭異

原因:傳參被主執行緒破壞

分析: 
   正像上面代碼中那樣,我們在創建執行緒的時候習慣於傳指標或取地址進去,即 &i; :

pthread_create(&pthread_id[i],NULL,thread_worker,&i);
這時發現運行結果是這樣的:

很奇怪,0號執行緒和1號執行緒的迴圈次數是0,多執行幾次發現經常會有執行緒迴圈次數為0,但是3個執行緒分明都被創建成功了,不可能不執行for 迴圈。

將代碼1 函式中的註釋去掉,我們打印一下thread_num 的值是否正常,同時打印執行緒ID用於區分執行緒:

居然沒有1號執行緒打印的 counter[i] ,但是打印3個thread_id 值不同,說明執行緒1也在運行。只是因為 i = 1 傳入執行緒函式後,thread_num 卻變成了2,導致最後執行緒1 和 執行緒2 都是在對 counter[2] 執行加法操作。看來傳參過程出現了問題。

看一下引數傳遞的具體過程:


很明顯,當執行緒在執行thread_num的賦值操作之前很有可能因為時間片用完將CPU控制權交給其他執行緒或者此時有其他執行緒在同時運行(多核CPU)。

當傳 &i; 進去時,可能會發生以下情況:

如上圖,如果在賦值之前,主執行緒進行了下一次for 迴圈,執行 i++ ,準備創建 1 號執行緒時,*arg 變成了 1 。因為 &i; = arg = 0x6666,它們對應的是同一塊記憶體。

對了,上述代碼還有可能會出現3個執行緒傳過去的引數都變成0的情況,起初很不解,引數應該只會偏大不應該比真實值小啊。在谷仕濤同學的提醒下,終於找到了原因,源頭在這塊代碼:

當主執行緒很快的執行完44~47的for迴圈,馬上又進入49~行的do while 迴圈中,並且執行了52行for 的第一次迴圈時,i 被賦值為了0,此時就有可能導致thread_worker 函式中的 *arg 變為了0,使得傳參發生異常。

解決方法

1、 值傳遞

直接傳 i 的值進入,而不是傳地址。

void* thread_worker(void*  arg)
{
   int  thread_num = (int)arg;

   

}

int main(int argc,char* argv[])
{    
   
for(i=0;iNULL,thread_worker,(void*)i);
   }  

     
   
return 0;
}

現在傳參正常了。 
但是,也許你還是有點不滿意,編譯的時候有個警告,不太想看見它:

因為編譯器雖然支持(void)轉化為(int),但還是不推薦這樣做,所以產生了 warning 。我們還可以用其他方法。

2、 借用陣列傳參

將3次傳遞的引數分別儲存為陣列的不同元素:

//關鍵代碼

void* thread_worker(void*  arg)
{
   //先將void* 轉為 int* 再賦值
   int  thread_num = *(int*)arg;

   

}

int main(int argc,char* argv[])
{
   int                 i,rtn,ch;
   pthread_t           pthread_id[MAX_THREAD] =  {0};  //存放執行緒
   //儲存引數的陣列
   int                 param[
3];

   for(i=0;iNULL,thread_worker,param+i);
   }  

 

   return 0;
}

這個方法可以解決問題,但是不具有靈活性,如果創建了很多個執行緒呢,難道要有一個很大的陣列麽。執行緒數量不確定怎麼辦,陣列定為多大才是合適的呢? 
下麵我們採用第3種方法。

3、動態申請臨時記憶體

因為每次申請記憶體傳回的地址都不一樣,所以引數傳指標進去不會有問題,要記得賦值完釋放記憶體,避免記憶體泄漏。

void* thread_worker(void*  arg)
{
   //先將void* 轉為 int* 再賦值
   int  thread_num = *(int*)arg;
   //釋放記憶體
   free((int*)arg);

   

}

int main(int argc,char* argv[])
{
   int                 i,rtn,ch;
   pthread_t           pthread_id[MAX_THREAD] =  {0};  //存放執行緒

   int                 *param;    for(i=0;iNULL,thread_worker,param);
   }    

   return 0;
}

問題2: main_counter < sum

原因:子執行緒相互競爭 main_counter ,導致不能正常執行 加 1 操作。

分析: 
  解決了問題1,我們發現還有問題,main_counter 居然不等於 sum 的值,與理論不符。這主要是由於 main_counter++ 陳述句並不是原子操作。 
什麼是原子操作呢? 
   通俗的將,就是執行緒執行某個操作的時候不能被其他執行緒打斷或破壞。好比說,你去食堂吃飯,剛刷了卡,結果給你的菜卻被另一個剛來的同學給端走了。

簡單的看一下執行 main_counter++ 的過程:

可能當執行緒 1 還沒完成加 1 操作的時候,此時,執行緒2 也開始執行 main_counter++(如果是單核CPU,執行緒 1 會暫時儲存暫存器中的值,待下一個時間片到來時,恢復現場繼續操作; 如果是多核CPU,執行緒 1 和執行緒 2 可能會同時執行++) ,但是執行緒 2 看到的main_counter 還是 0 ,所以執行緒 2完成了加 1 操作後,main_counter 還是 1。雖然兩個執行緒各執行了一次加 1 操作,但是最終 main_counter 實際上只加了1次。這就導致main_counter 比理論值偏小。在3個執行緒運行的情況下,理論值最大是實際值的3倍。

解決辦法:輸出的時候加鎖

代碼2:

//初始化鎖

static pthread_mutex_t    mutex = PTHREAD_MUTEX_INITIALIZER;

unsigned long long   main_counter,counter[MAX_THREAD]={0};

void* thread_worker(void*  arg)
{    
//先將void* 轉為 int* 再賦值
   
int  thread_num = *(int*)arg;

   //釋放記憶體

   free((int*)arg);    for(;;)
   {        
//加鎖
       pthread_mutex_lock(&mutex;);
       counter[thread_num]++;
 //本執行緒的counter 加 1

       main_counter++;

       //解鎖
       pthread_mutex_unlock(&mutex;);
   }
}

int main(int argc,char* argv[])
{    
int                 i,rtn,ch;
   pthread_t           pthread_id[MAX_THREAD] =  {
0};  //存放執行緒

   int                 *param;    for(i=0;i//申請記憶體臨時儲存引數
       param = (
int*)malloc(sizeof(int));
       *param = i;
       pthread_create(&pthread;_id[i],NULL,thread_worker,param);
   }    

do
   {      

         unsigned long long   sum = 0;

       for(i=0;i

           printf(“No.%d: %llu\n”,i,counter[i]);
       }        

    printf(“%llu/%llu\n”,main_counter,sum);
   }
while((ch = getchar())!=‘q’);    //銷毀鎖資源
   pthread_mutex_destroy(&mutex;);

   return 0;
}

看吧,sum 不再比 main_counter 大了。

但是,還是不能消停,這回發現 main_counter 居然比 sum 大了。如果是單核CPU,比如在單核雲服務器、虛擬機(VMWare、VirtualBox等)系統上運行時,可能看到main_counter = sum ,但是不代表不會出現main_counter > sum 的情況,還是有隱患的。這些問題我們接下來繼續解決。

問題3:main_counter 為毛比 sum 大了?

原因:還是競爭。調整加鎖的位置。

   其實這是主執行緒輸出的問題,問題主要出在以下這段代碼:


第57~61 行是在計算counter[i] 的和,將計算結果儲存到sum 中,然後第62行輸出結果。問題是在這個過程中也會發生競爭: 
(1)如果在主執行緒剛執行完求和操作還未輸出時,時間片用完了,CPU被子執行緒搶占,執行了main_counter++,但sum 不會再同步增加了,所以最後輸出時,main_counter > sum 。 
(2)如果是多核CPU,在主執行緒完成求和操作到輸出結果這一段時間內很可能有子執行緒在並行執行main_counter++,同樣sum 卻不會增加,導致輸出時,main_counter > sum 。

那在單核系統上為什麼代碼2 執行幾乎是正常的呢? 
   在單核系統上,運行出現異常是原因(1)導致,因為單核上CPU調度執行緒是用的時間片輪轉。大概因為57~62 行這幾條陳述句執行時間太短了,一個時間片內可以執行完,幾乎不會在中途被打斷。

我們可以在62行前加 sleep 睡眠,這時,隱患就暴露出來了。

然後在我的單核雲服務器上運行: 

果然,main_counter 比 sum 大了。

解決辦法:調整鎖的位置

   解決問題的關鍵就是 a、要讓 main_counter++ 和 counter[i]++ 是保持同步更新的, 這兩條陳述句中間不能被打斷; b、求和操作完成後,不能再讓 main_counter 增加。

我們把解鎖操作移到 62 行之後就可以了。

最終的完整代碼如下:

#include

#include

#include

#include

#include

#include

#define MAX_THREAD  3  //執行緒個數

//初始化鎖

static pthread_mutex_t    mutex = PTHREAD_MUTEX_INITIALIZER;

unsigned long long   main_counter,counter[MAX_THREAD]={0};

void* thread_worker(void*  arg)
{    
//先將void* 轉為 int* 再賦值
   
int  thread_num = *(int*)arg;    //釋放記憶體
   
free((int*)arg);

   for(;;)
   {        
//加鎖
       pthread_mutex_lock(&mutex;);
       counter[thread_num]++;    
//本執行緒的counter 加 1
       main_counter++;        
//解鎖
       pthread_mutex_unlock(&mutex;);        
//sleep(3);
   }
}

int main(int argc,char* argv[])
{    
int                 i,rtn,ch;
   pthread_t           pthread_id[MAX_THREAD] =  {
0};  //存放執行緒

   int                 *param;

   for(i=0;i//申請記憶體臨時儲存引數
       param = (
int*)malloc(sizeof(int));
       *param = i;
       pthread_create(&pthread;_id[i],NULL,thread_worker,param);
   }    

    do
   {        
//加鎖
       pthread_mutex_lock(&mutex;);

       unsigned long long   sum = 0;

       for(i=0;i

           printf(“No.%d: %llu\n”,i,counter[i]);
       }      

     printf(“%llu/%llu\n”,main_counter,sum);        //解鎖
       pthread_mutex_unlock(&mutex;);
   }
while((ch = getchar())!=‘q’);    //銷毀鎖資源
   pthread_mutex_destroy(&mutex;);

   return 0;
}
 

運行: main_counter == sum ,與理論符合

如有問題或錯誤,歡迎指出。

赞(0)

分享創造快樂