C++應用程序性能優化(書)內存管理


本質上虛擬內存就是要讓一個程序的代碼和數據在沒有全部載入內存時即可運行。運行過程中,當執行到尚未載入內存的代碼,或者要訪問還沒有載入到內存的數據時,虛擬內存管理器動態地將這部分代碼或數據從硬盤載入到內存中。而且在通常情況下,虛擬內存管理器也會相應地先將內存中某些代碼或者數據置換到硬盤中,為即將載入的代碼或數據騰出空間。

因為內存和硬盤之間的數據傳輸相對代碼執行來說,是非常慢的操作,因此虛擬內存管理器在保證工作正確的前提下,還必須考慮效率因素。比如,它需要優化置換算法,盡量避免就要執行的代碼或訪問的數據剛被置換出內存,而很久沒有訪問的代碼或數據卻一直駐留在內存中。另外它還需要將駐留在內存的各個進程的代碼或數據維持在一個合理的數量上,並且根據該進程的性能表現動態調整此數量,等等,使得程序運行時將其涉及的磁盤I/O次數降到盡可能低,以提高程序的運行性能。

本章前一部分着重介紹Windows的虛擬內存管理機制,后一部分則簡要介紹Linux的虛擬內存管理機制。

4.1  Windows內存管理

如果從應用程序的角度來看Windows虛擬內存管理系統,可以扼要地歸結為一句話。即Win32虛擬內存管理器為每一個Win32進程提供了進程私有且基於頁的4 GB(32位)大小的線性虛擬地址空間,這句話可以分解如下:

(1)“進程私有”意味着每個進程都只能訪問屬於自己的地址空間,而無法訪問其他進程的地址空間,也不用擔心自己的地址空間會被其他進程看到(父子進程例外,比如調試器利用父子進程關系來訪問被調試進程的地址空間,這里不詳述)。需要注意的是,進程運行時用到的dll並沒有屬於自己的虛擬地址空間。而是其所屬進程的虛擬地址空間,dll的全局數據,以及通過dll函數申請的內存都是從調用其進程的虛擬地址空間中開辟。

(2)“基於頁”是指虛擬地址空間被划分為多個稱為“頁”的單元,頁的大小由底層處理器決定,x86中頁的大小為4 KB。頁是Win32虛擬內存管理器處理的最小單元,相應的物理內存也被划分為多個頁。虛擬內存地址空間的申請和釋放,以及內存和磁盤的數據傳輸或置換都是以頁為最小單位進行的。

(3)“4 GB大小”意味着進程中的地址取值范圍可以從0x00000000到0xFFFFFFFF。Win32將低區的2 GB留給進程使用,高區的2 GB則留給系統使用。

Win32中用來輔助實現虛擬內存的硬盤文件稱為“調頁文件”,可以有16個,調頁文件用來存放被虛擬內存管理器置換出內存的數據。當這些數據再次被進程訪問時,虛擬內存管理器會先將它們從調頁文件中置換進內存,這樣進程可以正確訪問這些數據。用戶可以自己配置調頁文件。出於空間利用效率和性能的考慮,程序代碼(包括exe和dll文件)不會被修改,所以當它們所在的頁被置換出內存時,並不會被寫進調頁文件中,而是直接拋棄。當再次被需要時,虛擬內存管理器直接從存放它們的exe或dll文件中找到它們並調入內存。另外對exe和dll文件中包含的只讀數據的處理與此類似,也不會為它們在調頁文件中開辟空間。

當進程執行某段代碼或者訪問某些數據,而這些代碼或者數據還沒有在內存時,這種情形稱為“缺頁錯誤”。缺頁錯誤的原因有很多種,最常見的一種就是已經提到的,即這些代碼和數據被虛擬內存管理器置換出了內存,這時虛擬內存管理器在這段代碼執行或者這些數據被訪問前將它們調入內存。這個操作對開發人員來說是透明的,因此大大簡化了開發人員的負擔。但是調頁錯誤涉及磁盤I/O,大量的調頁錯誤會大大降低程序的總體性能。因此需要了解缺頁錯誤的主要原因,以及規避它們的方法。

4.1.1  使用虛擬內存

Win32中分配內存分為兩個步驟:“預留”和“提交”。因此在進程虛擬地址空間中的頁有3種狀態:自由(free)、預留(reserved)和提交(committed)。

(1)自由表示此頁尚未被分配,可以用來滿足新的內存分配請求。

(2)預留指從虛擬地址空間中划出一塊區域(region,頁的整數倍數大小),划出之后這個區域中的頁不能用來滿足新的內存分配請求,而是用來供要求“預留”此段區域的代碼以后使用。預留時並沒有分配物理存儲,只是增加了一個描述進程虛擬地址空間使用狀態的數據結構(VAD,虛擬地址描述符),用來記錄這段區域已被預留。“預留”操作相對較快,因為沒有真正分配物理存儲。也正因為沒有分配真正的物理存儲,所以預留的空間並不能夠直接訪問,對預留頁的訪問會引起“內存訪問違例”(內存訪問違例會導致整個進程立刻退出,而不僅僅是中止引起該違例的線程)。

(3)提交,若想得到真正的物理存儲,必須對預留的內存進行提交。提交會從調頁文件中開辟空間,並修改VAD中的相應項。注意,提交時也並沒有立刻從物理內存中分配空間,而只是從磁盤的調頁文件中開辟空間。這個空間用做以后置換的備份空間,直到有代碼第一次訪問這段提交內存中的某些數據時,系統發現並沒有真正的物理內存,拋出缺頁錯誤。虛擬內存管理器處理此缺頁錯誤,直到這時才會真正分配物理內存,提交也可以在預留的同時一起進行。需要注意的是,提交操作會從調頁文件中開辟磁盤空間,所以比預留操作的時間長。

