C語言多線程編程實戰

我沒有專門學習過多線程,非常可能有更簡單的方法,我說了一大堆廢話只是繞了遠路。不過這次嘗試中應該還是有很多自己的感悟的,所以如果你是大神,覺得很滑稽,這個家夥寫得都是什麽垃圾啊,笑一笑就好啦 : ) 如果你是和我一樣的小白,歡迎共勉共同進步。

想要實現一個Text UI (我對命令行情有獨鐘,因為我做不出圖形界面) ,要控制光標同時繪制多個區域以及時響應。那麽這個就很明顯用到多線程了,奈何我對多線程一竅不通,於是…

  • 這是我理想中的窗體:
  • 實際畫出來的效果是這樣的:

這一坨坨條形碼,讓我頓時感到世界對自己充滿了惡意…

第一次排錯

這個其實很明顯。控制台的標準輸出就一個,多個線程控制著光標滿屏亂跑,A線程抱著光標鉆到草叢里還沒幹事請呢就被B線程橫刀奪愛,最後搞出來的東西自然是誰都不像。

我於是用了一個隊列,思路是這樣的:

  • 新建一個隊列q,函數queLock()、函數queUnlock()、Bool類型變量queueLock
  • 當A函數想要執行敏感操作(比如修改同一個變量)的時候,就去使用queLock()。這個函數會給它添加一個標識到隊列尾部。然後不斷檢查隊列頭和queueLock,如果queueLock變成false且隊頭輪到這個標識符了,就瞬間取出這個標識符然後鎖上queueLock
  • 當敏感操作完畢後,用queUnlock取消queueLock的鎖。
  • 這就相當於queLock()暫時鎖住了這個線程。雖然降低了部分效率,但是還是可以體現多線程的優勢的。

我的代碼是這樣的。

bool queueLock=false;
int ident=0;
queue<int> q;
inline void queLock(){
    int id=++ident;
    q.push(id);
    while(1){
        if(!queueLock&&q.front()==id){
            queueLock=true;
            q.pop();
            return;
        }
    }
}
inline void queUnlock(){
    queueLock=false; //inline會在編譯的時候直接插入代碼,因此無需擔心調用費時。
}

這應該解決了吧?

然鵝…

基本沒有變化!

圖樣圖森破啊!

第二次排錯

經過研究,我終於发現了問題所在。如果你正在看(且你不是我),你估計早就发現問題了。

int id=++ident;

這個完美無缺的函數第一行就出現了問題。

這個操作先給ident+1,然後將此時的ident賦值給id。這個連在一起寫可能看不出來,那麽我分開。

//上面的代碼等價於:
ident++;
int id;
id=ident;

這就很明顯了吧。另外一個線程完全有可能在前兩行或者後兩行中間橫插一腿。

所以這個id放在這里就是個花瓶。等於把問題從stdout的訪問沖突轉嫁到了ident的訪問沖突上。確實訪問效率更高了,但是沒有從根本上解決問題。

解決方案其實也很簡單。關鍵在於ident訪問沖突,這個ident是幹什麽的啊?用來區別線程的。區別線程我幹嘛要專門弄一個標識符,這不是畫蛇添足嗎??

直接上改正過的代碼了。

bool queueLock=false;
queue<HANDLE> q;
inline void queLock(HANDLE hThread){ //排隊+加鎖
    q.push(hThread);
    while(1){
        if(!queueLock&&q.front()==hThread){ 
            queueLock=true;
            q.pop();
            CloseHandle(hThread); 
            return;
        }
    }
}
inline void queUnlock(){
    queueLock=false;
}

經過這次調整,效果很明顯。发生問題的幾率降低了50%,錯亂也含蓄了很多,從大塊錯亂變成了標題移位。

還是不給力啊!百分之五十算個毛線??標題移位算個毛線???

第三次排錯

經過又一翻的 苦(拔) 思(禿) 冥(頭) 想(发) ,終於OK了。

仔細看下第二次的代碼,先留出一炷香的時間思考那里有問題。



想出來了嗎?

這個if語句看起來與世無爭,屬於誰都不會去考慮的類型,在一般的程序中是絕對沒有任何問題的。但是這是多線程編程,即使代碼寫的再緊湊,每條語句中間還是有延遲的。

上面的if語句可以拆解成下面的語句:

