有些程式設計者在完成程式之後,會發現他們的程式執行的行為及結果都正確,但是,有個嚴重的問題,也就是他們所完成的程式會佔去絕大多數的CPU時間,有時甚至高達99%或100%。有時候,他們不會那麼快的察覺,但是,會發現電腦的反應似乎鈍鈍的,接著追查問題的原因,才發現所開發的程式將CPU的時間耗盡。

一個會霸佔電腦CPU時間的程式當然無法被正式使用。但是,在實際的經驗中有個結果讓我滿驚訝的,有一些程式設計者不知道為他所寫的程式為何會霸佔電腦CPU時間,也不知道如何解決或避免。更嚴重的是,這個問題時常是在無意間引發,引發的人卻不知道問題從何而起(有時甚至還沒有察覺到問題),也不知道該如何解決。而這正是本文這次所要探討的主題。

持續等待某個條件成立的迴圈
導致這個問題的原因,通常是因為程式中含有一個以上的「忙碌迴圈(busy loop)」。何謂「忙碌迴圈」呢?最常見到的忙碌迴圈形式,就是所謂的「忙碌等待(busy waiting)」,而忙碌等待就是在一個迴圈裡,不斷等待某個條件的成立,必須等該條件成立之後,才會離開該迴圈。例如:

while( eventFlag == false )
;

若eventFlag為false,那麼迴圈就會持續執行,直至同一個程序中的另一個執行緒,將eventFlag值改為非false時,才會離開迴圈。這在邏輯上一點問題也沒有,但因為多工作業系統的特性,因而衍生出效能的問題,佔據了CPU可供執行的時間,讓其他程式無法分配到足夠的CPU時間,使得其他程式呈現反應遲滯的現象。就像上面的程式片段中,該迴圈不斷持續地忙著檢查eventFlag的值,所以被稱為是一個忙碌的迴圈。

為什麼會說是多工作業系統的特性所導致呢?

在以round robin和priority queue為基礎多工的作業系統中,會有個工作的排程器(scheduler),它會將CPU的時間切割成執行的時間單位(例如在Win NT裡就叫quantum)。這個時間單位代表的是某個執行緒(對多執行緒的作業系統而言)被分配到執行權後,可以執行的最長時間片段。但在這個過程中,如果執行緒結束,或執行緒必須被某些事件的等待動作(例如做disk I/O必須等I/O動作完成,或是等待另一個同步物件)所影響而無法執行時,也會提前讓排程器介入,選出下一個要執行的執行緒,而暫時停止現行執行緒的執行。

而所謂的「忙碌迴圈(busy loop)」就是一種在迴圈裡只包括執行純粹CPU指令的動作,不僅不呼叫任何會造成等待的系統呼叫(system call)也不會等候任何事件,包括程式中最常見到的I/O動作。而這種單純只是執行純粹CPU指令的程式,就會變成所謂的CPU密集型(CPU intensive)的程式。因為,它的行為會傾向於盡力佔用CPU時間。

哪些是瞎忙的執行緒?
當我們在程式裡放了一個所謂的「忙碌迴圈」時,這個忙碌迴圈如果執行時間夠長,幾乎就會讓它所在的執行緒,耗盡整個被分配到的執行時間(一個排程的基本單位)。但如果是那種含有I/O動作的程式,會因為執行到I/O動作,而必須進入等待I/O動作完成的狀態,使得排程器提前結束它所被分配到的執行時間,改而選出下一個待執行的執行緒,並且將CPU時間交予該執行緒。所以,對於整個分配到的執行時間,此類的程式往往不會耗完。

對那種在單一迴圈裡只是純粹的執行CPU指令的程式碼,因為現代CPU運算時間極快,所以可以在很短的時間內執行非常大量的CPU指令,但其實其中的絕大多數都不會有實際的作用。例如,在迴圈裡只是檢查一個變數的值,其實早或晚個一毫秒,也不會有多大的差別,但是,因為這樣的執行緒一旦被分配到了CPU的執行權利,就會在這個時段裡拼命執行。可惜的是,大多數的執行沒有實際的效益,可以說是十分的忙碌,卻又是「瞎忙」。