這也是Win32虛擬內存管理中的demand-paging策略的一個體現,即不到真正訪問時,不會為某虛擬地址分配真正的物理內存。這種策略一是出於性能考慮,將工作分段完成,提高總體性能;二是出於空間效率考慮,不到真正訪問時,Win32總是假定進程不會訪問大多數的數據,因而也不必為它們開辟存儲空間或將其置換進物理內存,這樣可以提高存儲空間(磁盤和物理內存)的使用效率。

設想某些程序對內存有很大的需求,但又不是立即需要所有這些內存,那么一次就從物理存儲中開辟空間滿足這些還只是“潛在”的需求,從執行性能和存儲空間效率來說,都是一種浪費。因為只是“潛在”需求,極有可能這些分配的內存中很大一部分最后都沒有真正被用到。如果在申請的時候就一次性為它們分配全部物理存儲,無疑會極大地降低空間的利用效率。

另一方面,如果完全不用預留及提交機制,只是隨需分配內存來滿足每次的請求,那么對一個會在不同時間點頻繁請求內存的代碼來說,因為在它請求內存的不同時間點的間隙極有可能會有其他代碼請求內存。這樣這段在不同時間點頻繁請求內存的代碼請求得到的內存因為虛擬地址不連續,無法很好地利用空間locality特性,對其整體進行訪問(比如遍歷操作)時就會增加缺頁錯誤的數量,從而降低程序的性能。

預留和提交在Win32中都使用VirtualAlloc函數完成,預留傳入MEM_RESERVE參數,提交傳入MEM_COMMIT參數。釋放虛擬內存使用VirtualFree函數,此函數根據不同的傳入參數,與VirtualAlloc相對應,可以釋放與虛擬地址區域相對應的物理存儲,但該虛擬地址區域還可處於預留狀態,也可以連同虛擬地址區域一起釋放,該段區域恢復為自由狀態。

線程棧和進程堆的實現都利用了這種預留和提交兩步機制,下面僅以線程棧為例來說明Win32系統是如何使用這種預留和提交兩步機制的。

創建線程棧時,只是一個預留的虛擬地址區域,默認是1 MB(此大小可在CreateThread或在鏈接時通過鏈接選項修改),初始時只有前兩頁是提交的。當線程棧因為函數的嵌套調用需要更多的提交頁時,虛擬內存管理器會動態地提交該虛擬地址區域中的后續頁以滿足其需求,直到到達1 MB的上限。當到達此預留區域大小的上限(默認1 MB)時,虛擬內存管理器不會增加預留區域大小,而是在提交最后一頁時拋出一個棧溢出異常,拋出棧溢出異常時該棧還有一頁空間可用,程序仍可正常運行。而當程序繼續使用棧空間,用完最后一頁后,還繼續需要存儲空間,這時就超過了上限,會直接導致進程退出

所以為防止線程棧溢出導致整個程序退出,應該注意盡量控制棧的使用大小。比如減少函數的嵌套層數,減少遞歸函數的使用,盡量不要在函數中使用太大的局部變量(大的對象可以從堆中開辟空間存放,因為堆會動態擴大,而線程棧的可用內存區域在線程創建時就已固定,之后在整個線程生命期間無法擴展)。

另外為了防止因為一個線程棧的溢出導致整個進程退出,可以對可能會產生線程棧溢出的線程體函數加異常處理,捕獲在提交最后一頁時拋出的溢出異常,並做出相應處理。

4.1.2  訪問虛擬內存時的處理流程

對某虛擬內存區域進行了預留並提交之后,就可以對該區域中的數據進行訪問了,下圖描述了當程序對某段內存訪問時的處理流程:

如圖4-1所示,當該數據已在物理內存中時,虛擬內存管理器只需將指向該數據的虛擬地址映射為物理指針,即可訪問到物理內存中的真正數據。這一步不會涉及磁盤I/O,速度相對較快。

當第一次訪問一段剛剛提交的內存中的數據時,因為並沒有真正的物理內存分配給它。或者該數據以前已被訪問過,但是被虛擬內存管理器置換出了內存。這兩種情形都會引發缺頁錯誤,虛擬內存管理器此時會處理這一缺頁錯誤,它先檢測此數據是否在調頁文件中已有備份空間(exe和dll的代碼頁和只讀數據頁情形與此類似,但是其備份空間不在調頁文件,而是包含它們的exe或dll文件)。如果是這兩種情況,表明訪問的數據在磁盤中有備份,接下來虛擬內存管理器就需要在物理內存中找到合適的頁,並將存放在磁盤的備份數據置換進物理內存。

圖4-1  訪問虛擬內存的處理流程

虛擬內存管理器首先查詢當前物理內存中是否有空閑頁,虛擬內存管理器維護一個稱為“頁幀數據庫”(page-frame database)的數據結構,此數據結構是操作系統全局的,當Windows啟動時被初始化,用來跟蹤和記錄物理內存中每一個頁的狀態,它會用一個鏈表將所有空閑頁連接起來,當需要空閑頁時,直接查找此空閑頁鏈表,如果有,直接使用某個空閑頁;否則根據調頁算法首先選出某個頁。需要指出的是,虛擬內存管理器調頁時並不是只調入一個頁,為了利用局部特性,它在調入包含所需數據的頁的同時,會將其附近的幾個頁一起調入內存。這里為了簡單和清楚起見,假定只調入目標頁。但應該意識到Win32調頁時的這個特性,因為可以利用它來提高程序效率。這個頁將會用來存放即將從磁盤置換進來的頁的內容。選出某個內存頁后,接着檢查此頁狀態,如果此頁自上次調進內存以來尚未被修改過,則直接使用此頁(代碼頁和只讀頁也可以直接使用);反之,如果此頁已被修改過(“臟”),則需要先將此頁的內容“寫”到調頁文件中與此頁相對應的備份頁中,並隨即將此頁標為空閑頁。

現在,有了一個空閑頁用來存放即將要訪問的數據。此時,虛擬內存管理器會再次檢測,此數據是否是剛被申請的內存且是第一次被訪問。如果是,則直接將此空閑頁清0使用即可(不必從磁盤中將其備份頁的內容讀進,因為該備份頁中的內容無意義);如果不是,則需要將調頁文件中該頁的備份頁讀到此空閑頁中,並隨即將此頁的狀態從空閑頁改為活動頁。

