Win32 多執行緒的效能(2) (轉)
Win32 多執行緒的效能(2) (轉)[@more@]
Microsoft Developerwork 技術小組
ConcurrentExecution 的內部工作
請注意:本節的討論是非常技術性的,所以假設您理解很多有關 Win32 執行緒 的知識。如果您對如何使用 ConcurrentExecution 類來收集測試資料更加感興趣,而不是對 ConcurrentExecution::DoForAlls 是如何被實現的感興趣,那麼您現在就可以跳到下面的“使用 ConcurrentExecution 來試驗執行緒效能”一節。
讓我們從 DoSerial 開始,因為它很大程度上是一個“不費腦筋的傢伙”:
BOOL ConcurrentExecution::DoSerial(int iNoOfObjects,long *ObjectArray,
CONCURRENT_EXECUTION_ROUTINE pProcessor,
CONCURRENT_FINISHING_ROUTINE pTenator)
{
for (int iL=0;iLoop
{
pTerminator((LPVOID)ObjectArray[iLoop],(LPVOID)pProcessor((LPVOID)ObjectArray[iLoop]));
};
return TRUE;
};
這段程式碼只是迴圈遍歷該陣列,在每一次迭代中,然後在處理器和本身的結果上呼叫終結器。幹得既乾淨又漂亮,不是嗎?
令人感興趣的成員是 DoForAllObjects。乍一看,DoForAllObjects 所要做的也沒有什麼特別的——請求操作建立為每一個計算一個執行緒,並且確保終結器函式能夠被正確地呼叫。但是,有兩個問題使得 DoForAllObjects 比它的表面現象要複雜:第一,當計算的數目多於可用的執行緒數時,ConcurrentExecution 的一個例項所建立的“併發的最大度數”引數可能需要一些附加的記錄(bookkee)。第二,每一個計算的終結器函式都是在呼叫 DoForAllObjects 的執行緒的上下文中被呼叫的,而不是在該計算執行所處的執行緒上下文中被呼叫的;並且,終結器是在處理器結束之後立刻被呼叫的。要處理這些問題還是需要很多技巧的。
讓我們深入到程式碼中,看看究竟是怎麼樣的。該段程式碼是從 Thrdlib.cpp 中繼承來的,但是為了清除起見,已經被精簡了:
int ConcurrentExecution::DoForAllObjects(int iNoOfObjects,long *ObjectArray,
CONCURRENT_EXECUTION_ROUTINE pObjectProcessor,
CONCURRENT_FINISHING_ROUTINE
pObjectTerminated)
{
int iLoop,iEndLoop;
D iThread;
DWORD iArrayIndex;
DWORD dwReturnCode;
DWORD iCurrentArrayLength=0;
BOOL bWeFreedSomething;
char szBuf[70];
m_iCurrentNumberOfThreads=iNoOfObjects;
HANDLE *hPnt=(HANDLE *)VirtualAlloc(NULL,m_iCurrentNumberOfThreads*sizeof(HANDLE)
,MEM_COMMIT,PAGE_READWRITE);
for(iLoop=0;iLoop
hPnt[iLoop] = CreateThread(NULL,0,pObjectProcessor,(LPVOID)ObjectArray[iLoop],
CREATE_SUSPENDED,(LPDWORD)&iThread);
首先,我們為每一個物件建立單獨的執行緒。因為我們使用 CREATE_SUSPENDED 來建立該執行緒,所以還沒有執行緒被啟動。另一種方法是在需要時建立每一個執行緒。我決定不使用這種替代的策略,因為我發現當在一個同時執行了多個執行緒的應用中呼叫時, CreateThread 呼叫是非常浪費的;這樣,同在執行時建立每一個執行緒相比,在此時建立執行緒的開銷將更加容易接受,
for (iLoop = 0; iLoop < m_iCurrentNumberOfThreads; iLoop++)
{
HANDLE hNewThread;
bWeFreedSomething=FALSE;
// 如果陣列為空,分配一個 slot 和 boogie。
if (!iCurrentArrayLength)
{
iArrayIndex = 0;
iCurrentArrayLength=1;
}
else
{
// 首先,檢查我們是否可以重複使用任何的 slot。我們希望在查詢一個新的 slot 之前首先// 做這項工作,這樣我們就可以立刻呼叫該就執行緒的終結器...
iArrayIndex=WaitForMultipleObjects(iCurrentArrayLength,
m_hThreadArray,FALSE,0);
if (iArrayIndex==WAIT_TIMEOUT) // no slot free...
{
{
if (iCurrentArrayLength >= m_iMaxArraySize)
{
iArrayIndex= WaitForMultipleObjects(iCurrentArrayLength,
m_hThreadArray,FALSE,INFINITE);
bWeFreedSomething=TRUE;
}
else // 我們可以釋放某處的一個 slot,現在就這麼做...
{
iCurrentArrayLength++;
iArrayIndex=iCurrentArrayLength-1;
}; // Else iArrayIndex points to a thread that has been nuked
};
}
else bWeFreedSomething = TRUE;
}; // 在這裡,iArrayIndex 包含一個有效的以新的執行緒。
hNewThread = hPnt[iLoop];
ResumeThread(hNewThread);
if (bWeFreedSomething)
{
GetExitCodeThread(m_hThreadArray[iArrayIndex],&dwReturnCode); //錯誤
CloseHandle(m_hThreadArray[iArrayIndex]);
pObjectTerminated((void *)m_hObjectArray[iArrayIndex],(void *)dwReturnCode);
};
m_hThreadArray[iArrayIndex] = hNewThread;
m_hObjectArray[iArrayIndex] = ObjectArray[iLoop];
}; // 迴圈結束
DoForAllObjects 的核心是 hPnt,它是一個物件陣列,這些物件是當 ConcurrentExecution 物件被構造時分配的。該陣列能夠容納最大數目的執行緒,此最大數目與在建構函式中指定的最大併發度數相對應;因此,該陣列中的每一個元素都是一個"slot",並有一個計算居於之中。
關於決定如何填充和釋放的 slots 演算法如下:該物件陣列是從頭到尾遍歷的,並且對於每一個物件,我們都做如下的事情:如果尚未有 slot 已經被填充,我們使用當前的物件來填充該陣列中的第一個 slot,並且繼續將要處理當前物件的執行緒。如果至少有一個 slot 被使用,我們使用 WaitForMultipleObjects 函式來決定是否有正在執行的任何計算已經結束;如果是,我們在該物件上呼叫終結器,並且為新物件“重用”該 slot。請注意,我們也可以首先填充每一個空閒的 slot,直到沒有剩餘的 slots 為止,然後開始填充空的 slot。但是,如果我們這樣做了,那麼空出 slot 的終結器函式將不會被呼叫,直到所有的 slot 都已經被填充,這樣就違反了我們有關當處理器結束一個物件時,終結器立刻被呼叫的要求。
最後,還有沒有空閒 slot 的情況(就是說,當前啟用的執行緒數等於 ConcurrentExecution 物件所允許的最大併發度數)。在這種情況下,WaitForMultipleObjects 將被再次呼叫以使得 DoForAllObjects 處於“睡眠”狀態,直到有一個 slot 空出;只要這種情況一發生,終結器就被在空出 slot 的物件上呼叫,並且工作於當前物件的執行緒被繼續執行。
終於,所有的計算要麼都已經結束,要麼將佔有物件陣列中的 slot。下列的程式碼將會處理所有剩餘的執行緒:
iEndLoop = iCurrentArrayLength;
for (iLoop=iEndLoop;iLoop>0;iLoop--)
{
iArrayIndex=WaitForMultipleObjects(iLoop, m_hThreadArray,FALSE,INFINITE);
if (iArrayIndex==WAIT_FAILED)
{
GetLastError();
_asm int 3; // 這裡要做一些聰明的事...
};
GetExitCodeThread(m_hThreadArray[iArrayIndex],&dwReturnCode); // 錯誤?
if (!CloseHandle(m_hThreadArray[iArrayIndex]))
MessageBox(GetFocus(),"Can't delete thread!","",MB_OK); // 使它更好...
pObjectTerminated((void *)m_hObjectArray[iArrayIndex],
(void *)dwReturnCode);
if (iArrayIndex==iLoop-1) continue; // 這裡很好,沒有需要向後填充
m_hThreadArray[iArrayIndex]=m_hThreadArray[iLoop-1];
m_hObjectArray[iArrayIndex]=m_hObjectArray[iLoop-1];
};
最後,清除:
if (hPnt) VirtualFree(hPnt,m_iCurrentNumberOfThreads*sizeof(HANDLE),
MEM_DECOMMIT);
return iCurrentArrayLength;
};
使用 ConcurrentExecution 來試驗執行緒效能
效能測試的範圍如下:測試應用程式 Threadlibtest.exe 的可以指定是否測試基於 的或基於 I/O 的計算、執行多少個計算、計算的時間有多長、計算是如何排序的(為了測試最糟的情況與隨機延遲),以及計算是被併發執行還是執行。
為了消除意外的結果,每一個測試可以被執行十次,然後將十次的結果拿來平均,以產生一個更加可信的結果。
透過選擇選單選項 "Run entire test set",使用者可以請求執行所有測試變數的變形。在測試中使用的計算長度在基礎值 10 和 3,500 ms 之間變動(我一會兒將討論這一問題),計算的數目在 2 和 20 之間變化。如果在執行該測試的上了 Microsoft ,Threadlibtest.exe 將會把結果轉儲在一個 Microsoft Excel 工作表,該工作表位於 C:TempValues.xls。在任何情況下結果值也將會被儲存到一個純文字檔案中,該檔案位於 C:TempResults.fil。請注意,我對於檔案的位置使用了硬編碼的方式,純粹是懶惰行為;如果您需要在您的計算機上重新生成測試結果,並且需要指定一個不同的位置,那麼只需要重新編譯生成該工程,改變檔案 Threadlibtestview.cpp 的開頭部分的 TEXTFILELOC 和 SHEETFILELOC 識別符號的值即可。
請牢記,執行整個的測試程式將總是以最糟的情況來排序計算(就是說,執行的順序是序列的,最長的計算將被首先執行,其後跟隨著第二長的計算,然後以次類推)。這種方案犧牲了序列執行的靈活性,因為併發執行的響應時間在一個非最糟的方案下也沒有改變,而該序列執行的響應時間是有可能提高的。
正如我前面所提到的,在一個實際的方案中,您應該分析每一個計算的時間是否是可以預測的。
使用 ConcurrentExecution 類來收集效能資料的程式碼位於 Threadlibtestview.cpp 中。示例應用程式本身 (Threadlibtest.exe) 是一個真正的單文件介面 (SDI) 的 MFC 應用程式。所有與示例有關的程式碼都駐留在 view 類的實現 CThreadLibTestView 中,它是從 CEasyOutputView 繼承而來的。(有關對該類的討論,請參考" NT Security in Theory and Practice"。)這裡並不包含該類中所有的有趣程式碼,所包含的大部分是其數字統計部分和使用者介面處理部分。執行測試中的 "meat" 在 CThreadLibTestView::ExecuteTest 中,將執行一個測試執行週期。下面是有關 CThreadLibTestView::ExecuteTest 的簡略程式碼:
void CThreadlibtestView::ExecuteTest()
{
ConcurrentExecution *ce;
bCPUBound=((m_iCompType&CT_IOBOUND)==0); // 全域性...
ce = new ConcurrentExecution(25);
if (!QueryPerformanceCounter(&m_liOldVal)) return; // 獲得當前時間。
if (!m_iCompType&CT_IOBOUND) timeBeginPeriod(1);
if (m_iCompType&CT_CONCURRENT)
m_iThreadsUsed=ce->DoForAllObjects(m_iNumberOfThreads,
(long *)m_iNumbers,
(CONCURRENT_EXECUTION_ROUTINE)pProcessor,
(CONCURRENT_FINISHING_ROUTINE)pTerminator);
else
ce->DoSerial(m_iNumberOfThreads,
(long *)m_iNumbers,
(CONCURRENT_EXECUTION_ROUTINE)pProcessor,
(CONCURRENT_FINISHING_ROUTINE)pTerminator);
if (!m_iCompType&CT_IOBOUND) timeEndPeriod(1);
delete(ce);
< 其他的程式碼在一個陣列中排序結果,以供 Excel 處理...>
}
該段程式碼首先建立一個 ConcurrentExecution 類的物件,然後,取樣當前時間,(用於統計計算所消耗的時間和響應時間),並且,根據所請求的是序列執行還是併發執行,分別呼叫 ConcurrentExecution 物件 DoSerial 或 DoForAllObjects 成員。請注意,對於當前的執行我請求最大併發度數為 25;如果您想要執行有多於 25 個計算的測試程式,那麼您應該提高該值,使它大於或等於執行您的測試程式所需要的最大併發數。
讓我們看一下處理器和終結器,以得到精確測量的結果:
extern "C"
{
long WINAPI pProcessor(long iArg)
{
PTHREALOCKSTRUCT ptArg=(PTHREADBLOCKSTRUCT)iArg;
BOOL bResult=TRUE;
int lay=(ptArg->iDelay);
if (bCPUBound)
{
int iLoopCount;
iLoopCount=(int)(((float)iDelay/1000.0)*ptArg->tbOutputTarget->m_iBiaactor);
QueryPerformanceCounter(&ptArg->liStart);
for (int iCounter=0; iCounter
}
else
{
QueryPerformanceCounter(&ptArg->liStart);
Sleep(ptArg->iDelay);
};
return bResult;
}
long WINAPI pTerminator(long iArg, long iReturnCode)
{
PTHREADBLOCKSTRUCT ptArg=(PTHREADBLOCKSTRUCT)iArg;
QueryPerformanceCounter(&ptArg->liFinish);
ptArg->iEndOrder=iEndIndex++;
return(0);
}
}
處理器模擬一個計算,其長度已經被放到一個與計算有關的資料結構 THREADBLOCKSTRUCT 中。THREADBLOCKSTRUCT 保持著與計算有關的資料,如其延遲和終止時間(以效能計數“滴答”來衡量),以及反向指標,指向實用化該結構的檢視(view)。
透過簡單的使計算“睡眠”指定的時間就可以模擬基於I/O的計算。基於 CPU的計算將進入一個空的 for 迴圈。這裡的一些註釋是為了幫助理解程式碼的功能:計算是基於 CPU 的,並且假定其執行時間為指定的毫秒數。在本測試程式的早期版本中,我僅僅是要 for 迴圈執行足夠多的次數以滿足指定的延遲的需求,而不考慮數字的實際含義。(根據相關的程式碼,對於基於I/O的計算該數字實際意味著毫秒,而對於基於CPU的計算,該數字則意味著迭代次數。)但是,為了能夠使用絕對時間來比較基於CPU的計算和基於I/O的計算,我決定重寫這段程式碼,這樣無論對於基於CPU的計算還是基於I/O的計算,與計算有關的延遲都是以毫秒測量。
我發現對於具有指定的、預先定義時間長度的基於CPU的計算,要編寫程式碼來模擬它並不是一件簡單的事情。原因是這樣的程式碼本身不能查詢系統時間,因為所引發的呼叫遲早都會交出 CPU,而這違背了基於 CPU 的計算的要求。試圖使用非同步多時鐘事件同樣沒有得到滿意的效果,原因是 下計時器服務的工作方式。設定了一個多媒體計時器的執行緒實際上被掛起,直到該計時器回撥被呼叫;因此,基於 CPU 的計算突然變成了基於 I/O 的操作了。
於是,最後我使用了一個有點兒低劣的技巧:CThreadLibTestView::OnCreate 中的程式碼執行 100 次迴圈從 1 到 100,000 計數,並且取樣透過該迴圈所需要的平均時間。結果被儲存在成員變數 m_iBiasFactor 中,該成員變數是一個浮點數,它在處理器函式中被使用來決定毫秒如何被“翻譯”成迭代次數。不幸的是,因為的高度戲劇性的天性,要決定實際上執行一個指定長度的計算要迭代多少次給定的迴圈是困難的。但是,我發現該策略在決定基於CPU的操作的計算時間方面,完成了非常可信的工作。
注意 如果您重新編譯生成該測試應用程式,請小心使用最選項。如果您指定了 "Minimize execution time" 最佳化,則編譯程式將檢測具有空的主體的 for 迴圈,並且刪除這些迴圈。
終結器非常簡單:當前時間被取樣並儲存在計算的 THREADBLOCKSTRUCT 中。在測試結束之後,該程式碼計算執行 ExecuteTest 的時間和終結器為每一個計算所呼叫的時間之間的差異。然後,所有計算所消耗的時間由所有已完成的計算中最後一個計算完成時所消耗的時間所決定,而響應時間則是每一個計算的響應時間的平均值,這裡,每一個響應時間,同樣,定義為從測試開始該執行緒消耗的時間除以該執行緒的延遲因子。請注意,終結器在主執行緒上下文中序列化的執行,所以在共享的 iEndIndex 變數上的遞增指令是的。
這些實際就是本測試的全部;其餘的部分則主要是為測試的執行設定一些引數,以及對結果執行一些數學計算。填充結果到 Microsoft Excel 工作單中的相關邏輯將在"Interacting with Microsoft Excel: A Case Study in OLE Automation."一文中討論。
結果
如果您想要在您的計算機上重新建立該測試結果,您需要做以下的事情:
如果您需要改變測試引數,例如最大計算數或協議檔案的位置,請編輯 THRDPERF 示例工程中的 Threadlibtestview.cpp,然後重新編譯生成該應用程式。(請注意,要編譯生成該應用程式,您的計算機需要對長檔名的支援。)
請確保檔案 Thrdlib.dll 在一個 Threadlibtest.exe 能夠連結到它的位置。
如果您想要使用 Microsoft Excel 來檢視測試的結果,請確定 Microsoft Excel 已經正確地被安裝在執行該測試的計算機上。
從 Windows 95 或 Windows NT 執行 Threadlibtest.exe,並且從“Run performance tests”選單選擇"Run entire test set"。正常情況下,測試執行一次要花費幾個小時才能完成。
在測試結束之後,檢查結果時,既可以使用普通文字協議檔案 C:TempResults.fil ,也可以使用工作單檔案 C:TempValues.xls。請注意,Microsoft Excel 的自動化(automation)邏輯並不自動地為您從原始資料生成圖表,我使用了幾個宏來重新排列該結果,並且為您生成了圖表。我憎恨數字(number crunching),但是我必需稱讚 Microsoft Excel,因為即使象我一樣的工作表妄想狂(spreadsheet-paranoid),也可以提供這樣漂亮的使用者介面,在幾分鐘之內把幾列資料裝入有用的圖表。
我所展現的測試結果是在一個 486/33 MHz 的帶有 16 MB RAM 的系統收集而來的。該計算機同時安裝了 Windows NT (3.51 版) 和 Windows 95;這樣,在兩個作業系統上的不同測試結果就是可比的了,因為它們基於同樣的系統。
那麼,現在讓我們來解釋這些值。這裡是總結計算結果的圖表;後面有其解釋。該圖表應該這樣來看:每一個圖表的 x 軸有 6 個值(除了有關長計算的消耗時間表,該表僅含有 5 個值,這是因為在我的測試執行時,對於非常長的計算計時器了)。一個值代表多個計算;我以 2、5、8、11、14 和 17 個計算來執行每一個測試。在 Microsoft Excel 結果工作表中,您將會找到對於基於CPU的計算和基於I/O的計算的執行緒的每一種計算數目的結果,延遲(delay bias)分別是 10 ms、30 ms、90 ms、270 ms,、810 ms 和 2430 ms,但是在該圖表中,我僅包括了 10 ms 和 2430 ms 的結果,這樣所有的數字都被簡化,而且更容易理解。
我需要解釋 "delay bias." 的含義,如果一個測試執行的 delay bias 是 n,則每一個計算都有一個倍數 n 作為其計算時間。例如,如果試驗的是 delay bias 為 10 的 5 個計算,則其中一個計算將執行 50 ms,第二個將執行 40 ms,第三個將執行 30 ms,第四個將執行 20 ms,而第五個將執行 10 ms。並且,當這些計算被序列執行時,假定為最糟的情況,所以具有最長延遲的計算首先被執行,其他的計算按降序排列其後。於是,在“理想”情況下(就是說,計算之間沒有重疊),對於基於CPU的計算來說,全部所需的時間將是 50 ms + 40 ms + 30 ms + 20 ms + 10 ms = 150 ms。
對於消耗時間圖表來說, y 軸的值與毫秒對應,對於響應時間圖表來說,y 軸的值與相對(relative turnaround)長度(即,實際執行所花費的毫秒數除以預期的毫秒數)相對應。
圖 1. 短計算消耗時間比較,在 Windows NT 下
圖 2. 長計算消耗時間比較,在 Windows NT 下
圖 3. 短計算響應時間比較,在 Windows NT 下
圖 4. 長計算響應時間比較,在 Windows NT 下
圖 5. 短計算消耗時間比較,在 Windows 95 下
圖 6. 長計算消耗時間比較,在 Windows 95 下
圖 7. 短計算響應時間比較,在 Windows 95 下
圖 8. 長計算響應時間比較,在 Windows 95 下
基於 I/O 的任務
以消耗時間和 turnaround 時間來衡量,基於 I/O 的執行緒當併發執行時比序列執行要好得多。作為計算得一個功能,對於併發執行來說,消耗時間以線性遞增,而對於序列執行來說,則以指數模式遞增(有關 Windows NT 請參閱圖 1 和 2,有關 Windows 95 請參閱圖 5 和 6)。
請注意,這個結論與我們前面對基於 I/O 的計算的分析是一致的,基於 I/O 的計算是多執行緒的優秀候選人,因為一個執行緒在等待 I/O 請求結束時被掛起,而這段時間該執行緒不會佔用 CPU 時間,於是,這段時間就可以被其他的執行緒所使用。
對於併發計算來說,平均響應時間是一個常數,對於序列計算來說,平均響應時間則線性遞增(請分別參閱圖 3、4、7 和 8)。
請注意無論任何情況,只有少數幾個計算執行的方案中,無論序列或併發的執行,無論測試引數如何設定,並沒有什麼明顯的區別。
基於 CPU 的任務
正如我們前面所提到的,在一個單處理器的計算機中,基於 CPU 的任務的併發執行速度不可能比序列執行速度快,但是我們可以看到,在 Windows NT 下執行緒建立和切換的額外開銷非常小;對於非常短的計算,併發執行僅僅比序列執行慢 10%,而隨著計算長度的增加,這兩個時間就非常接近了。以響應時間來衡量,我們可以發現對於長計算,併發執行相對於序列執行的響應增益可以達到 50%,但是對於短的計算,序列執行實際上比並發執行更加好。
Windows 95 和 Windows NT 之間的比較
如果我們看一看有關長計算的圖表(即,圖2、4、6 和 8),我們可以發現在 Windows 95 和 Windows NT 下其行為是極其類似的。請不要被這樣的事實所迷惑,即好象 Windows 95 處理基於I/O的計算與基於CPU的計算不同於 Windows NT。我把這一結果的原因歸結為這樣一個事實,那就是我用來決定多少個測試迴圈與 1 毫秒相對應的演算法(如前面所述)是非常不精確的;我發現同樣是這個演算法,在完全相同的環境下執行多次時,所產生的結果之間的差異最大時可達20%。所以,比較基於 CPU 的計算和基於 I/O 的計算實際上是不公平的。
Windows 95 和 Windows NT 之間不同的一點是當針對短的計算時。如我們從圖1 和5 所看到的,對於併發的基於I/O的短計算,Windows NT 的效果要好得多。我把這一結果得原因歸結為更加有得執行緒建立方案。請注意,對於長得計算,序列與併發I/O操作之間的差別消失了,所以這裡我們處理的是固定的、相對比較小的額外開銷。
對於短的計算,以響應時間來衡量(如圖 3 和 7),請注意,在 Windows NT 下,在10個執行緒處有一個斷點,在這裡更多的計算併發執行有更好的效果,而對於 Windows 95 ,則是序列計算有更好的容量。
請注意這些比較都是基於當前版本的作業系統得到的(Windows NT 3.51 版和 Windows 95),如果考慮到作業系統的問題,那麼執行緒引擎非常有可能被增強,所以兩個作業系統之間的各自行為的差異有可能消失。但是,有一點很有趣的要注意,短計算一般不必要使用多執行緒,尤其是在 Windows 95 下。
建議
這些結果可以推出下面的建議:決定多執行緒效能的最主要因素是基於 I/O 的計算和基於 CPU 的計算的比例,決定是否採用多執行緒的主要條件是前臺的使用者響應。
讓我們假定在您的應用程式中,有多個子計算可以在不同的執行緒中潛在地被執行。為了決定對這些計算使用多執行緒是否有意義,要考慮下面的幾點。
如果使用者介面響應分析決定某些事情應該在第二執行緒中實現,那麼,決定將要執行的任務是基於I/O的計算還是基於CPU 的計算就很有意義。基於I/O的計算最好被重新定位到後臺執行緒中。(但是,請注意,非同步單執行緒的 I/O 處理可能比多執行緒同步I/O要好,這要看問題而定)非常長的基於CPU的執行緒可能從在不同的執行緒中被執行獲益;但是,除非該執行緒的響應非常重要,否則,在同一個後臺執行緒中執行所有的基於 CPU 的任務可能比在不同的執行緒中執行它更有意義。請記住在任何的情況下,短計算在併發執行時一般會線上程建立時有非常大的額外開銷。
如果對於基於CPU的計算 — 即每一個計算的結果只要得到了就立刻能應用的計算,響應是最關鍵的,那麼,您應該嘗試決定這些計算是否能夠以升序排序,在此種情況下這些計算序列執行的整體效能仍然會比並行執行要好。請注意,有一些計算機的體系結構的設計是為了能夠非常有效率地處理長的計算(例如矩陣操作),那麼,在這樣的計算機上對長的計算實行多執行緒化的話,您可能實際上犧牲了這種結構的優勢。
所有的這些分析都假定該應用程式是執行在一個單處理器的計算機上,並且計算之間是相互獨立的。實際上,如果計算之間相互依賴而需要序列設計,序列執行的效能將不受影響(因為序列是隱式的),而併發執行的版本將總是受到不利的影響。
我還建議您基於計算之間相互依賴的程度決定多執行緒的設計。在大多數情況下子計算執行緒化不用說是好的,但是,如果對於拆分您的應用程式為多個可以在不同執行緒處理的子計算的方法有多種選擇,我推薦您使用同步的複雜性作為一個條件。換句話說,如果拆分成多個執行緒而需要非常少和非常直接的同步,那麼這種方案就比需要使用大量且複雜的執行緒同步的方案要好。
最後一個請注意是,請牢記執行緒是一種系統資源,不是無代價的;所以,there may be a greater penalty to multithreading than performance hits alone. 作為第一規則(rule of thumb),我建議您在使用多執行緒時要保持理智並且謹慎。在多執行緒確實能夠給您的應用帶來好處的時候才使用它們,但是,如果序列計算可以達到同樣的效果,就不要使用它們。
總結
執行附加的效能測試套件產生了一些特殊的結果,這些結果提供了許多有關併發應用程式設計的內部邏輯。請注意我的許多假定是非常基本的;我選擇了比較非常長的計算和非常短的計算,我假定計算之間完全獨立,要麼完全是基於I/O的計算,要麼是完全基於CPU的計算。而絕大多數的現實問題,如果以計算長度和 boundedness 來衡量,都是介於這兩種情況之間的。請把本文中的材料僅僅作為一個起點,它使您更加詳細地分析您的應用程式,以決定是否使用多執行緒。
在本系列的將來的一篇文章中,我將討論透過非同步I/O來增強基於I/O的操作的效能。
多執行緒的(2)
作者:公司供稿 Ruediger R. AscheMicrosoft Developerwork 技術小組
ConcurrentExecution 的內部工作
請注意:本節的討論是非常技術性的,所以假設您理解很多有關 Win32 執行緒 的知識。如果您對如何使用 ConcurrentExecution 類來收集測試資料更加感興趣,而不是對 ConcurrentExecution::DoForAlls 是如何被實現的感興趣,那麼您現在就可以跳到下面的“使用 ConcurrentExecution 來試驗執行緒效能”一節。
讓我們從 DoSerial 開始,因為它很大程度上是一個“不費腦筋的傢伙”:
BOOL ConcurrentExecution::DoSerial(int iNoOfObjects,long *ObjectArray,
CONCURRENT_EXECUTION_ROUTINE pProcessor,
CONCURRENT_FINISHING_ROUTINE pTenator)
{
for (int iL=0;iLoop
{
pTerminator((LPVOID)ObjectArray[iLoop],(LPVOID)pProcessor((LPVOID)ObjectArray[iLoop]));
};
return TRUE;
};
這段程式碼只是迴圈遍歷該陣列,在每一次迭代中,然後在處理器和本身的結果上呼叫終結器。幹得既乾淨又漂亮,不是嗎?
令人感興趣的成員是 DoForAllObjects。乍一看,DoForAllObjects 所要做的也沒有什麼特別的——請求操作建立為每一個計算一個執行緒,並且確保終結器函式能夠被正確地呼叫。但是,有兩個問題使得 DoForAllObjects 比它的表面現象要複雜:第一,當計算的數目多於可用的執行緒數時,ConcurrentExecution 的一個例項所建立的“併發的最大度數”引數可能需要一些附加的記錄(bookkee)。第二,每一個計算的終結器函式都是在呼叫 DoForAllObjects 的執行緒的上下文中被呼叫的,而不是在該計算執行所處的執行緒上下文中被呼叫的;並且,終結器是在處理器結束之後立刻被呼叫的。要處理這些問題還是需要很多技巧的。
讓我們深入到程式碼中,看看究竟是怎麼樣的。該段程式碼是從 Thrdlib.cpp 中繼承來的,但是為了清除起見,已經被精簡了:
int ConcurrentExecution::DoForAllObjects(int iNoOfObjects,long *ObjectArray,
CONCURRENT_EXECUTION_ROUTINE pObjectProcessor,
CONCURRENT_FINISHING_ROUTINE
pObjectTerminated)
{
int iLoop,iEndLoop;
D iThread;
DWORD iArrayIndex;
DWORD dwReturnCode;
DWORD iCurrentArrayLength=0;
BOOL bWeFreedSomething;
char szBuf[70];
m_iCurrentNumberOfThreads=iNoOfObjects;
HANDLE *hPnt=(HANDLE *)VirtualAlloc(NULL,m_iCurrentNumberOfThreads*sizeof(HANDLE)
,MEM_COMMIT,PAGE_READWRITE);
for(iLoop=0;iLoop
hPnt[iLoop] = CreateThread(NULL,0,pObjectProcessor,(LPVOID)ObjectArray[iLoop],
CREATE_SUSPENDED,(LPDWORD)&iThread);
首先,我們為每一個物件建立單獨的執行緒。因為我們使用 CREATE_SUSPENDED 來建立該執行緒,所以還沒有執行緒被啟動。另一種方法是在需要時建立每一個執行緒。我決定不使用這種替代的策略,因為我發現當在一個同時執行了多個執行緒的應用中呼叫時, CreateThread 呼叫是非常浪費的;這樣,同在執行時建立每一個執行緒相比,在此時建立執行緒的開銷將更加容易接受,
for (iLoop = 0; iLoop < m_iCurrentNumberOfThreads; iLoop++)
{
HANDLE hNewThread;
bWeFreedSomething=FALSE;
// 如果陣列為空,分配一個 slot 和 boogie。
if (!iCurrentArrayLength)
{
iArrayIndex = 0;
iCurrentArrayLength=1;
}
else
{
// 首先,檢查我們是否可以重複使用任何的 slot。我們希望在查詢一個新的 slot 之前首先// 做這項工作,這樣我們就可以立刻呼叫該就執行緒的終結器...
iArrayIndex=WaitForMultipleObjects(iCurrentArrayLength,
m_hThreadArray,FALSE,0);
if (iArrayIndex==WAIT_TIMEOUT) // no slot free...
{
{
if (iCurrentArrayLength >= m_iMaxArraySize)
{
iArrayIndex= WaitForMultipleObjects(iCurrentArrayLength,
m_hThreadArray,FALSE,INFINITE);
bWeFreedSomething=TRUE;
}
else // 我們可以釋放某處的一個 slot,現在就這麼做...
{
iCurrentArrayLength++;
iArrayIndex=iCurrentArrayLength-1;
}; // Else iArrayIndex points to a thread that has been nuked
};
}
else bWeFreedSomething = TRUE;
}; // 在這裡,iArrayIndex 包含一個有效的以新的執行緒。
hNewThread = hPnt[iLoop];
ResumeThread(hNewThread);
if (bWeFreedSomething)
{
GetExitCodeThread(m_hThreadArray[iArrayIndex],&dwReturnCode); //錯誤
CloseHandle(m_hThreadArray[iArrayIndex]);
pObjectTerminated((void *)m_hObjectArray[iArrayIndex],(void *)dwReturnCode);
};
m_hThreadArray[iArrayIndex] = hNewThread;
m_hObjectArray[iArrayIndex] = ObjectArray[iLoop];
}; // 迴圈結束
DoForAllObjects 的核心是 hPnt,它是一個物件陣列,這些物件是當 ConcurrentExecution 物件被構造時分配的。該陣列能夠容納最大數目的執行緒,此最大數目與在建構函式中指定的最大併發度數相對應;因此,該陣列中的每一個元素都是一個"slot",並有一個計算居於之中。
關於決定如何填充和釋放的 slots 演算法如下:該物件陣列是從頭到尾遍歷的,並且對於每一個物件,我們都做如下的事情:如果尚未有 slot 已經被填充,我們使用當前的物件來填充該陣列中的第一個 slot,並且繼續將要處理當前物件的執行緒。如果至少有一個 slot 被使用,我們使用 WaitForMultipleObjects 函式來決定是否有正在執行的任何計算已經結束;如果是,我們在該物件上呼叫終結器,並且為新物件“重用”該 slot。請注意,我們也可以首先填充每一個空閒的 slot,直到沒有剩餘的 slots 為止,然後開始填充空的 slot。但是,如果我們這樣做了,那麼空出 slot 的終結器函式將不會被呼叫,直到所有的 slot 都已經被填充,這樣就違反了我們有關當處理器結束一個物件時,終結器立刻被呼叫的要求。
最後,還有沒有空閒 slot 的情況(就是說,當前啟用的執行緒數等於 ConcurrentExecution 物件所允許的最大併發度數)。在這種情況下,WaitForMultipleObjects 將被再次呼叫以使得 DoForAllObjects 處於“睡眠”狀態,直到有一個 slot 空出;只要這種情況一發生,終結器就被在空出 slot 的物件上呼叫,並且工作於當前物件的執行緒被繼續執行。
終於,所有的計算要麼都已經結束,要麼將佔有物件陣列中的 slot。下列的程式碼將會處理所有剩餘的執行緒:
iEndLoop = iCurrentArrayLength;
for (iLoop=iEndLoop;iLoop>0;iLoop--)
{
iArrayIndex=WaitForMultipleObjects(iLoop, m_hThreadArray,FALSE,INFINITE);
if (iArrayIndex==WAIT_FAILED)
{
GetLastError();
_asm int 3; // 這裡要做一些聰明的事...
};
GetExitCodeThread(m_hThreadArray[iArrayIndex],&dwReturnCode); // 錯誤?
if (!CloseHandle(m_hThreadArray[iArrayIndex]))
MessageBox(GetFocus(),"Can't delete thread!","",MB_OK); // 使它更好...
pObjectTerminated((void *)m_hObjectArray[iArrayIndex],
(void *)dwReturnCode);
if (iArrayIndex==iLoop-1) continue; // 這裡很好,沒有需要向後填充
m_hThreadArray[iArrayIndex]=m_hThreadArray[iLoop-1];
m_hObjectArray[iArrayIndex]=m_hObjectArray[iLoop-1];
};
最後,清除:
if (hPnt) VirtualFree(hPnt,m_iCurrentNumberOfThreads*sizeof(HANDLE),
MEM_DECOMMIT);
return iCurrentArrayLength;
};
使用 ConcurrentExecution 來試驗執行緒效能
效能測試的範圍如下:測試應用程式 Threadlibtest.exe 的可以指定是否測試基於 的或基於 I/O 的計算、執行多少個計算、計算的時間有多長、計算是如何排序的(為了測試最糟的情況與隨機延遲),以及計算是被併發執行還是執行。
為了消除意外的結果,每一個測試可以被執行十次,然後將十次的結果拿來平均,以產生一個更加可信的結果。
透過選擇選單選項 "Run entire test set",使用者可以請求執行所有測試變數的變形。在測試中使用的計算長度在基礎值 10 和 3,500 ms 之間變動(我一會兒將討論這一問題),計算的數目在 2 和 20 之間變化。如果在執行該測試的上了 Microsoft ,Threadlibtest.exe 將會把結果轉儲在一個 Microsoft Excel 工作表,該工作表位於 C:TempValues.xls。在任何情況下結果值也將會被儲存到一個純文字檔案中,該檔案位於 C:TempResults.fil。請注意,我對於檔案的位置使用了硬編碼的方式,純粹是懶惰行為;如果您需要在您的計算機上重新生成測試結果,並且需要指定一個不同的位置,那麼只需要重新編譯生成該工程,改變檔案 Threadlibtestview.cpp 的開頭部分的 TEXTFILELOC 和 SHEETFILELOC 識別符號的值即可。
請牢記,執行整個的測試程式將總是以最糟的情況來排序計算(就是說,執行的順序是序列的,最長的計算將被首先執行,其後跟隨著第二長的計算,然後以次類推)。這種方案犧牲了序列執行的靈活性,因為併發執行的響應時間在一個非最糟的方案下也沒有改變,而該序列執行的響應時間是有可能提高的。
正如我前面所提到的,在一個實際的方案中,您應該分析每一個計算的時間是否是可以預測的。
使用 ConcurrentExecution 類來收集效能資料的程式碼位於 Threadlibtestview.cpp 中。示例應用程式本身 (Threadlibtest.exe) 是一個真正的單文件介面 (SDI) 的 MFC 應用程式。所有與示例有關的程式碼都駐留在 view 類的實現 CThreadLibTestView 中,它是從 CEasyOutputView 繼承而來的。(有關對該類的討論,請參考" NT Security in Theory and Practice"。)這裡並不包含該類中所有的有趣程式碼,所包含的大部分是其數字統計部分和使用者介面處理部分。執行測試中的 "meat" 在 CThreadLibTestView::ExecuteTest 中,將執行一個測試執行週期。下面是有關 CThreadLibTestView::ExecuteTest 的簡略程式碼:
void CThreadlibtestView::ExecuteTest()
{
ConcurrentExecution *ce;
bCPUBound=((m_iCompType&CT_IOBOUND)==0); // 全域性...
ce = new ConcurrentExecution(25);
if (!QueryPerformanceCounter(&m_liOldVal)) return; // 獲得當前時間。
if (!m_iCompType&CT_IOBOUND) timeBeginPeriod(1);
if (m_iCompType&CT_CONCURRENT)
m_iThreadsUsed=ce->DoForAllObjects(m_iNumberOfThreads,
(long *)m_iNumbers,
(CONCURRENT_EXECUTION_ROUTINE)pProcessor,
(CONCURRENT_FINISHING_ROUTINE)pTerminator);
else
ce->DoSerial(m_iNumberOfThreads,
(long *)m_iNumbers,
(CONCURRENT_EXECUTION_ROUTINE)pProcessor,
(CONCURRENT_FINISHING_ROUTINE)pTerminator);
if (!m_iCompType&CT_IOBOUND) timeEndPeriod(1);
delete(ce);
< 其他的程式碼在一個陣列中排序結果,以供 Excel 處理...>
}
該段程式碼首先建立一個 ConcurrentExecution 類的物件,然後,取樣當前時間,(用於統計計算所消耗的時間和響應時間),並且,根據所請求的是序列執行還是併發執行,分別呼叫 ConcurrentExecution 物件 DoSerial 或 DoForAllObjects 成員。請注意,對於當前的執行我請求最大併發度數為 25;如果您想要執行有多於 25 個計算的測試程式,那麼您應該提高該值,使它大於或等於執行您的測試程式所需要的最大併發數。
讓我們看一下處理器和終結器,以得到精確測量的結果:
extern "C"
{
long WINAPI pProcessor(long iArg)
{
PTHREALOCKSTRUCT ptArg=(PTHREADBLOCKSTRUCT)iArg;
BOOL bResult=TRUE;
int lay=(ptArg->iDelay);
if (bCPUBound)
{
int iLoopCount;
iLoopCount=(int)(((float)iDelay/1000.0)*ptArg->tbOutputTarget->m_iBiaactor);
QueryPerformanceCounter(&ptArg->liStart);
for (int iCounter=0; iCounter
}
else
{
QueryPerformanceCounter(&ptArg->liStart);
Sleep(ptArg->iDelay);
};
return bResult;
}
long WINAPI pTerminator(long iArg, long iReturnCode)
{
PTHREADBLOCKSTRUCT ptArg=(PTHREADBLOCKSTRUCT)iArg;
QueryPerformanceCounter(&ptArg->liFinish);
ptArg->iEndOrder=iEndIndex++;
return(0);
}
}
處理器模擬一個計算,其長度已經被放到一個與計算有關的資料結構 THREADBLOCKSTRUCT 中。THREADBLOCKSTRUCT 保持著與計算有關的資料,如其延遲和終止時間(以效能計數“滴答”來衡量),以及反向指標,指向實用化該結構的檢視(view)。
透過簡單的使計算“睡眠”指定的時間就可以模擬基於I/O的計算。基於 CPU的計算將進入一個空的 for 迴圈。這裡的一些註釋是為了幫助理解程式碼的功能:計算是基於 CPU 的,並且假定其執行時間為指定的毫秒數。在本測試程式的早期版本中,我僅僅是要 for 迴圈執行足夠多的次數以滿足指定的延遲的需求,而不考慮數字的實際含義。(根據相關的程式碼,對於基於I/O的計算該數字實際意味著毫秒,而對於基於CPU的計算,該數字則意味著迭代次數。)但是,為了能夠使用絕對時間來比較基於CPU的計算和基於I/O的計算,我決定重寫這段程式碼,這樣無論對於基於CPU的計算還是基於I/O的計算,與計算有關的延遲都是以毫秒測量。
我發現對於具有指定的、預先定義時間長度的基於CPU的計算,要編寫程式碼來模擬它並不是一件簡單的事情。原因是這樣的程式碼本身不能查詢系統時間,因為所引發的呼叫遲早都會交出 CPU,而這違背了基於 CPU 的計算的要求。試圖使用非同步多時鐘事件同樣沒有得到滿意的效果,原因是 下計時器服務的工作方式。設定了一個多媒體計時器的執行緒實際上被掛起,直到該計時器回撥被呼叫;因此,基於 CPU 的計算突然變成了基於 I/O 的操作了。
於是,最後我使用了一個有點兒低劣的技巧:CThreadLibTestView::OnCreate 中的程式碼執行 100 次迴圈從 1 到 100,000 計數,並且取樣透過該迴圈所需要的平均時間。結果被儲存在成員變數 m_iBiasFactor 中,該成員變數是一個浮點數,它在處理器函式中被使用來決定毫秒如何被“翻譯”成迭代次數。不幸的是,因為的高度戲劇性的天性,要決定實際上執行一個指定長度的計算要迭代多少次給定的迴圈是困難的。但是,我發現該策略在決定基於CPU的操作的計算時間方面,完成了非常可信的工作。
注意 如果您重新編譯生成該測試應用程式,請小心使用最選項。如果您指定了 "Minimize execution time" 最佳化,則編譯程式將檢測具有空的主體的 for 迴圈,並且刪除這些迴圈。
終結器非常簡單:當前時間被取樣並儲存在計算的 THREADBLOCKSTRUCT 中。在測試結束之後,該程式碼計算執行 ExecuteTest 的時間和終結器為每一個計算所呼叫的時間之間的差異。然後,所有計算所消耗的時間由所有已完成的計算中最後一個計算完成時所消耗的時間所決定,而響應時間則是每一個計算的響應時間的平均值,這裡,每一個響應時間,同樣,定義為從測試開始該執行緒消耗的時間除以該執行緒的延遲因子。請注意,終結器在主執行緒上下文中序列化的執行,所以在共享的 iEndIndex 變數上的遞增指令是的。
這些實際就是本測試的全部;其餘的部分則主要是為測試的執行設定一些引數,以及對結果執行一些數學計算。填充結果到 Microsoft Excel 工作單中的相關邏輯將在"Interacting with Microsoft Excel: A Case Study in OLE Automation."一文中討論。
結果
如果您想要在您的計算機上重新建立該測試結果,您需要做以下的事情:
如果您需要改變測試引數,例如最大計算數或協議檔案的位置,請編輯 THRDPERF 示例工程中的 Threadlibtestview.cpp,然後重新編譯生成該應用程式。(請注意,要編譯生成該應用程式,您的計算機需要對長檔名的支援。)
請確保檔案 Thrdlib.dll 在一個 Threadlibtest.exe 能夠連結到它的位置。
如果您想要使用 Microsoft Excel 來檢視測試的結果,請確定 Microsoft Excel 已經正確地被安裝在執行該測試的計算機上。
從 Windows 95 或 Windows NT 執行 Threadlibtest.exe,並且從“Run performance tests”選單選擇"Run entire test set"。正常情況下,測試執行一次要花費幾個小時才能完成。
在測試結束之後,檢查結果時,既可以使用普通文字協議檔案 C:TempResults.fil ,也可以使用工作單檔案 C:TempValues.xls。請注意,Microsoft Excel 的自動化(automation)邏輯並不自動地為您從原始資料生成圖表,我使用了幾個宏來重新排列該結果,並且為您生成了圖表。我憎恨數字(number crunching),但是我必需稱讚 Microsoft Excel,因為即使象我一樣的工作表妄想狂(spreadsheet-paranoid),也可以提供這樣漂亮的使用者介面,在幾分鐘之內把幾列資料裝入有用的圖表。
我所展現的測試結果是在一個 486/33 MHz 的帶有 16 MB RAM 的系統收集而來的。該計算機同時安裝了 Windows NT (3.51 版) 和 Windows 95;這樣,在兩個作業系統上的不同測試結果就是可比的了,因為它們基於同樣的系統。
那麼,現在讓我們來解釋這些值。這裡是總結計算結果的圖表;後面有其解釋。該圖表應該這樣來看:每一個圖表的 x 軸有 6 個值(除了有關長計算的消耗時間表,該表僅含有 5 個值,這是因為在我的測試執行時,對於非常長的計算計時器了)。一個值代表多個計算;我以 2、5、8、11、14 和 17 個計算來執行每一個測試。在 Microsoft Excel 結果工作表中,您將會找到對於基於CPU的計算和基於I/O的計算的執行緒的每一種計算數目的結果,延遲(delay bias)分別是 10 ms、30 ms、90 ms、270 ms,、810 ms 和 2430 ms,但是在該圖表中,我僅包括了 10 ms 和 2430 ms 的結果,這樣所有的數字都被簡化,而且更容易理解。
我需要解釋 "delay bias." 的含義,如果一個測試執行的 delay bias 是 n,則每一個計算都有一個倍數 n 作為其計算時間。例如,如果試驗的是 delay bias 為 10 的 5 個計算,則其中一個計算將執行 50 ms,第二個將執行 40 ms,第三個將執行 30 ms,第四個將執行 20 ms,而第五個將執行 10 ms。並且,當這些計算被序列執行時,假定為最糟的情況,所以具有最長延遲的計算首先被執行,其他的計算按降序排列其後。於是,在“理想”情況下(就是說,計算之間沒有重疊),對於基於CPU的計算來說,全部所需的時間將是 50 ms + 40 ms + 30 ms + 20 ms + 10 ms = 150 ms。
對於消耗時間圖表來說, y 軸的值與毫秒對應,對於響應時間圖表來說,y 軸的值與相對(relative turnaround)長度(即,實際執行所花費的毫秒數除以預期的毫秒數)相對應。
圖 1. 短計算消耗時間比較,在 Windows NT 下
圖 2. 長計算消耗時間比較,在 Windows NT 下
圖 3. 短計算響應時間比較,在 Windows NT 下
圖 4. 長計算響應時間比較,在 Windows NT 下
圖 5. 短計算消耗時間比較,在 Windows 95 下
圖 6. 長計算消耗時間比較,在 Windows 95 下
圖 7. 短計算響應時間比較,在 Windows 95 下
圖 8. 長計算響應時間比較,在 Windows 95 下
基於 I/O 的任務
以消耗時間和 turnaround 時間來衡量,基於 I/O 的執行緒當併發執行時比序列執行要好得多。作為計算得一個功能,對於併發執行來說,消耗時間以線性遞增,而對於序列執行來說,則以指數模式遞增(有關 Windows NT 請參閱圖 1 和 2,有關 Windows 95 請參閱圖 5 和 6)。
請注意,這個結論與我們前面對基於 I/O 的計算的分析是一致的,基於 I/O 的計算是多執行緒的優秀候選人,因為一個執行緒在等待 I/O 請求結束時被掛起,而這段時間該執行緒不會佔用 CPU 時間,於是,這段時間就可以被其他的執行緒所使用。
對於併發計算來說,平均響應時間是一個常數,對於序列計算來說,平均響應時間則線性遞增(請分別參閱圖 3、4、7 和 8)。
請注意無論任何情況,只有少數幾個計算執行的方案中,無論序列或併發的執行,無論測試引數如何設定,並沒有什麼明顯的區別。
基於 CPU 的任務
正如我們前面所提到的,在一個單處理器的計算機中,基於 CPU 的任務的併發執行速度不可能比序列執行速度快,但是我們可以看到,在 Windows NT 下執行緒建立和切換的額外開銷非常小;對於非常短的計算,併發執行僅僅比序列執行慢 10%,而隨著計算長度的增加,這兩個時間就非常接近了。以響應時間來衡量,我們可以發現對於長計算,併發執行相對於序列執行的響應增益可以達到 50%,但是對於短的計算,序列執行實際上比並發執行更加好。
Windows 95 和 Windows NT 之間的比較
如果我們看一看有關長計算的圖表(即,圖2、4、6 和 8),我們可以發現在 Windows 95 和 Windows NT 下其行為是極其類似的。請不要被這樣的事實所迷惑,即好象 Windows 95 處理基於I/O的計算與基於CPU的計算不同於 Windows NT。我把這一結果的原因歸結為這樣一個事實,那就是我用來決定多少個測試迴圈與 1 毫秒相對應的演算法(如前面所述)是非常不精確的;我發現同樣是這個演算法,在完全相同的環境下執行多次時,所產生的結果之間的差異最大時可達20%。所以,比較基於 CPU 的計算和基於 I/O 的計算實際上是不公平的。
Windows 95 和 Windows NT 之間不同的一點是當針對短的計算時。如我們從圖1 和5 所看到的,對於併發的基於I/O的短計算,Windows NT 的效果要好得多。我把這一結果得原因歸結為更加有得執行緒建立方案。請注意,對於長得計算,序列與併發I/O操作之間的差別消失了,所以這裡我們處理的是固定的、相對比較小的額外開銷。
對於短的計算,以響應時間來衡量(如圖 3 和 7),請注意,在 Windows NT 下,在10個執行緒處有一個斷點,在這裡更多的計算併發執行有更好的效果,而對於 Windows 95 ,則是序列計算有更好的容量。
請注意這些比較都是基於當前版本的作業系統得到的(Windows NT 3.51 版和 Windows 95),如果考慮到作業系統的問題,那麼執行緒引擎非常有可能被增強,所以兩個作業系統之間的各自行為的差異有可能消失。但是,有一點很有趣的要注意,短計算一般不必要使用多執行緒,尤其是在 Windows 95 下。
建議
這些結果可以推出下面的建議:決定多執行緒效能的最主要因素是基於 I/O 的計算和基於 CPU 的計算的比例,決定是否採用多執行緒的主要條件是前臺的使用者響應。
讓我們假定在您的應用程式中,有多個子計算可以在不同的執行緒中潛在地被執行。為了決定對這些計算使用多執行緒是否有意義,要考慮下面的幾點。
如果使用者介面響應分析決定某些事情應該在第二執行緒中實現,那麼,決定將要執行的任務是基於I/O的計算還是基於CPU 的計算就很有意義。基於I/O的計算最好被重新定位到後臺執行緒中。(但是,請注意,非同步單執行緒的 I/O 處理可能比多執行緒同步I/O要好,這要看問題而定)非常長的基於CPU的執行緒可能從在不同的執行緒中被執行獲益;但是,除非該執行緒的響應非常重要,否則,在同一個後臺執行緒中執行所有的基於 CPU 的任務可能比在不同的執行緒中執行它更有意義。請記住在任何的情況下,短計算在併發執行時一般會線上程建立時有非常大的額外開銷。
如果對於基於CPU的計算 — 即每一個計算的結果只要得到了就立刻能應用的計算,響應是最關鍵的,那麼,您應該嘗試決定這些計算是否能夠以升序排序,在此種情況下這些計算序列執行的整體效能仍然會比並行執行要好。請注意,有一些計算機的體系結構的設計是為了能夠非常有效率地處理長的計算(例如矩陣操作),那麼,在這樣的計算機上對長的計算實行多執行緒化的話,您可能實際上犧牲了這種結構的優勢。
所有的這些分析都假定該應用程式是執行在一個單處理器的計算機上,並且計算之間是相互獨立的。實際上,如果計算之間相互依賴而需要序列設計,序列執行的效能將不受影響(因為序列是隱式的),而併發執行的版本將總是受到不利的影響。
我還建議您基於計算之間相互依賴的程度決定多執行緒的設計。在大多數情況下子計算執行緒化不用說是好的,但是,如果對於拆分您的應用程式為多個可以在不同執行緒處理的子計算的方法有多種選擇,我推薦您使用同步的複雜性作為一個條件。換句話說,如果拆分成多個執行緒而需要非常少和非常直接的同步,那麼這種方案就比需要使用大量且複雜的執行緒同步的方案要好。
最後一個請注意是,請牢記執行緒是一種系統資源,不是無代價的;所以,there may be a greater penalty to multithreading than performance hits alone. 作為第一規則(rule of thumb),我建議您在使用多執行緒時要保持理智並且謹慎。在多執行緒確實能夠給您的應用帶來好處的時候才使用它們,但是,如果序列計算可以達到同樣的效果,就不要使用它們。
總結
執行附加的效能測試套件產生了一些特殊的結果,這些結果提供了許多有關併發應用程式設計的內部邏輯。請注意我的許多假定是非常基本的;我選擇了比較非常長的計算和非常短的計算,我假定計算之間完全獨立,要麼完全是基於I/O的計算,要麼是完全基於CPU的計算。而絕大多數的現實問題,如果以計算長度和 boundedness 來衡量,都是介於這兩種情況之間的。請把本文中的材料僅僅作為一個起點,它使您更加詳細地分析您的應用程式,以決定是否使用多執行緒。
在本系列的將來的一篇文章中,我將討論透過非同步I/O來增強基於I/O的操作的效能。
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/10752043/viewspace-987694/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- 深入淺出Win32多執行緒程式設計--之MFC的多執行緒Win32執行緒程式設計
- Java多執行緒(2)執行緒鎖Java執行緒
- java 多執行緒-2Java執行緒
- Java多執行緒學習(2)執行緒控制Java執行緒
- 多執行緒(2)-執行緒同步互斥鎖Mutex執行緒Mutex
- Win32執行緒——等待另一個執行緒結束Win32執行緒
- 【java】【多執行緒】建立執行緒的兩種常用方式(2)Java執行緒
- 多執行緒引起的效能問題分析執行緒
- 多執行緒(2)-執行緒同步條件變數執行緒變數
- 多執行緒和多執行緒同步執行緒
- 玩轉java多執行緒 之多執行緒基礎 執行緒狀態 及執行緒停止實戰Java執行緒
- 執行緒以及多執行緒,多程式的選擇執行緒
- 多執行緒--執行緒管理執行緒
- 執行緒與多執行緒執行緒
- 多執行緒【執行緒池】執行緒
- 多執行緒(五)---執行緒的Yield方法執行緒
- 【Java多執行緒】執行緒安全的集合Java執行緒
- Java多執行緒-執行緒池的使用Java執行緒
- 使用多執行緒提高rest服務效能執行緒REST
- 最全java多執行緒總結2--如何進行執行緒同步Java執行緒
- 多執行緒下的網格生成及效能分析執行緒
- Faiss使用多執行緒出現的效能問題AI執行緒
- Java多執行緒-執行緒中止Java執行緒
- 多執行緒之初識執行緒執行緒
- 深入淺出Win32多執行緒程式設計--之基本概念Win32執行緒程式設計
- 多執行緒------執行緒與程式/執行緒排程/建立執行緒執行緒
- 多執行緒系列(1),多執行緒基礎執行緒
- java多執行緒之執行緒的基本使用Java執行緒
- 【Java】【多執行緒】執行緒的生命週期Java執行緒
- a、多執行緒執行緒
- 多執行緒的概述執行緒
- 多執行緒系列之 執行緒安全執行緒
- iOS 多執行緒之執行緒安全iOS執行緒
- Java多執行緒之執行緒中止Java執行緒
- Android多執行緒之執行緒池Android執行緒
- Java多執行緒-執行緒狀態Java執行緒
- Java多執行緒-執行緒通訊Java執行緒
- kuangshenshuo-多執行緒-執行緒池執行緒
- java 多執行緒守護執行緒Java執行緒