我沒有專門學習過多線程,非常可能有更簡單的方法,我說了一大堆廢話只是繞了遠路。不過這次嘗試中應該還是有很多自己的感悟的,所以如果你是大神,覺得很滑稽,這個家夥寫得都是什麽垃圾啊,笑一笑就好啦 : ) 如果你是和我一樣的小白,歡迎共勉共同進步。
想要實現一個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。
下面是開了三個搶資源線程的動圖。
還蠻美觀的蛤。(這就完全沒問題了)
思考題
留一道思考題。
如果你不像我這樣戰五渣就試試看這道題。
上面的代碼我通過修正判斷的順序解決了問題。但是我如果不去修正判斷順序,而是修正執行順序,即:
調換這兩行順序,能不能一樣達成目的呢?
(偷偷透露下,不行。)
那麽為什麽不行呢?