此時,此數據已在物理內存頁中,通過虛擬地址映射到物理地址,即就可訪問此數據了。

上述為訪問成功時的情形,但情形並非總是如此。比如當用戶定義了一個數組,而此數組剛好在其所在頁的下邊界,且此頁的下一頁剛好是自由或者預留的(不是提交的,即沒有真正的物理存儲)。當程序不小心向下越界訪問此數組,則首先引發缺頁錯誤。隨即虛擬內存管理器在處理缺頁錯誤時檢測到它也不在調頁文件中,這就是所謂的“訪問違例”(access violation)。訪問違例意味着要訪問的地址所在的虛擬內存頁還沒有被提交,即沒有實際的物理存儲與之對應,訪問違例會直接導致整個進程退出(即crash)。

可以看到,指針越界訪問的后果根據運行時實際情況而有所不同。如上所述,當數組並非處於其所在頁的邊界,越界后還在同一頁中,這時只會“誤訪問”(誤讀或誤寫,其中誤讀只會影響到正在執行的代碼;誤寫則會影響到其他處代碼的執行)該頁中其他數據,而不會導致整個進程的crash。即使在該數組真的處於其所在頁的邊界,且越界后指針值落在了其相鄰頁。但如果此相鄰頁碰巧也為一個提交頁,此時仍然只是“誤訪問”,也不會導致進程的crash。這也意味着,同一個應用程序的代碼中存在着指針越界訪問錯誤,運行時有時crash,但有時則不會

Microsoft提供了一個監測指針越界訪問的工具pageheap,它的原理就是強制使每次分配的內存都位於頁的邊界,同時強制該頁的相鄰頁為自由頁(即不分配其相鄰頁給程序使用)。這樣每次越界訪問都會立即引起access violation,導致程序crash。從而使得指針越界訪問錯誤在開發期間一定會被暴露出來,而不會發生某個指針越界訪問錯誤一直隱藏到Release版本,直到最終用戶使用時才被發現的情形。

4.1.3  虛擬地址到物理地址的映射

如上所述,在確保訪問的數據已在物理內存中后,還需要先將虛擬地址轉換為物理地址,即“地址映射”,才能夠真正訪問此數據。本節講述Win32中虛擬內存管理器如何將虛擬地址映射為物理地址。

Win32通過一個兩層表結構來實現地址映射,因為4 GB虛擬地址空間為每個進程私有,相應地,每個進程都維護一套自己的層次表結構用來實現其地址映射。第一層表稱為“頁目錄”(page directory),實際上就是一個內存頁(4 KB = 4 096 byte)。這一頁以四個字節為單元分為1 024項,每一項稱為一個“頁目錄項”(Page Directory Entry,PDE);第二層表稱為“頁表”(page table),共有1 024個頁表。頁目錄中每一個頁目錄項PDE對應這一層中的某一個頁表,每一個頁表也占了一個內存頁。這一頁中的4 KB,即4 096個字節也像頁目錄那樣被分成1 024項,每項4個字節,頁表的每一項則稱為“頁表項”(Page Table Entry,PTE)。每一個頁表項PTE都指向物理內存中的某一個頁幀,如圖4-2所示。

圖4-2  頁表

已經知道,Win32提供了4 GB(32位)大小的虛擬地址空間。因此每個虛擬地址都是一個32位的整數值,這32位由3個部分組成,如圖4-3所示。

圖4-3  虛擬地址空間

這三個部分中的第一部分,即前10位為頁目錄下標,用其可以定位在頁目錄的1 024項中的某一項。根據定位到的那一項的項值,可以找到第2層頁表中的某一個頁表。虛擬地址的第二部分,即中間的10位為頁表下標,可用來定位剛剛找到的頁表的1 024項中的某一項。此項值可以找到物理內存中的某一個頁,此頁包含此虛擬地址所代表的數據。最后用虛擬地址的第三部分,即最后12位可用來定位此物理頁中的特定的字節位置,12位剛好可以定位一個頁中的任意位置的字節。

舉一個具體的例子,假設在程序中訪問一個指針(Win32中的“指針”意味虛擬地址),此指針值為0x2A8E317F,圖4-4所示為虛擬地址到物理地址的映射過程。

圖4-4  虛擬地址到物理地址的映射過程

0x2A8E317F的二進制寫法為0010101010,0011100011,000101111111,為了方便起見,將這32位分成10位、10位和12位。第一個10位00101010用來定位頁目錄中的頁目錄項,因為頁目錄項為四個字節,定位前將此10位左移兩位,即0010101000(0x2A8)。再用此值作為下標找到對應的頁目錄項,此頁目錄項指向一個頁表。同樣方法再用第二個10位0011100011定位此頁表中的頁表項。此頁表項指向真正的物理內存,然后用最后12位000101111111定位頁內的數據(此時這12位不用再左移,因為物理頁內定位時,需要能定位到每一個字節。而不像頁目錄和頁表中,只需要定位每4個字節的第1個字節),即為此指針指向的數據。

上面假設的是此數據已在物理內存中,其實,“判斷訪問的數據是否在內存中”這一步驟,也是在這個地址映射過程中完成的,Win32總是假使數據已在物理內存中,並進行地址映射。頁表項中有一位用來標識包含此數據的頁是否在物理內存頁中,當取得頁表項時,檢測此位,如果在,就是本節描述的過程,如果不在,則拋出缺頁錯誤,此時此頁表項中包含了此數據是否在調頁文件中,如果不在,則為訪問違例,如果在,此頁表項可查出了此數據頁在哪個調頁文件中,以及此數據頁在該調頁文件中的起始位置,然后根據這些信息將此數據頁從磁盤中調入物理內存中,再繼續進行地址映射過程。

