C記憶體操作API的實現原理
我們在編寫C程式碼時,會使用兩種型別的記憶體,一種是棧記憶體,另外一種是堆記憶體,其中棧記憶體的申請和釋放是由編譯器來隱式管理的,我們也稱為自動記憶體,這種變數是最簡單而且最常用的,然後就是堆記憶體,堆的申請和釋放都由程式設計師顯式完成,因此使用起來也必須小心謹慎,以避免缺陷。
在C語言中通常是使用malloc/free來動態申請堆記憶體空間,所以我們有必要對malloc大致如何分配記憶體有一定的瞭解,事實上malloc/free不完全是系統呼叫,而是glibc提供的一組函式,malloc內部會涉及到brk()和mmap()這兩種系統呼叫。
那麼具體什麼時候使用brk,什麼時候使用mmap呢?其實這個取決於分配閾值mmap_threshold的定義,預設值為128K,如果每次申請分配的記憶體小於128K時,會通過brk申請記憶體,否則如果申請分配的記憶體大於或等於128K,則通過mmap申請記憶體,當然需要有可用的mmap對映區域,具體還受限於n_mmaps引數限制。
那麼說說brk,這種實現方式比較簡單,就是將使用者空間的堆頂指標向高地址移動,從而獲得新的記憶體空間,另外還有sbrk這個是通過傳入增量來移動堆頂指標,其實內部也是呼叫了brk,無論是哪種方式malloc分配的記憶體都是虛擬記憶體,並沒有建立到實體記憶體的對映,此時程序的頁表並沒有這些對映關係,當我們訪問已經分配的虛擬地址空間時,作業系統會查詢頁表,此時會引發缺頁異常,然後作業系統最終會建立起虛擬記憶體和實體記憶體之間的對映關係,就可以寫入並訪問資料了。對於brk/sbrk或者mmap時都屬於系統呼叫,具體並沒有建立對映關係,而是僅僅將虛擬空間新的地址指標更新到程序控制塊中mm_struct的標識中,僅此而已,因此這個呼叫速度也是相當快的,等後續真正訪問記憶體的時候才會逐漸建立頁表項。
可以簡單做下面的測試,例如:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(int argc, char const *argv[]) { void *ptr = malloc(8*sizeof(int)); printf("start addr: %llx\n", ptr); printf("pid: %d\n", getpid()); getchar(); free(ptr); printf("free.\n"); getchar(); return 0; }
上面用malloc申請了8個int大小的空間,在linux下程序執行時可以通過/proc/[pid]/maps檢視程序堆和棧的使用狀態,我們執行起來會首先打印出程序pid資訊,例如:
然後我們可以新開一個視窗檢視程序maps資訊:
cat /proc/667/maps
其中heap部分表示堆,前面的地址範圍就是當前程序堆的虛擬地址空間,可以看到範圍是5645b9ec0000-5645b9ee1000,大小正好是132KB,而看我們程式列印的起始地址5645b9ec02a0是正好落在這個範圍內的,但是比起始地址大了672B,這個後面再說,我們再執行一下回車讓free語句執行,然後再次以同樣的方法檢視maps檔案,我們會發現堆的大小沒有變化,也就是說記憶體並沒有真正地歸還給作業系統,而是由malloc的記憶體池進行管理,方便再次快速申請。
當申請較小的空間時,malloc會一次性向作業系統申請132KB的空間,這樣即使之後程式中再申請時也不需要發起系統呼叫了,從而提高效能,而程式即使釋放記憶體也不會真正歸還給作業系統,而是繼續放到malloc記憶體池中,下次再申請記憶體時可以直接使用,也是為了提高效能,我們可以在上面程式碼中多申請幾次記憶體,只要總量不超過128K我們會發現heap範圍仍然是132K的大小。但是當我們單次申請的記憶體超過128K時,則會通過mmap方式來申請,並且使用free釋放後記憶體就會立即歸還給作業系統,同樣可以使用上面程式碼做一下實驗,不過申請記憶體的時候需要寫128*1024或者更大的值,我們這裡申請132K的記憶體,執行起來後我們可以看到程式輸出的起始地址:
然後可以檢視對應的maps檔案:
現在我們不用關心[heap]部分,根據程式的輸出可以找到7f133604d000-7f133606f000這個地址範圍,後面沒有任何標記表示使用mmap申請的匿名記憶體,由於我們申請了132K,這裡的大小是136K,比實際的多4K,仔細看我們上面的地址最後是010,因為malloc本身使用了16個位元組儲存該記憶體塊的描述資訊,所以我們真正用地址的要向後偏移16個位元組。
然後我們執行回車後再次檢視maps檔案,就會發現剛才這行不見了:
所以申請的空間確實歸還給作業系統了。
最後可以總結下brk方式和mmap方式的異同,對於brk/sbrk呼叫方式申請的記憶體,在呼叫free釋放記憶體的時候,並不會把記憶體歸還給作業系統,而是掛到malloc記憶體池中,提供下次使用,而通過mmap申請記憶體時在釋放時,會把記憶體歸還給作業系統,從而真正地釋放實體記憶體。
其實我們也可以單獨使用brk/sbrk來申請記憶體使用:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char const *argv[])
{
printf("pid: %d\n", getpid());
intptr_t memory_size = 8 * sizeof(int);
void *ptr = sbrk(0);
printf("start addr: %llx\n", (long unsigned int) ptr);
getchar();
ptr += memory_size;
int ok = brk(ptr);
if(ok == -1) {
printf("memory allocation failed!\n");
return ok;
}
int *p = (int *) (ptr - memory_size);
for(int i = 0; i < memory_size / sizeof(int); i++) {
p[i] = i + 2;
}
for(int i = 0; i < memory_size / sizeof(int); i++) {
printf("%d ", p[i]);
}
printf("\n");
printf("end addr: %llx\n", (long unsigned int) sbrk(0));
getchar();
// free memory
sbrk(-memory_size);
getchar();
}
上面程式碼含義也比較簡單,我們執行時首先打印出當前堆的起始地址:
然後我們檢視當前程序的maps檔案:
可以看到只要程式建立,預設堆的大小就是132K,因為使用brk方式,這裡堆的結束地址就是我們的起始地址,因為brk指標就是指向堆頂的,然後我們再向下執行一步:
這時候我們會看到brk指向的位置就是起始地址加上我們申請記憶體空間的大小,也就是加了32B,然後再看maps檔案有什麼變化:
仔細看maps檔案中堆的地址後4位其實是增長到0x3000,剛好增長了4K,是因為在作業系統中記憶體分配的最小單元就是1個記憶體頁面,所以每次都會分配4K的空間,如果此時brk再往上移動,只要不超過4K我們堆的大小也是不變的,然後我們再向後執行釋放掉記憶體,具體的圖就不再截了,這時候會將記憶體歸還給作業系統,所以會發現[heap]的範圍又回到最開始的情況了。
上面是brk/sbrk的簡單使用,但是我們自己管理空間記憶體很容易出錯,所以我們只需要瞭解下原理,在實際使用的時候仍然使用malloc/free進行操作即可,那麼使用brk/sbrk方式有什麼優勢呢?
我們知道初次分配記憶體如果不使用的話,那麼是不會真正在物理空間中建立頁面以及建立頁表項的,只有當使用頁面時會觸發缺頁異常,作業系統會處理該異常即尋找空閒物理空間並新增頁表項,然後回到原來的程式碼繼續執行,這時候才可以向記憶體中寫入資料,由於使用brk申請的記憶體,後續不會歸還給作業系統,那麼這塊記憶體只會觸發一次缺頁異常,後續可以重複利用,因此當頻繁進行申請和釋放時存在很大的效能優勢,雖然brk也屬於系統呼叫,但是如果釋放中間部分的記憶體,brk指標不移動,那麼由malloc管理空閒地址連結串列,所以也就不會進行系統呼叫。而mmap每次都要執行系統呼叫,進行使用者態和核心態的切換,釋放記憶體會真正移除所有的頁表項,每次申請記憶體後使用都會發生缺頁異常,所以不適合頻繁分配記憶體的場景,同時會消耗過多的CPU,而brk方式就比較快而且輕量,這些都是brk相對於mmap的優勢。
我們可以使用下面一段程式碼來測試brk/sbrk和mmap之間的效能差距:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <sys/mman.h>
#define MEM_LEN 32 * 1024
int main(int argc, char const *argv[])
{
struct timeval s, e;
double delta;
gettimeofday(&s, NULL);
// malloc分配記憶體
for(int i = 0; i < 100000; i++) {
void *ptr = malloc(MEM_LEN);
// memset(ptr, 0, MEM_LEN);
free(ptr);
}
gettimeofday(&e, NULL);
delta = (e.tv_sec - s.tv_sec) + (e.tv_usec - s.tv_usec) / 1e6;
printf("brk/sbrk time: %lfs\n", delta);
gettimeofday(&s, NULL);
// malloc分配記憶體
for(int i = 0; i < 100000; i++) {
void *ptr = mmap(NULL, MEM_LEN,
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANON,
-1, 0);
if(ptr == MAP_FAILED) {
printf("mmap error!\n");
return -1;
}
// memset(ptr, 0, MEM_LEN);
if(munmap(ptr, MEM_LEN) == -1) {
printf("munmap error!\n");
return -1;
}
}
gettimeofday(&e, NULL);
delta = (e.tv_sec - s.tv_sec) + (e.tv_usec - s.tv_usec) / 1e6;
printf("mmap time: %lfs\n", delta);
return 0;
}
上面我們分別使用malloc/free和mmap/munmap來建立大小相同的32KB記憶體,反覆分配並釋放10萬次,統計對應的時間:
可以發現brk方式耗時僅3.6ms,而mmap耗時0.20秒,這個耗時的差距在於mmap每次都需要發起系統呼叫修改程序資訊,而brk只需要呼叫1次,注意程式碼不要開優化,否則malloc那裡時間是0,這樣看在當前機器環境下每次mmap或munmap的呼叫在1微秒以內。
如果我們註釋掉上面的memset程式碼會發現時間差距更明顯:
那麼這時候時間差距不僅是系統呼叫,還包括缺頁異常的處理時間,所以mmap明顯更慢了,注意使用mmap建立記憶體的大小必須是頁面大小的整數倍,這裡相當於建立了8個頁面,根據時間開銷看當前機器環境下每次缺頁異常的處理時間大致在2微秒左右。
那麼brk/sbrk相對於mmap有什麼劣勢呢?
因為brk分配的記憶體大多都是非常小的塊,如果頻繁無規律的申請以及釋放,會產生大量的記憶體碎片,而且更容易導致記憶體洩露,用valgrind之類的工具也無法檢測,碎片過多可能會影響系統中其他程序的執行,可能會引起不穩定,所以malloc中閾值設定為128K也是一種折中的考慮。
根據上面的原理,我們可以總結下日常開發中記憶體使用上面的一些小技巧:
-
如果我們需要一些比較小的空間,那麼可以多次申請或者釋放,並且同一塊記憶體的申請和釋放盡量連續中間不要穿插其他記憶體的申請和釋放,以保證重複利用,也就是說不要多塊記憶體交叉申請及釋放,以防止出現過多的記憶體碎片,而且用完及時釋放掉歸還給malloc,下次用再申請即可。
-
如果我們需要大塊的記憶體時,最好一次申請,後續多次複用,直到不用的時候再釋放,不要在迴圈內頻繁申請和釋放大塊記憶體,降低CPU的消耗。
-
雖然brk/sbrk也可以用來申請記憶體,但是容易出錯,所以堅持使用標準的malloc/free。
-
不要忘記為指標分配記憶體,否則在向空指標拷貝記憶體時會出現段錯誤(segmentation fault)。
-
要為資料分配足夠的記憶體,如果資料長度大於分配的記憶體長度,會出現緩衝區溢位,雖然可能不會報錯,但是會出現很多意想不到的結果。
-
分配的記憶體讀之前要初始化,雖然用malloc正確分配了記憶體,但是如果沒有寫入直接讀取可能會讀到一些異常或者有害的值,同樣導致莫名奇妙的結果,所以請一定先填入正確的值再讀取,或者使用memset填充固定的值。
最後,感謝您的耐心閱讀,如有錯誤歡迎指正!