性能優化 以及 控件和組件概念淺析

 

原代碼下載:CQA0406.exe (234KB)
原文出處:MSDN Magazine Jun 2004 (C++ Q&A)

  1. 性能優化
  2. 控件和組件概念淺析

分配大量小型類對象(如:10,000小型記錄)最快和最佳方法是什麼? 當然,MFC 序列流化對象可以完成所需的任務。但是,內存的分配和銷燬相當耗時。有沒有辦法對此進行改進?

Dave Kerrison
我無法告訴你最好的方法,因爲那取決與應用程序的具體情況和其使用方式。性能和內存分配是如此巨大的一個主題,有關它們已經有很多很多書籍。沒有哪一種方案適合所有的情形。最優化總是需要在速度和其它資源之間進行明智的權衡。例如,如果你願意建立巨型索引,那麼就會獲得非常快的查詢速度。或者要想顯示速度快,那麼就得以加載時間作爲代價。因此,本文我只能就某些需要考慮的問題給你提供一個概述,以及提供一些工具和途徑以幫助你自己找到答案。
  如果你覺得程序的性能不太滿意,首先必須確定瓶頸在哪,對此要有清醒的認識。你可以藉助複雜的工具(profiler)來產生各種有關性能的報告,但如果只是想知道你的代碼在哪裏耗時,那麼用一些自己編寫的簡單工具即可,我寫了一個類叫 ShowTime,它可以報告代碼的某些部分執行時要花費多長時間。爲了使用它,你只需在要用時鐘的代碼塊起始處實例化一個 ShowTime 堆棧對象即可:
void CalculatePi()
{ 
   ShowTime st("Calculating pi");
   // do it
}
這段代碼將產生一個象下面這樣的 TRACE 信息:
Calculating pi: 342 msec
  ShowTime 是如何工作的呢?它爲智能指針以及在代碼塊起始處和末尾處你想做某些自動處理的地方使用常見的 C++ 構造函數/析構函數(ctor/dtor)模式。ShowTime 的構造函數將時鐘時間(自從進程啓動後的時鐘嘀嗒數)保存在某個數據成員中;析構函數則用從最後的時鐘數中減去這個時鐘數併產生一條信息。由於構造函數/析構函數是在代碼塊的起始處/末尾處調用的,這樣便測算出總共用了多少時間。代碼如 Figure 1 所示。
  ShowTime 並不太複雜。比如,它並不考慮多線程的情況,並且也不報告在每個函數中某個工具消耗了多少時間。但是對於日常使用來說,它能給你提供應用程序在何處耗時的很好的參考。不要忘記針對 Release 版本進行性能測試!畢竟那是你交付使用的版本。此外,Release 和 Debug 版本之間的差別可能會曲解你的結果。例如,依賴你的設置方式,debug 版本也許要進行額外的堆棧檢查,這樣便使應用程序性能下降。由於在 Release 版本中沒有 TRACE 信息,所以我添加了另外一個類,PerfLog,它可以將性能統計定向到一個文本文件:
// open log file
PerfLog mylog("MyResults.log");

  現在 ShowTime 可以將信息寫入MyResults.log文件以及TRACE流。但是,不要忘了在交付程序之前去掉這個性能監視。
  有了 ShowTime 在手,我可以開始回答你的問題了。我寫了一個小程序,PerfTest,這是一個典型的具備文檔的 MFC 文檔/視圖應用,它使用三種不同的方法分配具有 20,000 條定長記錄的鏈表。
  方法一是典型的 MFC 方式。鏈表的實現使用 MFC 的 CObList,鏈表中的每個項目使用單獨的表單元。每個表單元只是小小結構,此結構保存指向上下單元的指針和對象本身。所以 CObList 的每一項由12個字節的開銷,但是,如果你需要幾個表指向相同項目的話,就必須要有幾個表單元。(例如,你想用不同的方法排序對象)。
  方法二表示了第一個性能上的改進。這裏記錄本身存儲下一條記錄的指針,所以沒有單獨的表單元。這個方法在僅有一個鏈表的情況下才成爲可能——也就是說,如果你不需要用幾個鏈表來指向以不同方式排序的相同對象。在這樣情況下,使用數組可能更有效。但即便是一個鏈表,如果要經常修改順序,鏈表也比數組要快,因爲修改指針比在內存中移動對象要快。
  方法一和方法二都是每分配一個記錄/對象單獨調用一次 new 操作符。如果你分配20,000個對象,便調用20,000次 new 操作。方法三用單個數組一次性分配所有的 20,000 個對象:

m_array = new CMyRecord[20000];      
  記錄的鏈接則是通過設置每條記錄中指向下一條記錄的指針域實現的。分配的速度快,因爲只有一次函數調用,但它需要一塊連續的足以容納 20,000 條記錄的內存塊。當然,編譯器仍然要保證對象的初始化。當你用向量形式的 new 操作,編譯器產生代碼來調用每個對象的構造函數,因此有20,000次的構造函數調用。同樣,在 delete [] 操作中會有 20,000 次的析構函數調用。如果構造函數/析構函數都爲空,這些調用將被優化掉。但如果它們有實際的事可做,這個代碼將需要有限次地執行。這時,你可能要進一步通過給該數組分配原始字節來加速性能(避免構造函數/析構函數調用),然後用手工編寫代碼來初始化這些對象——但現在這個對你已經不成問題。
  還有一種辦法是整個類的分配性能改進策略——我只將它提出來,不作進一步的探討——這個方法重載new操作符,如下所示:
class CMyRecord {
public:
  void* operator new (size_t nbytes) {
    return FancyAlloc(nbytes);
  }
  void operator delete (void* p) {
    FancyFree(p);
  }
};	
  你可以按照自己的意願實現 FancyAlloc 和 FancyFree,只要它們按照正確的大小分配/釋放內存塊。如果你有一個在程序中全程使用的特殊對象,最常用的技巧之一是維護一個釋放(free pool)對象池。而不是去調用 free,你的 delete 操作符將釋放的對象添加到一個叫釋放池的鏈表中。然後分配器調用malloc之前在此釋放池中查找對象。這樣做可以使分配/釋放操作極其快速,但你必須小心行事,使用內建的分配器而不能越雷池一步,大多數情況下它表現得相當不錯。
  不管怎樣,就像我開始所說的那樣,有關內存分配的詳細內容已經超出本專欄討論的範圍。你可以使用的技巧數不勝數。我之所以選擇這三種方法,並不是因爲它們如何棒,而是舉例說明你可以用來優化程序的幾種方式。
  爲了搞清楚哪種方法最快,關鍵的問題是:你要什麼東西快?如果你關注的是內存分配,那麼你覺得方法一(CObList)最慢,因爲它分配的對象最多,而方法三(成塊的分配)最快,因爲它一次性地分配。但是對於讀寫操作又如何呢?每一種方法都暗示了不同的序列化策略。對於方法一和方法二來說,每條記錄單獨調用 CMyRecord::Serialize 來序列化(參見 Figure 2),對於方法三而言,我
猛的一下寫入整個數組。還要說明一點,這是個很武斷的做法,我只用它了做教育目的。就像方法二那樣,我可以輕鬆地像序列化單條記錄那樣序列化巨型數組。注意在實現非正統的序列化策略時,做起來會稍微複雜一些。
  任何時候序列化包含指針的數據時,必須要將指針轉換成在磁盤上有意義的東西,因爲數據被實際加載到的位置與下次讀取該文件的內存位置正好完全相同的機率是非常低的。就像你不買彩票而想中獎的機率一樣。MFC 實現了許多神奇的操作對指針和磁盤IDs進行來回轉換。對於 PerfTest,我以鏈表順序保存記錄,因此不需要IDs。我可以簡單地在它被加載後進行重鏈(方法二和方法三)。當然,這意味着如果你改變鏈表順序,則方法三將失敗(我已經隱含假設該鏈表就是一個數組)。
  最後,另一個序列化問題是:你打算序列化的記錄是定長的還是可變長度的?CMyRecord 含有 64個字符的數組。CMyRecord::Serialize 使用 CArchive::ReadString/WriteString 來序列化用到的字符,而不是所有的 64 個字符。如果字符串是“foo”,則它只序列化4個字符(“foo”加結尾必須的“/n”)。方法三寫入整個數組。它序列化所有字符,即使字符串爲空。這樣是不是很浪費呢?要看情況。如果字符串是10個字符的電話號碼或16位字符的信用卡號碼,大多數記錄可能都會補足字符,因此序列化所有內容也許沒什麼問題。但如果字符串是一個地址或可選的字段,那麼磁盤上可能會有成兆字節的零。那就好考慮了。這不僅僅是磁盤空間的浪費,而且速度也會受影響,因爲它要花更多的時間來讀/寫更多的字節。方法三一次性讀/寫整個表——那它真的就更快嗎?
  爲了找到答案,我在 PerfTest 中使用 ShowTime 對象來顯示不同的操作要花多長時間。我運行這個程序,創建一個新文件,保存它又讀取它,然後退出。Figure 3 顯示了 ShowTime 產生的日誌,