已經說過,為了實現虛擬地址空間各進程私有,每個進程都擁有自己的頁目錄和頁表結構,對不同進程而言,頁目錄中的頁目錄項值(PDE),以及頁表中的頁表項值(PTE)都是不同的,因此相同的指針(虛擬地址)被不同的進程映射到的物理地址也是不同的。這也意味着,在不同進程間傳遞指針是沒有意義的。

4.1.4  虛擬內存空間使用狀態記錄

當通過VirtualAlloc申請一塊虛擬內存時,虛擬內存管理器是如何知道哪些內存塊是自由的,可以用來滿足此次內存請求呢?即Win32虛擬內存如何維護和記錄每一個進程的4 GB虛擬內存地址空間的使用狀態,如各個區域的狀態、大小及起始地址呢?

上一節中,讀者也許會認為可以通過遍歷頁目錄和頁表中的項值來收集虛擬內存空間的使用狀態,但這樣做首先有效率問題,因為每次申請內存都需要做一次搜索。但這個方法不僅僅是因為效率有問題,而且還是行不通的,對預留的頁來說,虛擬內存管理器並沒有為之分配物理存儲。所以也就不會為其填寫頁表項,這時遍歷頁表無法分辨某塊虛擬內存是自由還是預留的。另外即使對提交頁來說,遍歷頁表也無法得到完整的信息,正如4.1.1節中提到的Win32在虛擬內存管理時用到的主要策略demand-paging,即Win32虛擬內存管理器在程序沒有實際訪問某塊內存前,總是假定這塊內存不會被訪問到,因此不會為這塊內存做過多處理,包括不會為其分配真正的物理內存空間,甚至頁表,即進程中用來完成虛擬地址到物理地址映射的頁表的存儲空間也是隨需分配的。

Win32虛擬內存管理器使用另外一個數據結構來記錄和維護每個進程的4 GB虛擬地址空間的使用及狀態信息,這就是虛擬地址描述符樹(Virtual Address Descriptor,VAD)。每一個進程都有一個自己的VAD集合,這個集合中的VAD被組織成一個自平衡二叉樹,以提高查找的效率。另外只有預留或者提交的內存塊才會有VAD,自由的內存塊沒有VAD(因此不在VAD樹結構中的虛擬地址塊就是自由的)。VAD的組織如圖4-5所示。

圖4-5  VAD的組織結構

(1)當程序申請一塊新內存時,虛擬內存管理器只需訪問VAD樹。找到兩個相鄰VAD,只要小的VAD的上限與大的VAD的下限之間的差值滿足所申請的內存塊的大小需求,即可使用二者之間的虛擬內存。

(2)當第一次訪問提交的內存時,虛擬內存管理器根據上一節描述的流程。即總是假定該數據頁已在物理內存中,並進行虛擬地址到物理地址的轉換。當找到相應的頁目錄項后發現該頁目錄項並沒有指向一個合法的頁表,它就會查找該進程的VAD樹。找到包含該地址的VAD,並根據VAD中的信息,比如該內存塊的大小、范圍,以及在調頁文件中的起始位置等,隨需生成相應的頁表項,然后從剛才發生缺頁錯誤的地方繼續進行地址映射。由此可以看出,一個虛擬內存頁被提交時,除了在調頁文件中開辟一個備份頁之外,不會生成包含指向它的頁表項的頁表,也不會填充指向它的頁表項,更不會為之開辟真正的物理內存頁,而是直到第一次訪問這個提交頁時,才會“隨需地”從VAD中取得包含該頁的整個區域的信息,生成相應頁表,並填充相應頁的表項。

(3)當訪問預留的內存時,虛擬內存管理器也是根據上一節描述的流程進行虛擬地址到物理地址的映射,找到相應的頁目錄項后發現該頁目錄項並沒有指向一個合法的頁表,它就會查找該進程的VAD樹,找到包含該地址的VAD。這時它會發現此段內存塊只是預留的,而沒有提交,即並沒有對應的真正的物理存儲,這時直接拋出訪問違例,進程退出。

(4)當訪問自由的內存時,虛擬內存管理器還是根據上一節描述的流程進行虛擬地址到物理地址的映射。找到相應的頁目錄項后發現該頁目錄項並沒有指向一個合法的頁表,它就會查找該進程的VAD樹,發現並沒有VAD包含此虛擬地址,此時可以知道該地址所在的虛擬地址頁是自由狀態,直接拋出訪問違例,進程退出。

4.1.5  進程工作集

因為頻繁的調頁操作引起的磁盤I/O會大大降低程序的運行效率,因此對每一個進程,虛擬內存管理器都會將其一定量的內存頁駐留在物理內存中。並跟蹤其執行的性能指標,動態調整這個數量。Win32中駐留在物理內存中的內存頁稱為進程的“工作集”(working set),進程的工作集可以通過“任務管理器”查看,其中“內存使用”列即為工作集大小。圖4-6中綠色方框的數字是筆者寫作本書時所用Word編輯器的工作集大小,即38740 KB。

工作集是會動態變化的,進程初始時只有很少的代碼頁和數據頁被調入內存。當執行到未被調入內存的代碼或者訪問到尚未調入內存的數據時,這些代碼頁或者數據頁會被調入物理內存,工作集也隨之增長。但工作集不能無限增長,系統為每個進程都定義了一個默認的最小工作集(根據系統物理內存大小,此值可能為20~50 MB)和最大工作集(根據系統物理內存大小,此值可能為45~345 MB)。當工作集到達最大工作集,即進程需要再次調入新頁到物理內存中時,虛擬內存管理器會將其原來的工作集中的某些頁先置換出內存,然后將需要調入的新頁調入內存。

圖4-6  工作集

因為工作集的頁駐留在物理內存中,因此對這些頁的訪問不涉及磁盤I/O,相對而言非常快;反之,如果執行的代碼或者訪問的數據不在工作集中,則會引發額外的磁盤I/O,從而降低程序的運行效率。一個極端的情況就是所謂的顛簸或抖動(thrashing),即程序的大部分的執行時間都花在了調頁操作上,而不是代碼執行上。