//與上面的語句等價
if(!queueLock){  //第一個判斷 
    if(q.front()==hThread){ //第二個判斷
            queueLock=true;
            q.pop();
            CloseHandle(hThread); 
            return;
    }
 }
inline void queUnlock(){
    queueLock=false;
}

那麽,如果甲線程在執行第一個判斷的時候,丙線程剛剛執行完畢,把queueLock給取消掉了。這個時候甲和乙齊頭並進,都完成了第一重判斷。這個時候甲線程率先完成第二重判斷並pop掉了自己,於是乙再次完成第二重判斷,和甲線程一起進入了多線程狀態。
(這里的乙可以換成任意一個非甲線程,丙丁戊己庚辛 隨便那個都可以。在這個程序里面肯定有一堆線程等著要出這個頭。)

解決方案就是,把if中的兩個條件調換一下順序。理解了BUG存在的原因,那麽怎麽去掉他就非常容易了。

代碼我就不貼了,直接貼圖我方便,你看著也方便。

那麽這樣運行出來之後就穩定的得到了本文的第一張圖,多次測試沒有變過,應該是成功了。

現在可以來驗證一下我的猜想,到底是不是這樣。

如果我的猜想成立的的話,那麽這個BUG发生的條件是”同時存在三個及以上的線程“。我於是去掉了一個線程(框框)反覆嘗試,確實,一直都沒有发生問題。

為什麽出現錯誤時只有標題移位我還沒有搞清楚。我猜想是系統提供的輸出函數會使用自己的方法去後移坐標位置,方法應該比我的SetCursorPosition()更加基礎,也更快一點。這種速度跟我的慢速沖突了,所以系統的輸出爭先恐後的湧到前面來導致了錯亂。

總結

在這個程序里我完成的事請事實上是在特定條件下把並行改為並发,用了隊列的這個結構。隊列中其實同時存在的元素上限恒等於同時存在的進程數上限。這個是使用了隊列結構的特性。

第一個問題(第零次排錯)的发生,只是用來作為本文的開頭提出問題的。可以對問題有個直觀認識,我畢竟還沒蠢到那個程度。

第二個問題(第一次排錯所引发的)是沒有認清楚解決問題的本質。我實際上幹了轉移問題的操作,而沒有從實質解決問題。問題從進程搶光標變成了進程搶標識符。

第三個問題(第二次排錯所引发的)是沒有細節。多線程編程是很講究細節的,if判斷還是建議分開來寫,不然大大延長出現bug後拔頭发的時間。

而且多線程編程還是對調試不太友好的(特別是命令行情況下)。你不能開一個調試窗格去搞它,你一開,結果又不一樣了。你不能去增大延時去仔細看輸出順序,因為你開延時之後就沒問題了。這個有點像薛定諤的貓,開箱之後你最終只能看到貓的死活,你看不到貓是怎麽死掉的。所以處理這種問題的時候還是建議自己開思維導圖逐行的推斷,給自己大腦編個碼,也許問題就解決了。

後話

剛剛发現,手動的一遍遍穩定性測試弱爆了….

其實如果真的要測試到底有沒有問題,寫一個線程不停搶資源就是了。

void cpu_eater(void*){
    while(1){
        queLock(getHandle());
        queUnlock();
    }
}
int main(void)  
{  
    _beginthread(win_playlist,0,NULL);
    _beginthread(win_menu,0,NULL); 
    _beginthread(win_progressbar,0,NULL);
    //下面是搶資源線程
    _beginthread(cpu_eater,0,NULL);
    _beginthread(cpu_eater,0,NULL);
    _beginthread(cpu_eater,0,NULL);
    //上面是搶資源線程
    sleep(LIFETIME_DELAY);
    return 0;  
}

一旦有問題,在三個線程不停跟他搶資源的情況下肯定立即就暴露出來了。這樣可以非常方便的一遍檢出問題。我剛剛寫完才发現有此等操作,真是冤煞我的Ctrl+F5。

下面是開了三個搶資源線程的動圖。

還蠻美觀的蛤。(這就完全沒問題了)

思考題

留一道思考題。

如果你不像我這樣戰五渣就試試看這道題。

上面的代碼我通過修正判斷的順序解決了問題。但是我如果不去修正判斷順序,而是修正執行順序,即:

調換這兩行順序,能不能一樣達成目的呢?

(偷偷透露下,不行。)

那麽為什麽不行呢?

答案:image-20200407183057649

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *