如果你完全理解如下內容, 請聯系我:szu030606@163.com,? 討論更深層次合作 。
1.?
單線程模型
多線程模型
?
2.?
?
?
?
?
?
?
3.?
?
?
?
?
?
?
?
4.?
?
?
?
?
5.?
?
?
?
?
?
6.?
7.?
8.?
9.?
?
++++++++++++++++++++++++++++++++++++++++++++++++++++++++
大內高手—內存模型
?
了解linux的內存模型,或許不能讓你大幅度提高編程能力,但是作為一個基本知識點應該熟悉。坐火車外出旅行時,即時你對沿途的地方一無所知,仍然可以到達目標地。但是你對整個路途都很比較清楚的話,每到一個站都知道自己在哪里,知道當地的風土人情,對比一下所見所想,旅程可能更有趣一些。
?
類似的,了解linux的內存模型,你知道每塊內存,每個變量,在系統中處于什么樣的位置。這同樣會讓你心情愉快,知道這些,有時還會讓你的生活輕更松些。看看變量的地址,你可以大致斷定這是否是一個有效的地址。一個變量被破壞了,你可以大致推斷誰是犯罪嫌疑人。
?
Linux的內存模型,一般為:
?
地址 | 作用 | 說明 |
>=0xc000 0000 | 內核虛擬存儲器 | 用戶代碼不可見區域 |
<0xc000 0000 | Stack(用戶棧) | ESP指向棧頂 |
? | ↓ ? ↑ | ? 空閑內存 |
>=0x4000 0000 | 文件映射區 | ? |
<0x4000 0000 | ? ? ↑ | ? 空閑內存 ? |
? | Heap(運行時堆) | 通過brk/sbrk系統調用擴大堆,向上增長。 |
? | .data、.bss(讀寫段) | 從可執行文件中加載 |
>=0x0804 8000 | .init、.text、.rodata(只讀段) | 從可執行文件中加載 |
<0x0804 8000 | 保留區域 | ? |
?
很多書上都有類似的描述,本圖取自于《深入理解計算機系統》p603,略做修改。本圖比較清析,很容易理解,但仍然有兩點不足。下面補充說明一下:
?
1.?
為說明這個問題,我們先運行一個測試程序,并觀察其結果:
?
#include<stdio.h> ? intmain(intargc,char*argv[]) { ? ? ? ? ? ? ? ? ? ? ? ? } ? |
運行后,輸出結果為:
main=0x8048404 print=0x8048324
first=0xbfcd1264
p0=0x9253008 p1=0xb7ec0008 p2=0x97ebf008 p3=0x57ebe008
?
l?
l?
l?
l?
?
原因在于:運行時堆的位置與內存管理算法相關,也就是與malloc的實現相關。關于內存管理算法的問題,我們在后繼文章中有詳細描述,這里只作簡要說明。在glibc實現的內存管理算法中,Malloc小塊內存是在小于0x4000 0000的內存中分配的,通過brk/sbrk不斷向上擴展,而分配大塊內存,malloc直接通過系統調用mmap實現,分配得到的地址在文件映射區,所以其地址大于0x4000 0000。
?
從maps文件中可以清楚的看到一點:
?
00514000-00515000 r-xp 00514000 00:00 0 00624000-0063e000 r-xp 00000000 03:01 718192? 0063e000-0063f000 r-xp 00019000 03:01 718192? 0063f000-00640000 rwxp 0001a000 03:01 718192? 00642000-00766000 r-xp 00000000 03:01 718193? 00766000-00768000 r-xp 00124000 03:01 718193? 00768000-0076a000 rwxp 00126000 03:01 718193? 0076a000-0076c000 rwxp 0076a000 00:00 0 08048000-08049000 r-xp 00000000 03:01 1307138? 08049000-0804a000 rw-p 00000000 03:01 1307138? 09f5d000-09f7e000 rw-p 09f5d000 00:00 0? 57e2f000-b7f35000 rw-p 57e2f000 00:00 0 b7f44000-b7f45000 rw-p b7f44000 00:00 0 bfb2f000-bfb45000 rw-p bfb2f000 00:00 0? |
?
2.?
現在的應用程序,多線程的居多。表一所描述的模型無法適用于多線程環境。按表一所述,程序最多擁有上G的棧空間,事實上,在多線程情況下,能用的棧空間是非常有限的。為了說明這個問題,我們再看另外一個測試:
?
#include<stdio.h> #include<pthread.h> ? ? void*thread_proc(void*param) { ? ? ? ? ? ? ? ? } ? #defineN5 intmain(intargc,char*argv[]) { ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? } ? |
運行后,輸出結果為:
first=0xbfd3d35c
(0xb7f2cbb0): first=0xb7f2c454
(0xb7f2cbb0): p0=0x84d52d8 p1=0xb4c27008
(0xb752bbb0): first=0xb752b454
(0xb752bbb0): p0=0x84d56e0 p1=0xb4b26008
(0xb6b2abb0): first=0xb6b2a454
(0xb6b2abb0): p0=0x84d5ae8 p1=0xb4a25008
(0xb6129bb0): first=0xb6129454
(0xb6129bb0): p0=0x84d5ef0 p1=0xb4924008
(0xb5728bb0): first=0xb5728454
(0xb5728bb0): p0=0x84d62f8 p1=0xb7e2c008
?
我們看一下:
主線程與第一個線程的棧之間的距離:0xbfd3d35c - 0xb7f2c454=0x7e10f08=126M
第一個線程與第二個線程的棧之間的距離:0xb7f2c454 - 0xb752b454=0xa01000=10M
其它幾個線程的棧之間距離均為10M。
也就是說,主線程的棧空間最大為126M,而普通線程的棧空間僅為10M,超這個范圍就會造成棧溢出。
?
棧溢出的后果是比較嚴重的,或者出現Segmentation fault錯誤,或者出現莫名其妙的錯誤。
進階2:
l?
棧作為一種基本數據結構,我并不感到驚訝,用來實現函數調用,這也司空見慣的作法。直到我試圖找到另外一種方式實現遞歸操作時,我才感嘆于它的巧妙。要實現遞歸操作,不用棧不是不可能,而是找不出比它更優雅的方式。
?
盡管大多數編譯器在優化時,會把常用的參數或者局部變量放入寄存器中。但用棧來管理函數調用時的臨時變量(局部變量和參數)是通用做法,前者只是輔助手段,且只在當前函數中使用,一旦調用下一層函數,這些值仍然要存入棧中才行。
?
通常情況下,棧向下(低地址)增長,每向棧中PUSH一個元素,棧頂就向低地址擴展,每從棧中POP一個元素,棧頂就向高地址回退。一個有興趣的問題:在x86平臺上,棧頂寄存器為ESP,那么ESP的值在是PUSH操作之前修改呢,還是在PUSH操作之后修改呢?PUSH ESP這條指令會向棧中存入什么數據呢?據說x86系列CPU中,除了286外,都是先修改ESP,再壓棧的。由于286沒有CPUID指令,有的OS用這種方法檢查286的型號。
?
一個函數內的局部變量以及其調用下一級函數的參數,所占用的內存空間作為一個基本的單元,稱為一個幀(frame)。在gdb里,f命令就是用來查看指定幀的信息的。在兩個frame之間通過還存有其它信息,比如上一層frame的分界地址(EBP)等。
?
關于棧的基本知識,就先介紹這么多,我們下面來看看一些關于棧的技巧及應用:
1.?
callstack調試器的基本功能之一,利用此功能,你可以看到各級函數的調用關系。在gdb中,這一功能被稱為backtrace,輸入bt命令就可以看到當前函數的callstack。它的實現多少有些有趣,我們在這里研究一下。
?
我們先看看棧的基本模型
?
參數N | ↓高地址 |
參數… | 函數參數入棧的順序與具體的調用方式有關 |
參數3 | |
參數2 | |
參數1 | |
EIP | 返回本次調用后,下一條指令的地址 |
EBP | 保存調用者的EBP,然后EBP指向此時的棧頂。 |
臨時變量1 | ? |
臨時變量2 | ? |
臨時變量3 | ? |
臨時變量… | ? |
臨時變量5 | ↓低地址 |
?
要實現callstack我們需要知道以下信息:
l?
l?
關于第一點,從上表中,我們可以看出,棧中存有各級EIP的值,我們取出來就行了。用下面的代碼可以輕易實現:
?
#include<stdio.h> ? intbacktrace(void**BUFFER,int SIZE) { ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? } ? #defineN 4 staticvoid test2() { ? ? ? ? ? ? ? ? ? ? ? } ? staticvoid test1() { ? } ? staticvoid test() { ? } ? intmain(intargc,char*argv[]) { ? ? ? } |
程序輸出:
0x8048460
0x804849c
0x80484a9
0x80484cc
?
關于第二點,如何把指令地址與行號對應起來,這也很簡單。可以從MAP文件或者ELF中查詢。Binutil帶有一個addr2line的小工具,可以幫助實現這一點。
[root@linux bt]# addr2line?
/root/test/bt/bt.c:42
?
2.?
大家都知道動態分配的內存,一定要釋放掉,否則就會有內存泄露。可能鮮有人知,動態分配的內存,可以不用釋放。Alloca就是這樣一個函數,最后一個a代表auto,即自動釋放的意思。
?
Alloca是在棧中分配內存的。即然是在棧中分配,就像其它在棧中分配的臨時變量一樣,在當前函數調用完成時,這塊內存自動釋放。
?
正如我們前面講過,棧的大小是有限制的,普通線程的棧只有10M大小,所以在分配時,要量力而行,且不要分配過大內存。
?
Alloca可能會漸漸的退出歷史舞臺,原因是新的C/C++標準都支持變長數組。比如int array[n],老版本的編譯器要求n是常量,而新編譯器允許n是變量。編譯器支持的這一功能完全可以取代alloca。
?
這不是一個標準函數,但像linux和win32等大多數平臺都支持。即使少數平臺不支持,要自己實現也不難。這里我們簡單介紹一下alloca的實現方法。
?
我們先看看一個小程序,再看看它對應的匯編代碼,一切都清楚了。
?
#include<stdio.h> ? intmain(intargc,char*argv[]) { ? ? ? ? ? } |
匯編代碼為:
?
intmain(intargc,char*argv[]) { ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? } |
?
其中關鍵的一條指令為:sub?
由此可以看出實現alloca,僅僅是把ESP減去指定大小,擴大棧空間(記記住棧是向下增長),這塊空間就是分配的內存。
?
3.?
對新手來說,可變參數的函數也是比較神奇。還是以一個小程序來說明它的實現。
?
#include<stdio.h> #include<stdarg.h> ? intprint(constchar*fmt, ...) { ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? } ? intmain(intarg, char argv[]) { ? ? ? } |
?
我們看看對應的匯編代碼:
?
intprint(constchar*fmt, ...) { ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? } intmain(intarg,char argv[]) { ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? } |
?
從匯編代碼中,我們可以看出,參數是逆序入棧的。在取參數時,先讓ap指向第一個參數,又因為棧是向下增長的,不斷把指針向上移動就可以取出所有參數了。
進階3:
全局內存
?
?
有人可能會說,全局內存就是全局變量嘛,有必要專門一章來介紹嗎?這么簡單的東西,還能玩出花來?我從來沒有深究它,不一樣寫程序嗎?關于全局內存這個主題雖然玩不出花來,但確實有些重要,了解這些知識,對于優化程序的時間和空間很有幫助。因為有好幾次這樣經歷,我才決定花一章篇幅來介紹它。
?
正如大家所知道的,全局變量是放在全局內存中的,但反過來卻未必成立。用static修飾的局部變量就是放在放全局內存的,它的作用域是局部的,但生命期是全局的。在有的嵌入式平臺中,堆實際上就是一個全局變量,它占用相當大的一塊內存,在運行時,把這塊內存進行二次分配。
?
這里我們并不強調全局變量和全局內存的差別。在本文中,全局強調的是它的生命期,而不是它的作用域,所以有時可能把兩者的概念互換。
?
一般來說,在一起定義的兩個全局變量,在內存的中位置是相鄰的。這是一個簡單的常識,但有時挺有用,如果一個全局變量被破壞了,不防先查查其前后相關變量的訪問代碼,看看是否存在越界訪問的可能。
?
在ELF格式的可執行文件中,全局內存包括三種:bss、data和rodata。其它可執行文件格式與之類似。了解了這三種數據的特點,我們才能充分發揮它們的長處,達到速度與空間的最優化。
?
1.?
已經記不清bss代表Block Storage Start還是Block Started by Symbol。像這我這種沒有用過那些史前計算機的人,終究無法明白這樣怪異的名字,也就記不住了。不過沒有關系,重要的是,我們要清楚bss全局變量有什么樣特點,以及如何利用它。
?
通俗的說,bss是指那些沒有初始化的和初始化為0的全局變量。它有什么特點呢,讓我們來看看一個小程序的表現。
?
intbss_array[1024 * 1024] = {0}; ? intmain(intargc,char*argv[]) { ? } |
[root@localhost bss]# gcc -g bss.c -o bss.exe
[root@localhost bss]# ll
total 12
-rw-r--r-- 1root root?
-rwxr-xr-x1 root root 5683 Jun 22 14:32 bss.exe
?
變量bss_array的大小為4M,而可執行文件的大小只有5K。由此可見,bss類型的全局變量只占運行時的內存空間,而不占文件空間。
?
另外,大多數操作系統,在加載程序時,會把所有的bss全局變量全部清零,無需要你手工去清零。但為保證程序的可移植性,手工把這些變量初始化為0也是一個好習慣。
?
2.?
與bss相比,data就容易明白多了,它的名字就暗示著里面存放著數據。當然,如果數據全是零,為了優化考慮,編譯器把它當作bss處理。通俗的說,data指那些初始化過(非零)的非const的全局變量。它有什么特點呢,我們還是來看看一個小程序的表現。
?
intdata_array[1024 * 1024] = {1}; ? intmain(intargc,char*argv[]) { ? } |
?
[root@localhostdata]# gcc-gdata.c -odata.exe
[root@localhostdata]# ll
total 4112
-rw-r--r-- 1root root?
-rwxr-xr-x1 root root 4200025 Jun 22 14:35 data.exe
?
僅僅是把初始化的值改為非零了,文件就變為4M多。由此可見,data類型的全局變量是即占文件空間,又占用運行時內存空間的。
?
3.?
rodata的意義同樣明顯,ro代表read only,即只讀數據(const)。關于rodata類型的數據,要注意以下幾點:
l?
l?
l?
l?
l?
?
由此可見,把在運行過程中不會改變的數據設為rodata類型的,是有很多好處的:在多個進程間共享,可以大大提高空間利用率,甚至不占用RAM空間。同時由于rodata在只讀的內存頁面(page)中,是受保護的,任何試圖對它的修改都會被及時發現,這可以幫助提高程序的穩定性。
?
4.?
static關鍵字用途太多,以致于讓新手模糊。不過,總結起來就有兩種作用,改變生命期和限制作用域。如:
l?
l?
l?
l?
?
const關鍵字倒是比較明了,用const修飾的變量放在rodata里,字符串默認就是常量。對const,注意以下幾點就行了。
l?
l?
l?
?
violatile關鍵字通常用來修飾多線程共享的全局變量和IO內存。告訴編譯器,不要把此類變量優化到寄存器中,每次都要老老實實的從內存中讀取,因為它們隨時都可能變化。這個關鍵字可能比較生僻,但千萬不要忘了它,否則一個錯誤讓你調試好幾天也得不到一點線索。
進階4:
內存管理器(一)
?
?
?
內存管理算法多少有些神秘,我們很少想著去實現自己的內存管理算法,這也難怪:有這樣需求的情況并不多。其實,至于內存分配算法的實現,說簡單也簡單,說復雜也復雜。要寫一個簡單的,或許半天時間就可以搞掂,而要寫一個真正實用的,可能要花上你幾周甚至幾個月的時間。
?
malloc和free是兩個核心函數,而calloc和realloc之所以存在,完全是為了提高效率的緣故。否則完全可以用malloc和free的組合來模擬它們。
?
拿calloc函數的實現來說,在32位機上,內存管理器保證內存至少是4字節對齊的,其長度也會擴展到能被4字節整除,那么其清零算法就可以優化。可以一次清零4個字節,這大大提高清零速度。
?
拿realloc函數的實現來說,如果realloc的指針后面有足夠的空間,內存管理器可以直接擴展其大小,而無須拷貝原有內容。當然,新大小比原來還小時,更不拷貝了。相反,通過malloc和free來實現realloc時,兩種情況下都要拷貝,效率自然會低不少。
?
另外還有兩個非機標準的,但很常用的函數,也涉及到內存分配:strdup和strndup。這兩個函數在linux和win32下都支持,非常方便。這完全可以用malloc來模擬,而且沒有性能上的損失。
?
這里我們主要關注malloc和free兩個函數的實現,并以glibc 2.3.5(32位linux)為例分析。
?
內存管理器的目標
內存管理器為什么難寫?在設計內存管理算法時,要考慮什么因素?管理內存這是內存管理器的功能需求。正如設計其它軟件一樣,質量需求一樣占有重要的地位。分析內存管理算法之前,我們先看看對內存管理算法的質量需求有哪些:
?
l?
要實現內存管理器時,先要定義出分配器的接口函數。接口函數沒有必要標新立異,而是要遵循現有標準(如POSIX或者Win32),讓使用者可以平滑的過度到新的內存管理器上。
?
l?
通常情況下,內存管理器要向OS申請內存,然后進行二次分配。所以,在適當的時候要擴展內存或釋放多余的內存,這要調用OS提供的函數才行。OS提供的函數則是因平臺而異,盡量抽象出平臺相關的代碼,保證內存管理器的可移植性。
?
l?
內存管理器要管理內存,必然要使用自己一些數據結構,這些數據結構本身也要占內存空間。在用戶眼中,這些內存空間毫無疑問是浪費掉了,如果浪費在內存管理器身的內存太多,顯然是不可以接受的。
?
內存碎片也是浪費空間的罪魁禍首,若內存管理器中有大量的內存碎片,它們是一些不連續的小塊內存,它們總量可能很大,但無法使用,這也是不可以接受的。
?
l?
內存分配/釋放是常用的操作。按著2/8原則,常用的操作就是性能熱點,熱點函數的性能對系統的整體性能尤為重要。
?
l?
內存管理算法設計的難點就在于要適應用不同的情況。事實上,如果缺乏應用的上下文,是無法評估內存管理算法的好壞的。可以說在任何情況下,專用算法都比通用算法在時/空性能上的表現更優。
?
為每種情況都寫一套內存管理算法,顯然是不太合適的。我們不需要追求最優算法,那樣代價太高,能達到次優就行了。設計一套通用內存管理算法,通過一些參數對它進行配置,可以讓它在特定情況也有相當出色的表現,這就是可調性。
?
l?
大家都知道,使用cache可以提高程度的速度,但很多人未必知道cache使程序速度提高的真正原因。拿CPU內部的cache和RAM的訪問速度相比,速度可能相差一個數量級。兩者的速度上的差異固然重要,但這并不是提高速度的充分條件,只是必要條件。
?
另外一個條件是程序訪問內存的局部性(Locality)。大多數情況下,程序總訪問一塊內存附近的內存,把附近的內存先加入到cache中,下次訪問cache中的數據,速度就會提高。否則,如果程序一會兒訪問這里,一會兒訪問另外一塊相隔十萬八千里的內存,這只會使數據在內存與cache之間來回搬運,不但于提高速度無益,反而會大大降低程序的速度。
?
因此,內存管理算法要考慮這一因素,減少cache miss和page fault。
?
l?
作為一個C/C++程序員,內存錯誤可以說是我們的噩夢,上一次的內存錯誤一定還讓你記憶猶新。內存管理器提供的調試功能,強大易用,特別對于嵌入式環境來說,內存錯誤檢測工具缺乏,內存管理器提供的調試功能就更是不可或缺了。
?
l?
前面說了最大化可調性,以便讓內存管理器適用于不同的情況。但是,對于不同情況都要去調設置,無疑太麻煩,是非用戶友好的。要盡量讓內存管理器適用于很廣的情況,只有極少情況下才去調設置。
?
設計是一個多目標優化的過程,有些目標之間存在著競爭。如何平衡這些競爭力是設計的難點之一。在不同的情況下,這些目標的重要性又不一樣,所以根本不存在一個最好的內存分配算法。
?
關于glibc的內存分配器,我們并打算做代碼級分析,只談談幾點有趣的東西:
1.?
l?
l?
l?
l?
?
2.?
?
l?
本函數用于擴展堆空間(堆空間的定義可參考內存模型一章),用end_data_segment指明堆的結束地址。
l?
本函數用于擴展堆空間(堆空間的定義可參考內存模型一章),用increment指定要增加的大小。
l?
本函數用于分配大塊內存了,如前面所述大于128K的內存。
?
3.?
l?
l?
?
4.?
l?
l?
5.?
?
|
如果前面有一塊有效內存塊的,則第一個size_t指明前一塊內存的大小。
第二個size_t指明自己的大小,同時還指明:自己是不是用mmap分配的(M),前面是否有一個效內存塊(P)。你可能覺得奇怪,在32位機上,sizeof(size_t)就是32位,怎么還能留下兩個位來保存標志呢?前面我們說了,會對內存長度取整,保證最低2或3bits為0,即是空閑的。
?
6.?
?
|
由此可以看出,最小內存塊的長度為16字節:
sizeof(size_t) +
sizeof(size_t) +
sizeof(void*) +
sizeof(void*) +
0
這一招非常管用,第一次看到時,感覺簡直太巧妙了。這使得無需要額外的內存來管理空閑塊,利用空閑塊自己,把空閑塊強制轉換成一個雙向鏈表就行了。
?
?
大內高手—共享內存與線程局部存儲
?
城里的人想出去,城外的人想進來。這是《圍城》里的一句話,它可能比《圍城》本身更加有名。我想這句話的前提是,要么住在城里,要么住在城外,二者只能居其一。否則想住在城里就可以住在城里,想住在城外就可以住在城外,你大可以選擇單日住在城里,雙日住在城外,也就沒有心思去想出去還是進來了。
?
理想情況是即可以住在城里又可以住在城外,而不是走向極端。盡管像青蛙一樣的兩棲動物絕不會比人類更高級,但能適應于更多環境的能力畢竟有它的優勢。技術也是如此,共享內存和線程局部存儲就是實例,它們是為了防止走向內存完全隔離和完全共享兩個極端的產物。
?
當我們發明了MMU時,大家認為天下太平了,各個進程空間獨立,互不影響,程序的穩定性將大提高。但馬上又認識到,進程完全隔離也不行,因為各個進程之間需要信息共享。于是就搞出一種稱為共享內存的東西。
?
當我們發明了線程的時,大家認為這下可爽了,線程可以并發執行,創建和切換的開銷相對進程來說小多了。線程之間的內存是共享的,線程間通信快捷又方便。但馬上又認識到,有些信息還是不共享為好,應該讓各個線程保留一點隱私。于是就搞出一個線程局部存儲的玩意兒。
?
共享內存和線程局部存儲是兩個重要又不常用的東西,平時很少用,但有時候又離不了它們。本文介紹將兩者的概念、原理和使用方法,把它們放在自己的工具箱里,以供不時之需。
?
1.?
大家都知道進程空間是獨立的,它們之間互不影響。比如同是0xabcd1234地址的內存,在不同的進程中,它們的數據是不同的,沒有關系的。這樣做的好處很多:每個進程的地址空間變大了,它們獨占4G(32位)的地址空間,讓編程實現更容易。各個進程空間獨立,一個進程死掉了,不會影響其它進程,提高了系統的穩定性。
?
要做到進程空間獨立,光靠軟件是難以實現的,通常要依賴于硬件的幫助。這種硬件通常稱為MMU(Memory Manage Unit),即所謂的內存管理單元。在這種體系結構下,內存分為物理內存和虛擬內存兩種。物理內存就是實際的內存,你機器上裝了多大內存就有多大內存。而應用程序中使用的是虛擬內存,訪問內存數據時,由MMU根據頁表把虛擬內存地址轉換對應的物理內存地址。
?
MMU把各個進程的虛擬內存映射到不同的物理內存上,這樣就保證了進程的虛擬內存是獨立的。然而,物理內存往往遠遠少于各個進程的虛擬內存的總和。怎么辦呢,通常的辦法是把暫時不用的內存寫到磁盤上去,要用的時候再加載回內存中來。一般會搞一個專門的分區保存內存數據,這就是所謂的交換分區。
?
這些工作由內核配合MMU硬件完成,內存管理是操作系統內核的重要功能。其中為了優化性能,使用了不少高級技術,所以內存管理通常比較復雜。比如:在決定把什么數據換出到磁盤上時,采用最近最少使用的策略,把常用的內存數據放在物理內存中,把不常用的寫到磁盤上,這種策略的假設是最近最少使用的內存在將來也很少使用。在創建進程時使用COW(Copy on Write)的技術,大大減少了內存數據的復制。為了提高從虛擬地址到物理地址的轉換速度,硬件通常采用TLB技術,把剛轉換的地址存在cache里,下次可以直接使用。
?
從虛擬內存到物理內存的映射并不是一個字節一個字節映射的,而是以一個稱為頁(page)最小單位的為基礎的,頁的大小視硬件平臺而定,通常是4K。當應用程序訪問的內存所在頁面不在物理內存中時,MMU產生一個缺頁中斷,并掛起當前進程,缺頁中斷負責把相應的數據從磁盤讀入內存中,再喚醒掛起的進程。
?
進程的虛擬內存與物理內存映射關系如下圖所示(灰色頁為被不在物理內存中的頁):
?
?
?
也許我們很少直接使用共享內存,實際上除非性能上有特殊要求,我更愿意采用socket或者管道作為進程間通信的方式。但我們常常間接的使用共享內存,大家都知道共享庫(或稱為動態庫)的優點是,多個應用程序可以公用。如果每個應用程序都加載一份共享庫到內存中,顯然太浪費了。所以操作系統把共享庫放在共享內存中,讓多個應用程序共享。另外,同一個應用程序運行多個實例時,也采用同樣的方式,保證內存中只有一份可執行代碼。這樣的共享內存是設為只讀屬性的,防止應用程序無意中破壞它們。當調試器要設置斷點時,相應的頁面被拷貝一分,設置為可寫的,再向其中寫入斷點指令。這些事情完全由操作系統等底層軟件處理了,應用程序本身無需關心。
?
共享內存是怎么實現的呢?我們來看看下圖(黃色頁為共享內存):
?
?
?
由上圖可見,實現共享內存非常容易,只是把兩個進程的虛擬內存映射同一塊物理內存就行了。不過要注意,物理內存相同而虛擬地址卻不一定相同,如圖中所示進程1的page5和進程2的page2都映射到物理內存的page1上。
?
如何在程序中使用共享內存呢?通常很簡單,操作系統或者函數庫提供了一些API給我們使用。如:
?
Linux:
?
void * mmap(void *start, size_t length, int prot , int flags, int fd, off_t offset); int munmap(void *start, size_t length); |
?
Win32:
?
HANDLE CreateFileMapping(
? ? ? ? ? ? );
BOOL UnmapViewOfFile(
? );
|
?
2.?
同一個進程中的多個線程,它們的內存空間是共享的(棧除外),在一個線程修改的內存內容,對所有線程都生效。這是一個優點也是一個缺點。說它是優點,線程的數據交換變得非常快捷。說它是缺點,一個線程死掉了,其它線程也性命不保;多個線程訪問共享數據,需要昂貴的同步開銷,也容易造成同步相關的BUG;。
?
在unix下,大家一直都對線程不是很感興趣,直到很晚以后才引入線程這東西。像X Sever要同時處理N個客戶端的連接,每秒鐘要響應上百萬個請求,開發人員寧愿自己實現調度機制也不用線程。讓人很難想象X Server是單進程單線程模型的。再如Apache(1.3x),在unix下的實現也是采用多進程模型的,把像記分板等公共信息放入共享內存中,也不愿意采用多線程模型。
?
正如《unix編程藝術》中所說,線程局部存儲的出現,使得這種情況出現了轉機。采用線程局部存儲,每個線程有一定的私有空間。這可以避免部分無意的破壞,不過仍然無法避免有意的破壞行為。
?
個人認為,這完全是因為unix程序不喜歡面向對象方法引起的,數據沒有很好的封裝起來,全局變量滿天飛,在多線程情況下自然容易出問題。如果采用面向對象的方法,可以讓這種情況大為改觀,而無需要線程局部存儲來幫忙。
?
當然,多一種技術就多一種選擇,知道線程局部存儲還是有用的。盡管只用過幾次線程局部存儲的方法,在那種情況下,沒有線程局部存儲,確實很難用其它辦法實現。
?
線程局部存儲在不同的平臺有不同的實現,可移植性不太好。幸好要實現線程局部存儲并不難,最簡單的辦法就是建立一個全局表,通過當前線程ID去查詢相應的數據,因為各個線程的ID不同,查到的數據自然也不同了。
?
大多數平臺都提供了線程局部存儲的方法,無需要我們自己去實現:
?
linux:
?
方法一: int pthread_key_create(pthread_key_t *key, void (*destructor)(void*)); int pthread_key_delete(pthread_key_t key); void *pthread_getspecific(pthread_key_t key); int pthread_setspecific(pthread_key_t key, const void *value); 方法二: __thread int i; |
Win32
?
方法一: DWORD TlsAlloc(VOID); BOOL TlsFree( ? ); BOOL TlsSetValue( ? ? ); LPVOID TlsGetValue( ? ); 方法二:
|
?
~~end~~
進階5:
常見內存錯誤
?
?
隨著諸如代碼重構和單元測試等方法引入實踐,調試技能漸漸弱化了,甚至有人主張廢除調試器。這是有道理的,原因在于調試的代價往往太大了,特別是調試系統集成之后的BUG,一個BUG花了幾天甚至數周時間并非罕見。
?
而這些難以定位的BUG基本上可以歸為兩類:內存錯誤和并發問題。而又以內存錯誤最為普遍,即使是久經沙場的老手,也有時也難免落入陷阱。前事不忘,后世之師,了解這些常見的錯誤,在編程時就加以注意,把出錯的概率降到最低,可以節省不少時間。
?
這些列舉一些常見的內存錯誤,供新手參考。
?
1.?
大家都知道,在堆上分配的內存,如果不再使用了,應該把它釋放掉,以便后面其它地方可以重用。在C/C++中,內存管理器不會幫你自動回收不再使用的內存。如果你忘了釋放不再使用的內存,這些內存就不能被重用,就造成了所謂的內存泄露。
?
把內存泄露列為首位,倒并不是因為它有多么嚴重的后果,而因為它是最為常見的一類錯誤。一兩處內存泄露通常不至于讓程序崩潰,也不會出現邏輯上的錯誤,加上進程退出時,系統會自動釋放該進程所有相關的內存,所以內存泄露的后果相對來說還是比較溫和的。當然了,量變會產生質變,一旦內存泄露過多以致于耗盡內存,后續內存分配將會失敗,程序可能因此而崩潰。
?
現在的PC機內存夠大了,加上進程有獨立的內存空間,對于一些小程序來說,內存泄露已經不是太大的威脅。但對于大型軟件,特別是長時間運行的軟件,或者嵌入式系統來說,內存泄露仍然是致命的因素之一。
?
不管在什么情況下,采取比較謹慎的態度,杜絕內存泄露的出現,都是可取的。相反,認為內存有的是,對內存泄露放任自流都不是負責的。盡管一些工具可以幫助我們檢查內存泄露問題,我認為還是應該在編程時就仔細一點,及早排除這類錯誤,工具只是用作驗證的手段。
?
2.?
內存越界訪問有兩種:一種是讀越界,即讀了不屬于自己的數據,如果所讀的內存地址是無效的,程度立刻就崩潰了。如果所讀內存地址是有效的,在讀的時候不會出問題,但由于讀到的數據是隨機的,它會產生不可預料的后果。另外一種是寫越界,又叫緩沖區溢出。所寫入的數據對別人來說是隨機的,它也會產生不可預料的后果。
?
內存越界訪問造成的后果非常嚴重,是程序穩定性的致命威脅之一。更麻煩的是,它造成的后果是隨機的,表現出來的癥狀和時機也是隨機的,讓BUG的現象和本質看似沒有什么聯系,這給BUG的定位帶來極大的困難。
?
一些工具可以夠幫助檢查內存越界訪問的問題,但也不能太依賴于工具。內存越界訪問通常是動態出現的,即依賴于測試數據,在極端的情況下才會出現,除非精心設計測試數據,工具也無能為力。工具本身也有一些限制,甚至在一些大型項目中,工具變得完全不可用。比較保險的方法還是在編程是就小心,特別是對于外部傳入的參數要仔細檢查。
?
3.?
野指針是指那些你已經釋放掉的內存指針。當你調用free(p)時,你真正清楚這個動作背后的內容嗎?你會說p指向的內存被釋放了。沒錯,p本身有變化嗎?答案是p本身沒有變化。它指向的內存仍然是有效的,你繼續讀寫p指向的內存,沒有人能攔得住你。
?
釋放掉的內存會被內存管理器重新分配,此時,野指針指向的內存已經被賦予新的意義。對野指針指向內存的訪問,無論是有意還是無意的,都為此會付出巨大代價,因為它造成的后果,如同越界訪問一樣是不可預料的。
?
釋放內存后立即把對應指針置為空值,這是避免野指針常用的方法。這個方法簡單有效,只是要注意,當然指針是從函數外層傳入的時,在函數內把指針置為空值,對外層的指針沒有影響。比如,你在析構函數里把this指針置為空值,沒有任何效果,這時應該在函數外層把指針置為空值。
?
4.?
空指針在C/C++中占有特殊的地址,通常用來判斷一個指針的有效性。空指針一般定義為0。現代操作系統都會保留從0開始的一塊內存,至于這塊內存有多大,視不同的操作系統而定。一旦程序試圖訪問這塊內存,系統就會觸發一個異常。
?
操作系統為什么要保留一塊內存,而不是僅僅保留一個字節的內存呢?原因是:一般內存管理都是按頁進行管理的,無法單純保留一個字節,至少要保留一個頁面。保留一塊內存也有額外的好處,可以檢查諸如p=NULL; p[1]之類的內存錯誤。
?
在一些嵌入式系統(如arm7)中,從0開始的一塊內存是用來安裝中斷向量的,沒有MMU的保護,直接訪問這塊內存好像不會引發異常。不過這塊內存是代碼段的,不是程序中有效的變量地址,所以用空指針來判斷指針的有效性仍然可行。
?
在訪問指針指向的內存時,在確保指針不是空指針。訪問空指針指向的內存,通常會導致程度崩潰,或者不可預料的錯誤。
?
5.?
未初始化變量的內容是隨機的(像VC一類的編譯器會把它們初始化為固定值,如0xcc),使用這些數據會造成不可預料的后果,調試這樣的BUG也是非常困難的。
?
對于態度嚴謹的程度員來說,防止這類BUG非常容易。在聲明變量時就對它進行初始化,是一個編程的好習慣。另外也要重視編譯器的警告信息,發現有引用未初始化的變量,立即修改過來。
?
6.?
對于一些新手來說,指針常常讓他們犯糊涂。
?
比如int *p = …; p+1等于(size_t)p + 1嗎
老手自然清楚,新手可能就搞不清了。事實上, p+n等于 (size_t)p + n * sizeof(*p)
?
指針是C/C++中最有力的武器,功能非常強大,無論是變量指針還是函數指針,都應該掌握都非常熟練。只要有不確定的地方,馬上寫個小程序驗證一下。對每一個細節都了然于胸,在編程時會省下不少時間。
?
7.?
在初始化一個結構時,老手可能很少像新手那樣老老實實的,一個成員一個成員的為結構初始化,而是采用快捷方式,如:
?
Structs { ? ? }; ? intmain(intargc,char*argv[]) { ? ? } |
?
以上這種方式是非常危險的,原因在于你對結構的內存布局作了假設。如果這個結構是第三方提供的,他很可能調整結構中成員的相對位置。而這樣的調整往往不會在文檔中說明,你自然很少去關注。如果調整的兩個成員具有相同數據類型,編譯時不會有任何警告,而程序的邏輯上可能相距十萬八千里了。
?
正確的初始化方法應該是(當然,一個成員一個成員的初始化也行):
?
structs { ? ? }; ? intmain(intargc,char*argv[]) { ? ? ? ? } |
?
8.?
我們看看下面這個例子:
?
structbase { ? }; ? structs { ? ? }; |
?
在OOP中,我們可以認為第二個結構繼承了第一結構,這有什么問題嗎?當然沒有,這是C語言中實現繼承的基本手法。
?
現在假設第一個結構是第三方提供的,第二個結構是你自己的。第三方提供的庫是以DLL方式分發的,DLL最大好處在于可以獨立替換。但隨著軟件的進化,問題可能就來了。
?
當第三方在第一個結構中增加了一個新的成員int k;,編譯好后把DLL給你,你直接給了客戶了。程序加載時不會有任何問題,在運行邏輯可能完全改變!原因是兩個結構的內存布局重疊了。解決這類錯誤的唯一辦法就是全部重新相關的代碼。
?
解決這類錯誤的唯一辦法就是重新編譯全部代碼。由此看來,DLL并不見得可以動態替換,如果你想了解更多相關內容,建議閱讀《COM本質論》。
?
9.?
大家都知道malloc要和free配對使用,new要和delete/delete[]配對使用,重載了類new操作,應該同時重載類的delete/delete[]操作。這些都是書上反復強調過的,除非當時暈了頭,一般不會犯這樣的低級錯誤。
?
而有時候我們卻被蒙在鼓里,兩個代碼看起來都是調用的free函數,實際上卻調用了不同的實現。比如在Win32下,調試版與發布版,單線程與多線程是不同的運行時庫,不同的運行時庫使用的是不同的內存管理器。一不小心鏈接錯了庫,那你就麻煩了。程序可能動則崩潰,原因在于在一個內存管理器中分配的內存,在另外一個內存管理器中釋放時出現了問題。
?
10.?
大家都知道,棧里面的變量都是臨時的。當前函數執行完成時,相關的臨時變量和參數都被清除了。不能把指向這些臨時變量的指針返回給調用者,這樣的指針指向的數據是隨機的,會給程序造成不可預料的后果。
?
下面是個錯誤的例子:
?
char* get_str(void) { ? ? ? } ? intmain(intargc, char* argv[]) { ? ? ? ? ? } ? |
?
下面這個例子沒有問題,大家知道為什么嗎?
?
char*get_str(void) { ? ? ? } ? intmain(intargc,char*argv[]) { ? ? ? ? ? } |
?
11.?
在函數參數前加上const修飾符,只是給編譯器做類型檢查用的,編譯器禁止修改這樣的變量。但這并不是強制的,你完全可以用強制類型轉換繞過去,一般也不會出什么錯。
?
而全局常量和字符串,用強制類型轉換繞過去,運行時仍然會出錯。原因在于它們是是放在.rodata里面的,而.rodata內存頁面是不能修改的。試圖對它們修改,會引發內存錯誤。
?
下面這個程序在運行時會出錯:
?
intmain(intargc,char*argv[]) { ? ? ? ? ? } |
?
?
12.?
在C/C++中,參數默認傳遞方式是傳值的,即在參數入棧時被拷貝一份。在函數里修改這些參數,不會影響外面的調用者。如:
?
?
#include <stdlib.h> #include <stdio.h> ? void get_str(char* p) { ? ? ? ? } ? int main(int argc, char* argv[]) { ? ? ? ? ? ? ? } |
?
在main函數里,p的值仍然是空值。
?
13.?
無論是函數名還是變量名,如果在不同的作用范圍內重名,自然沒有問題。但如果兩個符號的作用域有交集,如全局變量和局部變量,全局變量與全局變量之間,重名的現象一定要堅決避免。gcc有一些隱式規則來決定處理同名變量的方式,編譯時可能沒有任何警告和錯誤,但結果通常并非你所期望的。
?
下面例子編譯時就沒有警告:
t.c
?
#include<stdlib.h> #include<stdio.h> ? intcount = 0; ? intget_count(void) { ? } ? |
?
main.c
?
#include <stdio.h> ? extern int get_count(void); ? int count; ? int main(int argc, char* argv[]) { ? ? ? ? ? } |
?
如果把main.c中的int count;修改為int count = 0;,gcc就會編輯出錯,說multiple definition of `count'。它的隱式規則比較奇妙吧,所以還是不要依賴它為好。
?
14.?
我們在前面關于堆棧的一節講過,在PC上,普通線程的棧空間也有十幾M,通常夠用了,定義大一點的臨時變量不會有什么問題。
?
而在一些嵌入式中,線程的棧空間可能只5K大小,甚至小到只有256個字節。在這樣的平臺中,棧溢出是最常用的錯誤之一。在編程時應該清楚自己平臺的限制,避免棧溢出的可能。
?
15.?
盡管C/C++通常是按值傳遞參數,而數組則是例外,在傳遞數組參數時,數組退化為指針(即按引用傳遞),用sizeof是無法取得數組的大小的。
?
從下面這個例子可以看出:
?
voidtest(charstr[20]) { ? }? ? intmain(intargc,char*argv[]) { ? ? ? ? ? ? ? } |
[root@localhost mm]# ./t.exe
test:size=4
main:size=20
?
16.?
字節對齊主要目的是提高內存訪問的效率。但在有的平臺(如arm7)上,就不光是效率問題了,如果不對齊,得到的數據是錯誤的。
?
所幸的是,大多數情況下,編譯會保證全局變量和臨時變量按正確的方式對齊。內存管理器會保證動態內存按正確的方式對齊。要注意的是,在不同類型的變量之間轉換時要小心,如把char*強制轉換為int*時,要格外小心。
?
另外,字節對齊也會造成結構大小的變化,在程序內部用sizeof來取得結構的大小,這就足夠了。若數據要在不同的機器間傳遞時,在通信協議中要規定對齊的方式,避免對齊方式不一致引發的問題。
?
17.?
字節順序歷來是設計跨平臺軟件時頭疼的問題。字節順序是關于數據在物理內存中的布局的問題,最常見的字節順序有兩種:大端模式與小端模式。
?
大端模式是高位字節數據存放在低地址處,低位字節數據存放在高地址處。
小端模式指低位字節數據存放在內存低地址處,高位字節數據存放在內存高地址處;
?
?
?
?
模式 | 第1個字節 | 第2個字節 | 第3個字節 | 第4個字節 |
大端模式 | 0x11 | 0x22 | 0x33 | 0x44 |
小端模式 | 0x44 | 0x33 | 0x22 | 0x11 |
?
在普通軟件中,字節順序問題并不引人注目。而在開發與網絡通信和數據交換有關的軟件時,字節順序問題就要特殊注意了。
?
18.?
在關于全局內存的一節中,我們講了valotile的作用,它告訴編譯器,不要把變量優化到寄存器中。在開發多線程并發的軟件時,如果這些線程共享一些全局變量,這些全局變量最好用valotile修飾。這樣可以避免因為編譯器優化而引起的錯誤,這樣的錯誤非常難查。
?
可能還有其它一些內存相關錯誤,一時想不全面,這里算是拋磚引玉吧,希望各位高手補充。
進階6:
《POSA》中根據模式粒度把模式分為三類:架構模式、設計模式和慣用手法。其中把分層模式、管道過濾器和微內核模式等歸為架構模式,把代理模式、命令模式和出版-訂閱模式等歸為設計模式,而把引用計數等歸為慣用手法。這三類模式間的界限比較模糊,在特定的情況,有的設計模式可以作為架構模式來用,有的把架構模式也作為設計模式來用。
?
在通常情況下,我們可以說架構模式、設計模式和慣用手法,三者的重要性依次遞減,畢竟整體決策比局部決策的影響面更大。但是任何整體都是局部組成的,局部的決策也會影響全局。慣用手法的影響雖然是局部的,其作用仍然很重要。它不但在提高軟件的質量方面,而且在加快軟件開發進度方面都有很大貢獻。本文介紹幾種關于內存的慣用手法,這些手法對于老手來說已經習以為常,對于新手來說則是必修秘技。
?
1.?
假想我們實現了一個動態數組(vector)時,當向其中增加元素時,它會自動擴展(縮減)緩沖區的大小,無需要調用者關心。擴展緩沖區的大小的原理都是一樣的:
?
l?
l?
l?
?
如果你使用realloc來實現,內存管理器可能會做些優化:如果老的緩沖區后面有連續的空閑空間,它只需要簡單的擴展老的緩沖區,而跳過后面兩個步驟。但在大多數情況下,它都要通過上述三個步驟來完成擴展。
?
以此可見,擴展緩沖區對調用者來說雖然是透明的,但決不是免費的。它得付出相當大的時間代價,以及由此產生的產生內存碎片問題。如果每次向vector中增加一個元素,都要擴展緩沖區,顯然是不太合適的。
?
此時我們可以采用預分配機制,每次擴展時,不是需要多大就擴展多大,而是預先分配一大塊內存。這一大塊可以供后面較長一段時間使用,直到把這塊內存全用完了,再繼續用同樣的方式擴展。
?
預分配機制比較常見,多見于一些帶buffer的容器實現中,比如像vector和string等。
?
2.?
在面向對象的系統中,對象之間的協作關系非常復雜。所謂協作其實就調用對象的函數或者向對象發送消息,但不管調用函數還是發送消息,總是要通過某種方式知道目標對象才行。而最常見的做法就是保存目標對象的引用(指針),直接引用對象而不是拷貝對象,提高了時間和空間上的效率,也避免了拷貝對象的麻煩,而且有的地方就是要對象共享才行。
?
對象被別人引用了,但自己可能并不知道。此時麻煩就來了,如果對象被釋放了,對該對象的引用就變成了野針,系統隨時可能因此而崩潰。不釋放也不行,因為那樣會出現內存泄露。怎么辦呢?
?
此時我們可以采用對象引用計數,對象有一個引用計數器,不管誰要引用這個對象,就要把對象的引用計數器加1,如果不再該引用了,就把對象的引用計數器減1。當對象的引用計數器被減為0時,說明沒有其它對象引用它,該對象就可以安全的釋放了。這樣,對象的生命周期就得到了有效的管理。
?
對象引用計數運用相當廣泛。像在COM和glib里,都是作為對象系統的基本設施之一。即使在像JAVA和C#等現代語言中,對象引用計數也是非常重要的,它是實現垃圾回收(GC)的基本手段之一。
?
代碼示例: (atlcom.h: CcomObject)
?
? ? ? ? ? ? ? ? |
?
3.?
OS內核創建子進程的過程是最常見而且最有效的COW例子:創建子進程時,子進程要繼承父進程內存空間中的數據。但繼承之后,兩者各自有獨立的內存空間,修改各自的數據不會互相影響。
?
要做到這一點,最簡單的辦法就是直接把父進程的內存空間拷貝一份。這樣做可行,但問題在于拷貝內容太多,無論是時間還是空間上的開銷都讓人無法接受。況且,在大多數情況下,子進程只會使用少數繼承過來的數據,而且多數是讀取,只有少量是修改,也就說大部分拷貝的動作白做了。怎么辦呢?
?
此時可以采用寫時拷貝(COW),COW代表Copy on Write。最初的拷貝只是個假象,并不是真正的拷貝,只是把引用計數加1,并設置適當的標志。如果雙方都只是讀取這些數據,那好辦,直接讀就行了。而任何一方要修改時,為了不影響另外一方,它要把數據拷貝一份,然后修改拷貝的這一份。也就是說在修改數據時,拷貝動作才真正發生。
?
當然,在真正拷貝的時候,你可以選擇只拷貝修改的那一部分,或者拷貝全部數據。在上面的例子中,由于內存通常是按頁面來管理的,拷貝時只拷貝相關的頁面,而不是拷貝整個內存空間。
?
寫時拷貝(COW)對性能上的貢獻很大,差不多任何帶MMU的OS都會采用。當然它不限于內核空間,在用戶空間也可以使用,比如像一些String類的實現也采用了這種方法。
?
代碼示例(MFC:strcore.cpp):
拷貝時只是增加引用計數:
?
CString::CString(constCString&stringSrc) { ? ? ? ? ? ? ? ? ? ? ? ? } |
?
修改前才拷貝:
?
voidCString::MakeUpper() { ? ? } ? voidCString::MakeLower() { ? ? } ? |
?
拷貝動作:
?
voidCString::CopyBeforeWrite() { ? ? ? ? ? ? ? ? } |
?
?
4.?
頻繁的分配大量小塊內存是內存管理器的挑戰之一。
?
首先是空間利用率上的問題:由于內存管理本身的需要一些輔助內存,假設每塊內存需要8字節用作輔助內存,那么即使只要分配4個字節這樣的小塊內存,仍然要浪費8字節內存。一塊小內存不要緊,若存在大量小塊內存,所浪費的空間就可觀了。
?
其次是內存碎片問題:頻繁分配大量小塊內存,很容易造成內存碎片問題。這不但降低內存管理器的效率,同時由于這些內存不連續,雖然空閑卻無法使用。
?
此時可以采用固定大小分配,這種方式通常也叫做緩沖池(pool)分配。緩沖池(pool)先分配一塊或者多塊連續的大塊內存,把它們分成N塊大小相等的小塊內存,然后進行二次分配。由于這些小塊內存大小是固定的,管理大開銷非常小,往往只要一個標識位用于標識該單元是否空閑,或者甚至不需要任何標識位。另外,緩沖池(pool)中所有這些小塊內存分布在一塊或者幾塊連接內存上,所以不會有內存碎片問題。
?
固定大小分配運用比較廣泛,差不多所有的內存管理器都用這種方法來對付小塊內存,比如glibc、STLPort和linux的slab等。
?
5.?
服務器要長時間運行,內存泄露是它的威脅之一,任何小概率的內存泄露,都可能會累積到具有破壞性的程度。從它們的運行模式來看,它們總是不斷的重復某個過程,而在這個過程中,又要分配大量(次數)內存。
?
比如像WEB服務器,它不斷的處理HTTP請求,我們把一次HTTP請求,稱為一次會話。一次會話要經過很多階段,在這個過程要做各種處理,要多次分配內存。由于處理比較復雜,分配內存的地方又比較多,內存泄露可以說防不甚防。
?
針對這種情況,我們可以采用會話緩沖池分配。它基于多次分配一次釋放的策略,在過程開始時創建會話緩沖池(Session Pool),這個過程中所有內存分配都通過會話緩沖池(Session Pool)來分配,當這個過程完成時,銷毀掉會話緩沖池(Session Pool),即釋放這個過程中所分配的全部內存。
?
因為只需要釋放一次,內存泄露的可能大大降低。會話緩沖池分配并不是太常見,apache采用的這種用法。后來自己用過兩次,感覺效果不錯。
?
當然還有其一些內存慣用手法,如cache等,這里不再多說。上述部分手法在《實時設計模式》里有詳細的描述,大家可以參考一下。
進階7:
調試手段及原理
?
?
?
?
本文將從應用程序、編譯器和調試器三個層次來講解,在不同的層次,有不同的方法,這些方法有各自己的長處和局限。了解這些知識,一方面滿足一下新手的好奇心,另一方面也可能有用得著的時候。
?
從應用程序的角度
?
最好的情況是從設計到編碼都扎扎實實的,避免把錯誤引入到程序中來,這才是解決問題的根本之道。問題在于,理想情況并不存在,現實中存在著大量有內存錯誤的程序,如果內存錯誤很容易避免,JAVA/C#的優勢將不會那么突出了。
?
對于內存錯誤,應用程序自己能做的非常有限。但由于這類內存錯誤非常典型,所占比例非常大,所付出的努力與所得的回報相比是非常劃算的,仍然值得研究。
?
前面我們講了,堆里面的內存是由內存管理器管理的。從應用程序的角度來看,我們能做到的就是打內存管理器的主意。其實原理很簡單:
?
對付內存泄露。重載內存管理函數,在分配時,把這塊內存的記錄到一個鏈表中,在釋放時,從鏈表中刪除吧,在程序退出時,檢查鏈表是否為空,如果不為空,則說明有內存泄露,否則說明沒有泄露。當然,為了查出是哪里的泄露,在鏈表還要記錄是誰分配的,通常記錄文件名和行號就行了。
?
對付內存越界/野指針。對這兩者,我們只能檢查一些典型的情況,對其它一些情況無能為力,但效果仍然不錯。其方法如下(源于《Comparing and contrasting the runtime error detection technologies》):
?
l?
?
Header | Leading guard(0xFC) | User data(0xEB) | Tailing guard(0xFC) |
?
在內存分配時,內存管理器按如上結構填充分配出來的內存。其中Header是管理器自己用的,前后各有幾個字節的guard數據,它們的值是固定的。當內存釋放時,內存管理器檢查這些guard數據是否被修改,如果被修改,說明有寫越界。
?
它的工作機制注定了有它的局限性:只能檢查寫越界,不能檢查讀越界,而且只能檢查連續性的寫越界,對于跳躍性的寫越界無能為力。
?
l?
?
空閑內存(0xDD) |
?
內存被釋放之后,它的內容填充成固定的值。這樣,從指針指向的內存的數據,可以大致判斷這個指針是否是野指針。
?
它同樣有它的局限:程序要主動判斷才行。如果野指針指向的內存立即被重新分配了,它又被填充成前面那個結構,這時也無法檢查出來。
?
從編譯器的角度
?
boundschecker和purify的實現都可以歸于編譯器一級。前者采用一種稱為CTI(compile-time instrumentation)的技術。VC的編譯不是要分幾個階段嗎?boundschecker在預處理和編譯兩個階段之間,對源文件進行修改。它對所有內存分配釋放、內存讀寫、指針賦值和指針計算等所有內存相關的操作進行分析,并插入自己的代碼。比如:
?
Before ? ? ? After ? ? ? ? ? ? ? ? ? ? ? |
?
Purify則采用一種稱為OCI(object code insertion)的技術。不同的是,它對可執行文件的每條指令進行分析,找出所有內存分配釋放、內存讀寫、指針賦值和指針計算等所有內存相關的操作,用自己的指令代替原始的指令。
?
boundschecker和purify是商業軟件,它們的實現是保密的,甚至擁有專利的,無法對其研究,只能找一些皮毛性的介紹。無論是CTI還是OCI這樣的名稱,多少有些神秘感。其實它們的實現原理并不復雜,通過對valgrind和gcc的bounds checker擴展進行一些粗淺的研究,我們可以知道它們的大致原理。
?
gcc的bounds checker基本上可以與boundschecker對應起來,都是對源代碼進行修改,以達到控制內存操作功能,如malloc/free等內存管理函數、memcpy/strcpy/memset等內存讀取函數和指針運算等。Valgrind則與Purify類似,都是通過對目標代碼進行修改,來達到同樣的目的。
?
Valgrind對可執行文件進行修改,所以不需要重新編譯程序。但它并不是在執行前對可執行文件和所有相關的共享庫進行一次性修改,而是和應用程序在同一個進程中運行,動態的修改即將執行的下一段代碼。
?
Valgrind是插件式設計的。Core部分負責對應用程序的整體控制,并把即將修改的代碼,轉換成一種中間格式,這種格式類似于RISC指令,然后把中間代碼傳給插件。插件根據要求對中間代碼修改,然后把修改后的結果交給core。core接下來把修改后的中間代碼轉換成原始的x86指令,并執行它。
?
由此可見,無論是boundschecker、purify、gcc的bounds checker,還是Valgrind,修改源代碼也罷,修改二進制也罷,都是代碼進行修改。究竟要修改什么,修改成什么樣子呢?別急,下面我們就要來介紹:
?
管理所有內存塊。無論是堆、棧還是全局變量,只要有指針引用它,它就被記錄到一個全局表中。記錄的信息包括內存塊的起始地址和大小等。要做到這一點并不難:對于在堆里分配的動態內存,可以通過重載內存管理函數來實現。對于全局變量等靜態內存,可以從符號表中得到這些信息。
?
攔截所有的指針計算。對于指針進行乘除等運算通常意義不大,最常見運算是對指針加減一個偏移量,如++p、p=p+n、p=a[n]等。所有這些有意義的指針操作,都要受到檢查。不再是由一條簡單的匯編指令來完成,而是由一個函數來完成。
?
有了以上兩點保證,要檢查內存錯誤就非常容易了:比如要檢查++p是否有效,首先在全局表中查找p指向的內存塊,如果沒有找到,說明p是野指針。如果找到了,再檢查p+1是否在這塊內存范圍內,如果不是,那就是越界訪問,否則是正常的了。怎么樣,簡單吧,無論是全局內存、堆還是棧,無論是讀還是寫,無一能夠逃過出工具的法眼。
?
代碼賞析(源于tcc):
對指針運算進行檢查:
?
void*__bound_ptr_add(void*p,int offset) { ? ? #ifdefined(BOUND_DEBUG) ? #endif ? ? ? ? ? ? ? ? ? ? ? ? ? ? } staticvoid __bound_check(constvoid *p,size_t size) { ? ? ? ? ? } ? |
?
重載內存管理函數:
?
void*__bound_malloc(size_tsize,const void *caller) { ? ? ? ? ? ? ? ? ? } void__bound_free(void*ptr,const void *caller) { ? ? ? ? ? ? } ? |
?
重載內存操作函數:
?
void*__bound_memcpy(void*dst,const void *src,size_t size) { ? ? ? ? ? ? } |
?
從調試器的角度
?
現在有OS的支持,實現一個調試器變得非常簡單,至少原理不再神秘。這里我們簡要介紹一下win32和linux中的調試器實現原理。
?
在Win32下,實現調試器主要通過兩個函數:WaitForDebugEvent和ContinueDebugEvent。下面是一個調試器的基本模型(源于:《Debugging Applications for Microsoft .NET and Microsoft Windows》)
?
? voidmain ( void ) { ? ? ? ? ? ? ? ? ? ? } |
?
由調試器起動被調試的進程,并指定DEBUG_ONLY_THIS_PROCESS標志。按Win32下事件驅動的一貫原則,由被調試的進程主動上報調試事件,調試器然后做相應的處理。
?
在linux下,實現調試器只要一個函數就行了:ptrace。下面是個簡單示例:(源于《Playing with ptrace》)。
?
#include<sys/ptrace.h> #include<sys/types.h> #include<sys/wait.h> #include<unistd.h> #include<linux/user.h>? intmain(intargc,char *argv[]) {? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? } |
?
由于篇幅有限,這里對于調試器的實現不作深入討論,主要是給新手指一個方向。以后若有時間,再寫個專題來介紹linux下的調試器和ptrace本身的實現方法。