如前所述,虛擬內存管理器在調頁時,不僅僅只是調入需要的頁,同時還將其附近的頁也一起調入內存中。綜合這些知識,對開發人員來說,如果想提高程序的運行效率,應該考慮以下兩個因素。

(1)對代碼來說,盡量編寫緊湊代碼,這樣最理想的情形就是工作集從不會到達最大閥值。在每次調入新頁時,也就不需要置換已經載入內存的頁。因為根據locality特性,以前執行的代碼和訪問的數據在后面有很大可能會被再次執行或訪問。這樣程序執行時,發生的缺頁錯誤數就會大大降低,即減少了磁盤I/O,在圖4-6中也可以看到一個程序執行時截至當時共發生的缺頁錯誤次數。即使不能達到這種理想情形,緊湊的代碼也往往意味着接下來執行的代碼更大可能就在相同的頁或相鄰頁。根據時間locality特性,程序80%的時間花在了20%的代碼上。如果能將這20%的代碼盡量緊湊且排在一起,無疑會大大提高程序的整體運行性能。

(2)對數據來說,盡量將那些會一起訪問的數據(比如鏈表)放在一起。這樣當訪問這些數據時,因為它們在同一頁或相鄰頁,只需要一次調頁操作即可完成;反之,如果這些數據分散在多個頁(更糟的情況是這些頁還不相鄰),那么每次對這些數據的整體訪問都會引發大量的缺頁錯誤,從而降低性能。利用Win32提供的預留和提交兩步機制,可以為這些會一同訪問的數據預留出一大塊空間。此時並沒有分配實際存儲空間,然后在后續執行過程中生成這些數據時隨需為它們提交內存。這樣既不浪費真正的物理存儲(包括調頁文件的磁盤空間和物理內存空間),又能利用locality特性。另外內存池機制也是基於類似的考慮。

4.1.6  Win32內存相關API

在Win32平台下,開發人員可以通過如下5組函數來使用內存(完成申請和釋放等操作)。

(1)傳統的CRT函數(malloc/free系列):因為這組函數的平台無關性,如果程序會被移植到其他非Windows平台,則這組函數是首選。也正因為這組函數非Win32專有,而且介紹這組函數的資料俯拾皆是,這里不作詳細介紹。

(2)global heap/local heap函數(GlobalAlloc/LocalAlloc系列):這組函數是為了向后兼容而保留的。在Windows 3.1平台下,global heap為系統中所有進程共有的堆,這些進程包括系統進程和用戶進程。它們對此global heap內存的申請會交錯在一起,從而使得一個用戶進程的不小心的內存使用錯誤會導致整個操作系統的崩潰。local heap又被稱為“private heap”,與global heap相對應,local heap為每個進程私有。進程通過LocalAlloc從自己的local heap里申請內存,而不會相互干擾。除此之外,進程不能通過另外的用戶自定義堆或者其他方式動態地申請內存。到了Win32平台,由於考慮到安全因素,global heap已經廢棄,local heap也改名為“process heap”。為了使得以前針對Windows 3.1平台寫的應用程序能夠運行在新的Win32平台上,GlobalAlloc/ LocalAlloc系列函數仍然得到沿用,但是這一系列函數最后都是從process heap中分配內存。不僅如此,Win32平台還允許進程除process heap之外生成和使用新的用戶自定義堆,因此在Win32平台下建議不使用GlobalAlloc/LocalAlloc系列函數進行內存操作,因此這里不詳細介紹這組函數。

(3)虛擬內存函數(VirtualAlloc/VirtualFree系列):這組函數直接通過保留(reserve)和提交(commit)虛擬內存地址空間來操作內存,因此它們為開發人員提供最大的自由度,但相應地也為開發人員內存管理工作增加了更多的負擔。這組函數適合於為大型連續的數據結構數組開辟空間。

(4)內存映射文件函數(CreateFileMapping/MapViewOfFile系列):系統使用內存映射文件函數系列來加載.exe或者.dll文件。而對開發人員而言,一方面通過這組函數可以方便地操作硬盤文件,而不用考慮那些繁瑣的文件I/O操作;另一方面,運行在同一台機器上的多個進程可以通過內存映射文件函數來共享數據(這也是同一台機器上進程間進行數據共享和通信的最有效率和最方便的方法)。

(5)堆內存函數(HeapCreate/HeapAlloc系列):Win32平台中的每個堆都是各進程私有的,每個進程除了默認的進程堆,還可以另外創建用戶自定義堆。當程序需要動態創建多個小數據結構時,堆函數系列最為適合。一般來說CRT函數(malloc/free)就是基於堆內存函數實現的。

1.虛擬內存

虛擬內存相關函數共有4對,即VirtualAlloc/VirtualFree、VirtualLock/VirtualUnlock、VirtualQuery/VirtualQueryEx及VirtualProtect/VirtualProtectEx。其中最重要的是第一對,本節主要介紹這一對。

LPVOID VirtualAlloc(

    LPVOID lpAddress,

    DWORD dwSize,

    DWORD flAllocationType,

    DWORD flProtect

);

VirtualAlloc根據flAllocationType的不同,可以保留一段虛擬內存區域(MEM_ RESERVE)或者提交一段虛擬內存區域(MEM_COMMIT)。當保留時,除了修改進程的VAD之外(准確地說是增加了一項),並沒有分配其他資源,如調頁文件空間或者實際物理內存,甚至沒有創建頁表項。因此非常快捷,而且執行速度與保留空間的大小沒有關系。因為保留僅僅只是讓內存管理器預留一段虛擬地址空間,並沒有實在的存儲(硬盤上的調頁文件空間或者物理內存),因此訪問保留地址會引起訪問違例,這是一種嚴重錯誤,會直接導致進程退出;相反,提交虛擬內存時,內存管理器必須從系統調頁文件中開辟實際的存儲空間,因此速度會比保留操作慢。但是需要注意的是,此時在物理內存中並沒有立刻分配空間用來與這段虛擬內存空間相對應,甚至也沒有相應的頁表項被創建,但是提交操作會相應修改VAD項。只有首次訪問這段虛擬地址空間中的某個地址時,由於缺頁中斷,虛擬內存管理器查找VAD,接着根據VAD的內容,動態創建PTE,然后根據PTE信息,分配物理內存頁,並實際訪問該內存。由此可見,真正花費時間的操作不是提交內存,而是對提交內存的第一次訪問!這種lazy-evaluation機制對程序運行性能是十分有益的,因為如果某個程序提交了大段內存,但只是零星地對其中的某些頁進行訪問,如果沒有這種lazy-evaluation機制,提交大段內存會極大地降低系統的性能。

