題目《讓CPU佔用率曲線聽你指揮》
問題
寫一個程序,讓用戶來決定Windows任務管理器(Task Manager)的CPU佔用率。程序越精簡越好,計算機語言不限。例如,可以實現下面三種情況:
1. CPU的佔用率固定在50%,爲一條直線;
2. CPU的佔用率爲一條直線,但是具體佔用率由命令行參數決定(參數範圍1~ 100);
3. CPU的佔用率狀態是一個正弦曲線。
分析與解法
有一名學生寫了如下的代碼:
while (true)
{
if (busy)
i++;
else
}
然後她就陷入了苦苦思索:else幹什麼呢?怎麼才能讓電腦不做事情呢?CPU使用率爲0的時候,到底是什麼東西在用CPU?另一名學生花了很多時間構想如何“深入內核,以控制CPU佔用率”——可是事情真的有這麼複雜麼?
MSRA TTG(Microsoft Research Asia, Technology Transfer Group)的一些實習生寫了各種解法,他們寫的簡單程序可以達到如圖1-1所示的效果。
圖1-1 編碼控制CPU佔用率呈現正弦曲線形態
看來這並不是不可能完成的任務。讓我們仔細地回想一下寫程序時曾經碰到的問題,如果我們不小心寫了一個死循環,CPU佔用率就會跳到最高,並且一直保持100%。我們也可以打開任務管理器,實際觀測一下它是怎樣變動的。憑肉眼觀察,它大約是1秒鐘更新一次。一般情況下,CPU使用率會很低。但是,當用戶運行一個程序,執行一些複雜操作的時候,CPU的使用率會急劇升高。當用戶晃動鼠標時,CPU的使用率也有小幅度的變化。
那當任務管理器報告CPU使用率爲0的時候,誰在使用CPU呢?通過任務管理器的“進程(Process)”一欄可以看到,System Idle Process佔用了CPU空閒的時間——這時候大家該回憶起在“操作系統原理”這門課上學到的一些知識了吧。系統中有那麼多進程,它們什麼時候能“閒下來”呢?答案很簡單,這些程序或者在等待用戶的輸入,或者在等待某些事件的發生(WaitForSingleObject()),或者進入休眠狀態(通過Sleep()來實現)。
在任務管理器的一個刷新週期內,CPU忙(執行應用程序)的時間和刷新週期總時間的比率,就是CPU的佔用率,也就是說,任務管理器中顯示的是每個刷新週期內CPU佔用率的統計平均值。因此,我們寫一個程序,讓它在任務管理器的刷新期間內一會兒忙,一會兒閒,然後通過調節忙/閒的比例,就可以控制任務管理器中顯示的CPU佔用率。
【解法一】簡單的解法
步驟1 要操縱CPU的usage曲線,就需要使CPU在一段時間內(根據Task Manager的採樣率)跑busy和idle兩個不同的loop,從而通過不同的時間 比例,來獲得調節CPU Usage的效果。
步驟2 Busy loop可以通過執行空循環來實現,idle可以通過Sleep()來實現。
問題的關鍵在於如何控制兩個loop的時間,方法有二:
Sleep一段時間,然後以for循環n次,估算n的值。
那麼對於一個空循環for(i = 0; i < n; i++);又該如何來估算這個最合適的n值呢?我們都知道CPU執行的是機器指令,而最接近於機器指令的語言是彙編語言,所以我們可以先把這個空循環簡單地寫成如下彙編代碼後再進行分析:
loop:
mov dx i ;將i置入dx寄存器
inc dx ;將dx寄存器加1
mov i dx ;將dx中的值賦回i
cmp i n ;比較i和n
jl loop ;i小於n時則重複循環
假設這段代碼要運行的CPU是P4 2.4Ghz(2.4 * 10的9次方個時鐘週期每秒)。現代CPU每個時鐘週期可以執行兩條以上的代碼,那麼我們就取平均值兩條,於是讓(2 400 000 000 * 2)/5=960 000 000(循環/秒),也就是說CPU 1秒鐘可以運行這個空循環960 000 000次。不過我們還是不能簡單地將n = 60 000 000,然後Sleep(1000)了事。如果我們讓CPU工作1秒鐘,然後休息1秒鐘,波形很有可能就是鋸齒狀的——先達到一個峯值(大於>50%),然後跌到一個很低的佔用率。
我們嘗試着降低兩個數量級,令n = 9 600 000,而睡眠時間相應改爲10毫秒(Sleep(10))。用10毫秒是因爲它不大也不小,比較接近Windows的調度時間片。如果選得太小(比如1毫秒),則會造成線程頻繁地被喚醒和掛起,無形中又增加了內核時間的不確定性影響。最後我們可以得到如下代碼:
代碼清單1-1
int main()
{
for(;;)
{
for(int i = 0; i < 9600000; i++);
Sleep(10);
}
return 0;
}
在不斷調整9 600 000的參數後,我們就可以在一臺指定的機器上獲得一條大致穩定的50% CPU佔用率直線。
使用這種方法要注意兩點影響:
1. 儘量減少sleep/awake的頻率,如果頻繁發生,影響則會很大,因爲此時優先級更高的操作系統內核調度程序會佔用很多CPU運算時間。
2. 儘量不要調用system call(比如I/O這些privilege instruction),因爲它也會導致很多不可控的內核運行時間。
該方法的缺點也很明顯:不能適應機器差異性。一旦換了一個CPU,我們又得重新估算n值。有沒有辦法動態地瞭解CPU的運算能力,然後自動調節忙/閒的時間比呢?請看下一個解法。
【解法二】使用GetTickCount()和Sleep()
我們知道GetTickCount()可以得到“系統啓動到現在”的毫秒值,最多能夠統計到49.7天。另外,利用Sleep()函數,最多也只能精確到1毫秒。因此,可以在“毫秒”這個量級做操作和比較。具體如下:
利用GetTickCount()來實現busy loop的循環,用Sleep()實現idle loop。僞代碼如下:
代碼清單1-2
int busyTime = 10; //10 ms
int idleTime = busyTime; //same ratio will lead to 50% cpu usage
Int64 startTime = 0;
while (true)
{
startTime = GetTickCount();
// busy loop的循環
while ((GetTickCount() - startTime) <= busyTime) ;
//idle loop
Sleep(idleTime);
}
這兩種解法都是假設目前系統上只有當前程序在運行,但實際上,操作系統中有很多程序都會在不同時間執行各種各樣的任務,如果此刻其他進程使用了10% 的CPU,那我們的程序應該只能使用40%的CPU(而不是機械地佔用50%),這樣可達到50%的效果。
怎麼做呢?
我們得知道“當前CPU佔用率是多少”,這就要用到另一個工具來幫忙——Perfmon.exe。
Perfmon是從Windows NT開始就包含在Windows服務器和臺式機操作系統的管理工具組中的專業監視工具之一(如圖1-2所示)。Perfmon可監視各類系統計數器,獲取有關操作系統、應用程序和硬件的統計數字。Perfmon的用法相當直接,只要選擇您所要監視的對象(比如:處理器、RAM或硬盤),然後選擇所要監視的計數器(比如監視物理磁盤對象時的平均隊列長度)即可。還可以選擇所要監視的實例,比如面對一臺多CPU服務器時,可以選擇監視特定的處理器。
圖1-2 系統監視器(Perfmon)
我們可以寫程序來查詢Perfmon的值,Microsoft .Net Framework提供了PerformanceCounter()這一類型,從而可以方便地拿到當前各種計算機性能數據,包括CPU的使用率。例如下面這個程序——
【解法三】能動態適應的解法
代碼清單1-3
//C# code
static void MakeUsage(float level)
{
PerformanceCounter p = new PerformanceCounter("Processor", "% Processor Time", "_Total");
while (true)
{
if (p.NextValue() > level)
System.Threading.Thread.Sleep(10);
}
}
可以看到,上面的解法能方便地處理各種CPU使用率參數。這個程序可以解答前面提到的問題2。
有了前面的積累,我們應該可以讓任務管理器畫出優美的正弦曲線了,見下面的代碼。
【解法四】正弦曲線
代碼清單1-4
//C++ code to make task manager generate sine graph
#include "Windows.h"
#include "stdlib.h"
#include "math.h"
const double SPLIT = 0.01;
const int COUNT = 200;
const double PI = 3.14159265;
const int INTERVAL = 300;
int _tmain(int argc, _TCHAR* argv[])
{
DWORD busySpan[COUNT]; //array of busy times
DWORD idleSpan[COUNT]; //array of idle times
int half = INTERVAL / 2;
double radian = 0.0;
for(int i = 0; i < COUNT; i++)
{
busySpan[i] = (DWORD)(half + (sin(PI * radian) * half));
idleSpan[i] = INTERVAL - busySpan[i];
radian += SPLIT;
}
DWORD startTime = 0;
int j = 0;
while (true)
{
j = j % COUNT;
startTime = GetTickCount();
while ((GetTickCount() - startTime) <= busySpan[j]) ;
Sleep(idleSpan[j]);
j++;
}
return 0;
}
討論
如果機器是多CPU,上面的程序會出現什麼結果?如何在多個CPU時顯示同樣的狀態?例如,在雙核的機器上,如果讓一個單線程的程序死循環,能讓兩個CPU的使用率達到50%的水平麼?爲什麼?
多CPU的問題首先需要獲得系統的CPU信息。可以使用GetProcessorInfo()獲得多處理器的信息,然後指定進程在哪一個處理器上運行。其中指定運行使用的是SetThreadAffinityMask()函數。
另外,還可以使用RDTSC指令獲取當前CPU核心運行週期數。
在x86平臺上定義函數:
inline __int64 GetCPUTickCount()
{
__asm
{
rdtsc;
}
}
在x64平臺上定義:
#define GetCPUTickCount() __rdtsc()
使用CallNtPowerInformation API得到CPU頻率,從而將週期數轉化爲毫秒數,例如:
代碼清單1-5
_PROCESSOR_POWER_INFORMATION info;
CallNTPowerInformation(11, //query processor power information
NULL, //no input buffer
0, //input buffer size is zero
&info, //output buffer
Sizeof(info)); //outbuf size
__int64 t_begin = GetCPUTickCount();
//do something
__int64 t_end = GetCPUTickCount();
double millisec = ((double)t_end –
(double)t_begin)/(double)info.CurrentMhz;
RDTSC指令讀取當前CPU的週期數,在多CPU系統中,這個週期數在不同的CPU之間基數不同,頻率也有可能不同。用從兩個不同的CPU得到的週期數作計算會得出沒有意義的值。如果線程在運行中被調度到了不同的CPU,就會出現上述情況。可用SetThreadAffinityMask避免線程遷移。另外,CPU的頻率會隨系統供電及負荷情況有所調整。
總結
能幫助你瞭解當前線程/進程/系統效能的API大致有以下這些:
1. Sleep()——這個方法能讓當前線程“停”下來。
2. WaitForSingleObject()——自己停下來,等待某個事件發生
3. GetTickCount()——有人把Tick翻譯成“嘀嗒”,很形象。
4. QueryPerformanceFrequency()、QueryPerformanceCounter()——讓你訪問到精度更高的CPU數據。
5. timeGetSystemTime()——是另一個得到高精度時間的方法。
6. PerformanceCounter——效能計數器。
7. GetProcessorInfo()/SetThreadAffinityMask()。遇到多核的問題怎麼辦呢?這兩個方法能夠幫你更好地控制CPU。
8. GetCPUTickCount()。想拿到CPU核心運行週期數嗎?用用這個方法吧。