其中有註解解釋其操作。像期望的那樣,方法一(CObList)分配是最慢的(130ms),方法三最快(70ms)。銷燬釋放對象差別更爲明顯。那麼序列化又如何?對於寫入操作,方法二和方法三差別不大——分別爲60和61ms。顯然一次性寫操作爭取的時間是以寫入太多數據爲代價的——方法二是 536KB,方法三是龐大的 2.9MB。(我寫了另一個類,ShowFileUsed,這個類報告序列化期間存檔 CFile 開始和結束位置之間的差別)。對於讀取操作,方法三比較快,但同時也有一個磁盤緩衝副作用——這是當你着手性能測試時,另一個必須考慮的因素。
  ShowTime 提供原始的性能數據,但你必須對它們進行解釋,以便通俗易懂。使用 CObList (方法一)分配所用的時間幾乎是方法三大塊數組分配所用時間的兩倍——但不知是否有人注意到沒有,它用 70ms 的時間來打開一個文件?從字面上講是一眨眼的功夫。那大塊讀取所節省的時間確實值得以五倍的磁盤空間爲代價嗎?對於 PerfTest 來說,答案肯定是,No。對於其它的一些應用,答案可能是Yes。底線是必須經過試驗才能確定。你總是可以使自己的程序更快,但通常只能以其它資源爲代價,像內存、磁盤空間、複雜性(解釋爲可靠性、健壯性和程序員小時數)或其它方面的速度。性能優化是一種藝術。技巧是充分理解你的應用程序,併購買或編寫一些工具,這些工具是你能瞭解應用程序到底在幹什麼。你也許會對所發現的事情感到驚訝。

我正在學習 Microsoft .NET 框架,不太理解控件和組件之間的差別。我知道這些術語可以互用,但什麼時候從 Control 派生,什麼時候從 Component 派生呢?
Linda Berno  
好問題!簡單說來,控件就是具有用戶界面的組件。要說的具體一點,就得回顧早期 Windows 的歷史根源,當時控件指任何子窗口——按鈕、列表框、編輯框或者某個對話框中的靜態文本。從概念上講,這些窗口——控件——類似用來操作收音機或小電器的旋鈕和按鈕。隨着控件數量的增加(組合框、日期時間控件等等),控件逐漸成爲子窗口的代名詞,無論是用在對話框中還是用在其它種類的主窗口中。沒過多久 BASIC 程序員開始編寫他們自己專用的控件,自然而然地人們便想到共享這些控件。共享代碼的方法之一是通過磁盤拷貝,但那樣顯然效率低下。必須要有一種機制使開發者建立的控件能夠在其它程序員的應用中輕而易舉地插入,這便是VBA控件,OLE控件,OCX和最後ActiveX 控件的動機。
  這就是控件和組件之間產生混淆之所在。因爲爲了解決控件的可複用問題,所有這些技術必須首先解決更爲一般的組件重用問題。(COM,如果你還記得它的話,意思是組件對象模型)。在軟件行話中,組件這個術語指任何可複用的對象或任何可與其它對象交互的代碼體。子程序的發明,曾經一度成爲程序員趨之若鶩的軟件工程聖盃:一種統一的編程理論,它使程序員從基本構建塊——也就是用所選語言編寫的各種組件建立大型系統。從子程序演變到OOP,到DLLs,再到COM,再到.NET框架的每一種新的編程範例都代表了一種不同的提供可重用性的方案。VBX使用DLLs的固化名稱。COM使用接口和IUnknown。.NET框架使用微軟的中間語言(MSIL)層和公共語言運行時(CLR)來提供統一的粘合。