與之相對,VirtualFree釋放內存,它提供兩種選擇:可以將提交的內存釋放給系統,但是不釋放保留的虛擬內存地址空間;也可以在釋放內存的同時將虛擬內存地址空間一並釋放,這樣這塊虛擬內存地址空間的狀態變回初始的自由狀態。如果內存是提交狀態,VirtualFree因為會釋放真正的存儲空間而比較慢;如果只是釋放保留的虛擬內存地址空間,那么因為只需要修改VAD,該操作會很快。

除此之外,VirtualLock保證某塊內存在lock期間一直在物理內存中,因此對該內存的訪問不會引起缺頁中斷。lock的內存用VirtualUnlock解鎖。因為VirtualLock會把內存鎖定在物理內存中,如果這些內存實際中訪問的並不頻繁,那么會使得其他經常使用到的內存反而增大了被調頁出去的概率,從而降低了系統的整體性能,因此在實際使用中,並不推薦使用VirtualLock/VirtualUnlock函數。VirtualQuery可以獲得傳入指針所在的虛擬內存塊的狀態,如包含該指針所在頁的虛擬內存區域的基址,以及該區域的狀態等。VirtualProtect可用來修改某段區域的提交內存頁的存取保護標志。

2.內存映射文件

內存映射文件主要有三個用途,Windows利用它來有效使用exe和dll文件,開發人員利用它來方便地訪問硬盤文件,或者實現不同進程間的內存共享。第一種這里不詳細介紹,只介紹后兩種用途。首先討論它提供的方便訪問硬盤文件的機制,一旦通過這種機制將一個硬盤文件(部分或者全部)映射到進程的一段虛擬地址空間中,讀寫該文件的內容就像通過指針訪問變量一樣。假設pViewMem為文件映射到內存的首址,那么:

*pViewMem = 100;                     //寫文件的第1個字節

char ch = *(pViewMem + 50);     //讀文件的第50個字節內容

下面介紹這種機制的使用步驟。

(1)新建或者打開一個硬盤文件。

此步驟用來獲得一個文件對象的句柄,用CreateFile函數來新建或者打開一個文件:

HANDLE CreateFile(

PCSTR pszFileName,

DWORD dwDesiredAccess,

DWORD dwShareMode,

PSECURITY_ATTRIBUTES psa,

DWORD dwCreationDisposition,

DWORD dwFlagsAndAttributes,

HANDLE hTemplateFile);

其中pszFileName參數指示該文件的路徑名,dwDesiredAccess參數表示該文件內容將會被如何訪問,此參數包括0、GENERIC_READ、GENERIC_WRITE,以及GENERIC_ READ | GENERIC_WRITE共4種可能,分別表示“不能讀也不能寫”(在只為了讀取該文件屬性時使用)、“只讀”、“只寫”,以及“既可讀也可寫”;dwShareMode參數用來限定對該文件的任何其他訪問的權限,也包括上述4種類型。剩余的幾個參數因為與要討論的問題關系不大,所以不贅述。

此函數成功時,會返回一個文件對象句柄;否則會返回INVALID_HANDLE_ VALUE。

(2)創建或者打開一個文件映射內核對象。

還需要有一個文件映射內核對象,正是它真正將文件內容映射到內存中。如果已經存在此內核對象,只需通過OpenFileMapping函數將其打開即可,這個函數返回該命名對象的句柄。大多數情況下,需要新建一個文件映射內核對象,此時調用CreateFileMapping函數:

HANDLE CreateFileMapping(

HANDLE hFile,

PSECURITY_ATTRIBUTES psa,

DWORD fdwProtect,

DWORD dwMaximumSizeHigh,

DWORD dwMaximumSizeLow,

PCTSTR pszName);

hFile參數是第一個步驟中返回的文件內核對象句柄;psa參數是指明內核對象安全特性的,不詳述;fdwProtect參數指明了對映射到內存頁中的文件內容的存取權限,這個權限必須與第一個步驟中的文件訪問權限對應;dwMaximumSizeHigh和dwMaximumSizeLow參數指明映射的最大的空間大小,因為Windows支持大小達到64位的文件,因此需要兩個32位的參數;pszName為內核對象名稱。

此步只是創建了一個文件映射內核對象,並沒有預留或者提交虛擬地址空間,更沒有物理內存頁被分配出來存放文件內容。

(3)映射文件的內容到進程虛擬地址空間。

訪問文件內容之前,必須將要訪問的文件內容映射到內存中,通過MapViewOfFile函數完成:

PVOID MapViewOfFile(

HANDLE hFileMappingObject,

DWORD dwDesiredAccess,

DWORD dwFileOffsetHigh,

DWORD dwFileOffsetLow,

SIZE_T dwNumberOfBytesToMap);

其中參數分別為:用來映射內存映射內核對象的句柄,映射的文件內容到內存內存頁的存取權限,需要映射的文件內容的起始部分在文件中的偏移及大小。映射時並不需要一次將整個文件的內容全部映射到內存中。

這個函數的操作包括從進程虛擬地址空間中預留出所需映射大小的一段區域,然后提交。提交時並不是從系統的調頁文件中開辟空間用來作為該段區域的備份存儲,而是內存映射內核對象所對應的文件的指明區域。與虛擬內存使用的惟一不同就是該段虛擬地址空間區域的備份存儲不同,其他都是一樣的。同樣,此時並沒有真正的物理內存開辟出來,直到通過返回的指針訪問已經映射到內存中的文件內容時,因為發生缺頁錯誤,系統才會分配物理內存頁,並將對應的文件存儲中的內容調頁到該物理內存頁。