如果只是自己瞎忙、不會影響到其他執行緒也就算了,然而,這種CPU密集型的執行緒若沒有其他的競爭者時,因為其他執行緒多半沒有完整的執行完被分配到的時間片段,甚至大多都處於等待某特定事件完成的狀態,CPU密集型的執行緒卻會徹底耗盡它所被分配到的時間,所以,它的執行時間在比例上,就會佔去絕大多數的CPU時間。

大多數稱為互動型(interactive)的執行緒,例如處理使用者在視窗上的滑鼠及鍵盤輸入的執行緒都是涉及較多I/O動作的,而這類的執行緒由於是直接面對使用者、負責處理和使用者之間的互動,所以反而需要比較快的反應速度。如果系統中正有個CPU密集型的執行緒在執行時,便會因為CPU密集型的執行緒傾向於佔用CPU時間,而影響到互動型執行緒獲得CPU時間的權利,因而大幅影響到此類執行緒的執行反應。就像我們時常會有的體驗,若是執行需要較高運算量的程式時,在操作電腦的使用者介面時,就會有反應遲鈍的感覺。

為此,作業系統的排程器也有可能會動態的區分出執行緒究竟是否具備CPU密集型的特性(其實只要檢查執行緒所消耗執行時間的情況就可以知道),據以動態的調低CPU密集型執行緒的執行優先序。像WinNT的排程器甚至還設計了所謂「boosting」的機制,在I/O密集型的執行緒等待完I/O動作之後,賦予它短暫的優先序調升,藉以提高此類執行緒的反應速度。

不過,即使有些作業系統的排程器,設計了上述類似的機制,來避免CPU密集型的執行緒「欺凌」I/O密集型的執行緒,但是,CPU密集型的執行緒仍然能構成影響。尤其是忙碌的迴圈更是能造成嚴重且明顯的衝擊。

防止的作法
當你在撰寫一段具有迴圈結構的程式碼時,就需要養成習慣,隨時思考它會不會是個忙碌迴圈。它也可能不是在等待一個變數的值有所異動,但在迴圈裡就是很單純的執行純CPU指令,例如不斷的將某個變數值加一之類的。諸如此類僅執行純CPU指令的迴圈,就會構成忙碌迴圈。一般的程式碼,即使只是單純的執行純CPU指令,也不會形成問題,這是因為它們並不會如在迴圈中反覆不斷快速地執行,所以不會造成傷害。

要避免「忙碌迴圈」,就必須在迴圈中做出會交出CPU使用權的事情,而不致於耗盡被分配到的整個時間片段,以便讓排程器有機會讓其他的執行緒接續執行。最常見到能交出CPU使用權的動作,就是I/O動作。所以,當你在迴圈中做了I/O動作時,並不會形成忙碌迴圈,就像我們時常利用迴圈來讀取檔案內容或從網路上接收資料,都不會佔用太多CPU時間,因為迴圈中主要內容就是I/O的動作。所以,迴圈中一旦有I/O動作,那就可以放心。

如果程式中需要倚賴事件的成立與否才繼續執行,那麼與其去反覆不斷輪詢代表該事件是否成立的變數值,不如利用一些IPC(Inter-Process Communication)的同步物件,來控制多執行緒之間的等待行為。

有時候,輪詢變數值以便等待某條件成立的情形在程式中無法避免,這時候還是有些方式可以讓出CPU的控制權,像是做一些讓執行緒休眠(sleep)的動作,例如在Windows上的Sleep()系統API。這類的API都會讓排程器再度作動,因而讓出既有的CPU權利。你可以選擇休眠一個很短的時間甚至是0,都可以將CPU控制權釋放出來,原先的目的也不致於被影響,例如:

while( eventFlag == false )
Sleep(0);

忙碌迴圈造成的影響不小,對於不明究理的人來說,卻是有可能在無意間觸發,如果明白它的導因以及避免的方式,相信可以避免不少的困擾。

 

專欄作者

熱門新聞

Advertisement