因此,控件是組件的一個主要樣本(並且歷史上曾驅動着組件的開發),控件又不僅僅是唯一的一種組件。組件不需要顯示任何信息或用戶界面。組件可能實現科學計算,收集性能數據,計算1971年1月1日到現在的毫秒數,仰或是讀取布什總統競選活動保險箱裏的美金數。Figure 4 顯示了 Visual Studio .NET 中的非控件組件例子。


Figure 4 組件

在 .NET 框架中,術語控件和組件爲 .NET 賦予了專門的意義。Component 類爲被用於設計層面的對象如 Windows Forms Designer (Windows 窗體設計器)或 Web Forms Designer (Web 窗體設計器)提供了基本實現。某個 Component 是任何可以被拽到某個窗體的任何東西。Component 類實現IComponent,ISite 和 IContainer。這些接口比起其來自 OLE 時期的 COM 堂兄弟要簡單得多。 IContainer 比起帶有 Add/Remove 方法的組件列表以及組件屬性來要稍微複雜一點,它獲得的組件是一個 ComponentCollection (組件集合)。
IComponent 從 IDisposable 派生而來,並且只有一個屬性,Site,獲取組件的ISite接口。Component 可能有,也可能沒有Site。ISite 有四個屬性,其中包括Name和DesignMode,它控制該組件是否處於——還能是什麼?——設計模式。ISite 派生於另一個接口,IServiceProvider,它只有一個方法,GetService。在COM中,IServiceProvider 類似 QueryInterface——用它可以通過ID來查詢某個對象的接口,但是與 QueryInterface 不同的是該對象本身不用去實現這個接口,它僅僅知道在哪裏和如何獲取它即可。同樣,在.NET框架中,IServiceProvider 是一種獲取其它接口或對象的通用方法——服務——對象不用實現它就知道的一種服務。
  .NET框架使得編寫可複用組件輕鬆自如,不再需要 IDL,不再需要類型定義語言,不再需要費力的設計時支持。通過反射(reflection)的魔法,CLR 從代碼本身就已經知道了該知道的一切,所有的類都在掌控之中。爲了添加設計時支持,你只要用額外的設計器標記你的屬性即可。例如,在託管C++中:
// in CMyControl
[Category(S"Appearance")]
[Description(S"Specifies widget foreground color.")]
_property Color get_ForeColor() { ... } 
_property void set_ForeColor(Color value) { ... }

現在窗體設計器在“外觀”(Appearance)中列出你的 ForeColor 屬性並使用幫助描述(Description)。有關設計時屬性的更多內容,請參考.NET框架文檔中的“組件的設計時屬性”


Figure 5 類層次結構

Figure 5 顯示了.NET框架中的類層次結構,它能說明上述討論的問題。正如你所看到的,Control 從
Component 派生而來。這是用另外一種方式來說明控件即組件(反之則不然)。更具體地講,控件是一個用用戶界面的組件——能繪製東西並能與用戶交互。Control 類還是所有託管窗口類的基類——窗體、按鈕、柵格、面板、工具欄等等。Control 類是定義 WndProc 和 ClientSize 以及所有標準窗口事件如 GotFocus 和 Click 的地方。Web控件(System.Web.UI.Control)也是組件,不過從嚴格的意義上講,它不是從 System.ComponentModel.Component 派生的。(對於 Web 控件,其名字空間爲 System.Web.UI,Control 本身實現 IComponent。)
  除了實現 IComponent 之外,System.ComponentModel.Component 還提供了所有組件需要的列集支持,但它是通過從 MarshalByRefObject 派生來實現的。如果想生成一個值列集組件,可以從 MarshalByValueComponent 派生(它實現了 IComponent,IDisposable 和 IServiceProvider)。System.Data.DataColumn,DataSet 和 DataTable 都是是值列集組件的例子。這些對象跨機器/進程邊界傳遞其實際數據。
  如果你正在編寫其他人也能用窗體設計器拖拽到其窗體的可重用的小組件,那麼你必須從 Component 派生。如果你的小組件還具備用戶界面——能創建窗口,繪畫或與用戶交互——那麼就應該從 Control 派生。明白了嗎?

向 Paul 提問和評論請發到 [email protected].

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章