(4)訪問文件內容。

現在可以通過MapViewOfFile函數返回的指針來訪問該段映射到內存中文件內容,就像本小節演示的那樣,通過指針訪問硬盤文件內容。

這里需要提醒的是,通過該指針修改文件內容時,修改的結果常常不會立刻反映到文件中,因為實際上是在對調入物理內存頁中的數據進行修改。考慮到性能因素,該頁並不會每做一次修改就立刻將該修改同步到硬盤文件中。如果需要在某個時候強制將之前所做的修改一次性同步到與之對應的硬盤文件中時,可以通過FlushViewOfFile函數達到這個目的:

BOOL FlushViewOfFile(PVOID pvAddress, SIZE_T dwNumberOfBytesToFlush);

這個函數傳入需要將修改同步到硬盤文件中的內存塊的起始地址和大小。

(5)取消文件內容到進程虛擬地址空間的映射。

當該段映射到內存中的文件內容訪問完畢,不再需要訪問時,為了有效地利用系統的資源,應該及時回收該段內存,這時調用UnmapViewOfFile函數:

BOOL UnmapViewOfFile(PVOID pvBaseAddress);

此函數傳入MapViewOfFile函數返回的指針,系統回收對應的MapViewOfFile調用時預留並提交的虛擬內存地址空間區域,這樣該段區域可被其他申請使用。另外因為對應的備份存儲不是系統的調頁文件,所以不存在備份存儲回收的問題。

(6)關閉文件映射內核對象和文件內核對象。

最后,在完成任務不再使用該文件時,通過CloseHandle(hFile)和CloseHandle (hMapping)來關閉文件並釋放內存映射文件的內核對象句柄。

下面接着討論如何利用內存映射文件內核對象來進行進程間的內存共享。

進程間通過內存映射文件進行內存共享時,該內存映射文件內核對象常常不是基於某一個硬盤文件,而是從系統的調頁文件中開辟空間作為臨時用做共享的存儲空間。因此與單純地利用內存映射文件來訪問硬盤文件內容稍有不同,下面是通過內存映射文件來進行進程間內存共享的步驟。假設有進程A和進程B,進程A通過CreateFileMapping創建一個基於系統調頁文件的名為“SharedMem”的內存映射文件內核對象:

HANDLE m_hFileMapA = CreateFileMapping

(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE, 0,

10 * 1024, TEXT("SharedMem"));

需要注意的是,因為現在不再基於普通的硬盤文件,所以不需要調用CreateFile來新建或者打開文件這個步驟,注意此時傳入的文件句柄參數為INVALID_HANDLE_VALUE,此參數代表從調頁文件中開辟空間作為共享內存。

進程B通過OpenFileMapping打開剛才進程A創建的名為“SharedMem”的內存映射文件內核對象:

HANDLE m_hFileMapB = OpenFileMapping(..., TEXT("SharedMem"));

進程A和進程B都可以用此內存映射文件內核對象將從系統調頁文件中開辟的那塊存儲空間的全部或者部分映射到內存中,然后即可使用。

進程A:

...

PVOID pViewA = MapViewOfFile(m_hFileMapA, FILE_MAP_READ | FILE_MAP_WRITE, 0, 0, 0);

...

進程B:

...

PVOID pViewB = MapViewOfFile(m_hFileMapB, FILE_MAP_READ | FILE_MAP_WRITE, 0, 0, 0);

...

它們各自對該共享內存的修改都能夠及時地被對方看到。另外需要注意的是,它們映射到的虛擬內存空間區域並不一定有相同的起始地址,這是因為它們擁有自己的虛擬地址空間。

還有一個需要引起注意,但很難發現的問題是因為創建基於系統調頁文件的內存映射文件內核對象是通過傳入hFile為INVALID_HANDLE_VALUE的參數來標記的,而創建或者打開普通硬盤文件失敗時的返回值也是INVALID_HANDLE_VALUE,因此諸如下面這段代碼存在的bug是很難發現的:

...

HANDLE hFile = CreateFile(...);

HANDLE hMap = CreateFileMapping(hFile, ...);

if (hMap == NULL)

return(GetLastError());

...

這段代碼的本意是首先創建或者打開一個普通的硬盤文件,然后創建一個基於此文件的內存映射文件內核對象,而並不是想創建一個基於系統調頁文件的該對象。但是可以看到,當第1句CreateFile執行失敗時,返回INVALID_HANDLE_VALUE。這個返回值立刻被傳入到CreateFileMapping函數,結果創建了一個基於系統調頁文件的內存映射文件內核對象。這並不是這段代碼的原意,而且也會造成問題。因為基於普通硬盤文件的內存映射文件內核對象的操作往往希望將最后的結果保存在該文件中,而基於系統調頁文件的內存映射文件內核對象的操作往往只是關注該數據在執行期的結果,操作完畢后並不保存該結果。當CreateFile失敗且程序運行后,程序運行無誤。但是當檢查結果文件時,會發現該文件要么沒有被創建,要么數據沒有改動,因為隨后的操作都是基於系統調頁文件的!

因此當使用基於普通硬盤文件的內存映射文件內核對象時,一定要在CreateFile調用完后檢查返回值。

3.堆

分配多個小塊內存一般都會選擇使用堆函數,比如鏈表節點和樹節點等,堆函數的最大優點就是開發人員不用考慮頁邊界之類的瑣碎事情;劣勢就是堆函數的操作相對虛擬內存和內存映射文件來說速度要慢些,而且無法像虛擬內存或者內存映射文件那樣直接提交或者回收物理存儲。

進程都有一個默認的堆,其初始區域大小默認是1 MB,鏈接時可以通過/HEAP參數修改此默認值。很多操作的臨時存儲都使用進程的默認堆,比如絕大多數的Win32函數,進程默認堆的句柄可以通過GetProcessHeap函數獲得。

因為程序大部分的內存需求都是從進程默認堆中分配的,而且在多線程情況下還需要考慮線程安全問題。因此對特定的應用,這種情況會造成程序的性能下降。針對這種需求,Win32提供了自定義堆機制。

自定義堆的步驟如下。

(1)創建自定義堆。

與進程默認堆(進程創建時系統自動創建)不同,自定義堆需要開發人員首先通過HeapCreate函數創建:

HANDLE HeapCreate(

DWORD fdwOptions,

SIZE_T dwInitialSize,

SIZE_T dwMaximumSize);

fdwOptions參數可以指明是否需要串行化訪問支持(HEAP_NO_SERIALIZE),以及分配和回收內存出錯時是否拋出異常(HEAP_GENERATE_EXCEPTIONS)。當該自定義堆會被多個線程同時訪問時,需要加上串行化訪問支持,但相應的性能會有所下降。

dwInitialSize參數指明該自定義堆創建時提交的存儲大小(頁大小的倍數),dwMaximumSize參數則指明該自定義堆從進程虛擬地址空間中預留出的區域大小。隨着對此自定義堆內存的分配,提交的存儲大小隨之變大,但此參數限制了增大的極限。另一種情況時是dwMaximumSize為0,此時該自定義堆可以一直增長,直到進程虛擬地址空間用完。

(2)從自定義堆中分配內存。

從自定義堆中分配內存調用函數HeapAlloc(從進程默認堆中分配內存也調用此函數):

PVOID HeapAlloc(

HANDLE hHeap,

DWORD fdwFlags,

SIZE_T dwBytes);

hHeap參數即上一步驟中返回的堆內核對象句柄,fdwFlags可以取HEAP_ ZERO_MEMORY、HEAP_GENERATE_EXCEPTIONS和HEAP_NO_SERIALIZE共3個值,HEAP_ZERO_MEMORY指明返回的內存必須全部清0。HEAP_GENERATE_ EXCEPTIONS指明此次分配內存如果失敗,需要拋出異常。如果該自定義堆創建時指明過此參數,則其上的內存分配不必再指明此參數;如果堆創建時沒有指明,則可以在每次申請時指明。HEAP_NO_SERIALIZE參數指明此次分配不必串行化訪問支持。最后的dwBytes參數指明此次分配的內存大小,返回值為分配內存的起始位置。

(3)釋放內存。

從堆中釋放內存調用HeapFree函數:

BOOL HeapFree(

HANDLE hHeap,

DWORD fdwFlags,

PVOID pvMem);

這個函數的參數意義很明顯,無須贅述。需要指出的是,這樣釋放內存並不能保證所有物理存儲被回收,一是因為物理存儲以頁大小為單位判斷是否可以回收;二是Windows設計堆機制時對效率的考慮。

(4)銷毀自定義堆。

當程序不再需要使用某個自定義堆時,調用HeapDestroy函數:

BOOL HeapDestroy(HANDLE hHeap);

對堆的銷毀有幾點需要說明,一是當堆銷毀時,所有從該堆分配的內存全部被回收,而不必對那些內存一一進行釋放,同時該堆占用物理存儲以及虛擬地址空間區域也會被系統回收;二是如果沒有顯式銷毀自定義堆,這些堆會在程序退出時被系統銷毀。需要注意的是,線程創建的自定義堆並不會在線程退出時被銷毀,而是當整個進程退出時才會被銷毀,從資源利用效率角度出發,應該在自定義堆不再被使用時立即銷毀;三是進程默認堆不能通過此函數銷毀,更嚴格地說,進程默認堆在進程退出前是不能被銷毀的。

自定義堆的其他函數如下。

(1)獲得進程所有堆:

DWORD GetProcessHeaps(

DWORD dwNumHeaps,

PHANDLE pHeaps);

此函數返回進程目前所有的堆(包括進程默認堆),傳入存放所有堆內核對象句柄的數組,以及數組的大小,返回值為堆數目。

(2)修改分配內存的大小:

PVOID HeapReAlloc(

HANDLE hHeap,

DWORD fdwFlags,

PVOID pvMem,

SIZE_T dwBytes);

這個函數可以修改原來分配的內存塊(pvMem)的大小,新的大小由參數dwBytes指明。

(3)查詢某塊分配內存的大小:

SIZE_T HeapSize(

HANDLE hHeap,

DWORD fdwFlags,

LPCVOID pvMem);

這個函數可以查詢到原來分配的一個內存塊的大小。當該內存塊指針是外部模塊傳入時,如果需要知道該塊確切大小時,這個函數就可以發揮作用。

(4)堆壓縮:

UINT HeapCompact(

HANDLE hHeap,

DWORD fdwFlags);

此函數將相鄰的回收回來的自由塊合並在一起,需要注意的是,這個函數並不能移動已經分配的內存塊,即它並不能消除內存碎片。

自定義堆有如下優點。

(1)減少碎片,節省內存:由於大多數自定義堆是為某些特定的數據結構創建的,所以這些數據結構大小相同,從而使得上次釋放的空間有更大機會剛好可以滿足下一次的內存申請,從而減少了碎片的產生。

(2)由於局部性獲得的性能提高:由上所述,自定義堆上的內存塊大多是某些特定的數據結構,比如鏈表節點或者樹節點。這些數據結構有着很強的時間局部性,即程序往往會在相鄰的時間內訪問所有這些數據。如果這些數據都放在某一個自定義堆中,這種空間局部性就會極大地減少對這些數據整體訪問引起的缺頁錯誤,從而提高了程序的運行性能。

(3)由避免線程同步獲得的性能提高:如前所述,因為進程默認堆可能會被多個線程同時訪問,因此添加了保證線程安全的串行化訪問支持,但串行化訪問支持的代價就是性能下降。如果某個自定義堆只允許某單個線程訪問,那么此自定義堆不必添加串行化訪問支持,從而提高程序的性能。 


注意!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系我们删除。



 
粤ICP备14056181号  © 2014-2021 ITdaan.com