[譯] C程序員該知道的內存知識 (2)

續上篇:html

這是本系列的第二篇,預計還會有2篇,感興趣的同窗記得關注,以便接收推送,等不及的推薦閱讀原文。node


先放圖鎮樓: linux

linuxFlexibleAddressSpaceLayout.png

來源:Linux地址空間佈局 - by Gustavo Duartegit

關於圖片的解釋可參見上篇。程序員

開始吧。github


 理解堆上的內存分配

工具箱:面試

  • brk(), sbrk() - 修改數據段的大小
  • malloc() 家族 - 可移植的 libc 內存分配器

堆上的內存分配,最簡單的實現能夠是修改 program break[2](譯註:參見上圖中部右側)的上界,申請對原位置和新位置之間內存的訪問權限。若是就這麼搞的話,堆上內存分配和棧上分配同樣快(除了換頁的開銷,通常咱們認爲棧是被鎖定在內存中;譯註:指棧不會被換出到磁盤,能夠用mlock限制OS對一段地址空間使用swap)。但這裏仍是有隻貓(cat),我是說,有點毛病(catch),見鬼。(譯tu注cao:這個真難翻)c#

char *block = sbrk(1024 * sizeof(char));
  • 咱們沒法回收再也不使用的內存塊
  • 它也不是線程安全的,由於堆是在線程間共享的
  • 這個接口(譯註:sbrk)也很難移植,所以庫函數被禁止碰這個break。
man 3 sbrk — 各類系統的 sbrk 使用多種不一樣的參數類型,常見的包括 int, ssize_t, ptrdiff_t, intptr_t

因爲這些問題,libc要求實現統一的內存分配接口,具體實現有不少[3](譯註:例如glibc,jemalloc,tcmalloc等),但都給你提供了線程安全、支持任意尺寸的內存分配器……只是要付出點代價——延遲,由於得引入鎖機制,以及用來維護已用/可用內存的數據結構,和額外的內存開銷。還有,堆也不是惟一的選項,在大塊內存分配的狀況下經常也會用內存映射段(Memory Mapping Segment,MMS)。segmentfault

man 3 malloc —— 通常來講,malloc() 從堆上分配內存,  ... 當分配的內存塊大於 MMAP_THRESHOLD 時,glibc的 malloc() 實現使用私有匿名映射來分配內存。

(譯註:Linux 下 mmap 的 flags 參數是個 bitmap,其中有兩個 bit 分別爲 MAP_PRIVATE、MAP_ANONYMOUS,對應引用中提到的「私有」、「匿名」) api

因爲堆空間在 start_brk 和 brk (譯註:即 heap 的下界和上界,建議對照圖中右側的標註)之間老是連續的(譯註:這裏指的是虛擬地址空間連續,但其中每一頁均可能映射到任一物理頁),所以你沒法在中間打個洞來減小數據段的尺寸。好比這個場景:

char *truck = malloc(1024 * 1024 * sizeof(char));
char *bike  = malloc(sizeof(char));
free(truck);

堆分配器將會調大 brk,以便給 truck 騰出空間。對於 bike 也同樣。可是當 truck 被釋放後,brk 不能被調小,由於 bike 正佔着高位的地址。結果是,你的進程能夠重用 truck 的內存,但不能退還給OS,除非 bike 也被釋放。固然你也能夠用 mmap 來分配 truck 所需的空間,不放在堆內存段裏,就能夠不影響 program break ,但這仍然沒法解決分配小塊內存致使的空洞(換句話說就是「引發碎片化」)。

注意 free() 並不老是會縮小數據段,由於這是一個有很大潛在開銷的操做(參見後文「對按需調頁的解釋」)。對於須要長時間運行的程序(例如守護進程)來講這會是個問題。有個叫  malloc_trim() 的 GNU 擴展能夠用來從堆頂釋放內存,但可能會慢得使人蛋疼,尤爲對於大量小對象的狀況,因此應該儘可能少用。

何時應該使用自定義分配器

有一些實際場景中通用分配器有短板,例如大量分配固定尺寸的小內存。這看起來不像是典型的場景,但實際上出現得很頻繁 。例如,用於查找的數據結構(典型如樹、字典樹)須要分配大量節點用於構造其層次結構。在這個場景下,不只碎片化會是個問題,數據的局部性也是。cache效率高的數據結構會將key放在一塊兒(最好在同一個內存頁),而不是和數據混在一塊兒。默認的分配器不能保證下次分配時還在同一個block,更糟的是分配小單元的額外空間開銷。解決辦法在此:

X

來源: Slab by wadem, on Flickr (CC-BY-SA)

(譯註:原圖沒法打開了,另貼一張)

slab.gif
來源:IBM - Linux slab 分配器剖析

Slab分配器

工具箱:

  • posix_memalign() - 分配對齊的內存

Bonwick 爲內核對象緩存寫的這篇文章[4]介紹了 slab 分配器的原理,也可用於用戶空間。Okay,咱們對綁定在CPU上的 slab 不感興趣 —— 就是你找分配器要一塊內存,例如說一整頁,而後切成不少固定大小的小塊。若是每一個小塊都能保存至少一個指針或一個整數,你就能夠把他們串成一個鏈表 ,表頭指向第一個空閒元素。

/* Super-simple slab. */
struct slab {
    void **head;
};

/* Create page-aligned slab */
struct slab *slab = NULL;
posix_memalign(&slab, page_size, page_size);
slab->head = (void **)((char*)slab + sizeof(struct slab));

/* Create a NULL-terminated slab freelist */
char* item = (char*)slab->head;
for(unsigned i = 0; i < item_count; ++i) {
    *((void**)item) = item + item_size;
    item += item_size;
}
*((void**)item) = NULL;

譯註:

  1. 代碼裏用的二級指針可能讓人有點暈,這是Linus推崇的鏈表實現,推薦閱讀"linus torvalds answers your questions"[5],在favorite hack這一節,是個頗有趣的思惟訓練
  2. 第8行 posix_memalign 分配了一頁內存,而且對齊到頁邊界,這意味着正好拿到了一個物理頁
  3. 由於申請到的 slab 大小是一個page,因此 item_count = page_size / item_size;其中 item_size 能夠根據應用須要指定。

而後內存分配就簡單到只要彈出鏈表的頭結點就好了,內存釋放則是插入頭結點。這裏還有個優雅的小技巧:既然 slab 對齊到了頁邊界,你只要將指針向下取整到 page_size 就能獲得這個 slab 的指針。

/* Free an element */
struct slab *slab = (void *)((size_t)ptr & PAGESIZE_BITS);
*((void**)ptr) = (void*)slab->head;
slab->head = (void**)ptr;

/* Allocate an element */
if((item = slab->head)) {
    slab->head = (void**)*item;
} else {
    /* No elements left. */
}

譯註:對於 page_size = 4KB 的頁面,PAGESIZE_BITS = 0xFFFFF000,ptr & PAGESIZE_BITS 清零了低12位,正好是這一頁的開始,也就是這個slab的起始地址。

太棒了,可是還有binning(譯註:應該是指按不一樣的長度分桶),變長存儲,cache aliasing(譯註:同一個物理地址中的數據出如今多個不一樣的緩存行中),咖啡因(譯註:這應該是做者在逗逼了),...怎麼辦?能夠看看我以前爲 Knot DNS 寫的代碼[6],或者其餘實現了這些點的庫。例如,(喘口氣),glib 裏有個很整齊的文檔[7],把它稱爲「memory slices」。

譯註:slab 分配器適合大量小對象的分配,能夠避免常見的碎片問題;在內核中的實現還能夠支持硬件緩存對齊,從而提升緩存的利用率。

內存池

工具箱:

  • obstack_alloc() - 從object stack中分配內存

(譯註:指 GNU 的 obstack ,用stack來保存object的內存池實現)

正如slab分配器同樣,內存池比通用分配器好的地方在於,你每次申請一大塊內存,而後像切蛋糕同樣一小塊一小塊切出去,直到不夠用了,而後你再申請一大塊。還有,當你都處理完了之後,你就能夠收工,一次性釋放全部空間。

是否是特別傻瓜化?由於確實如此,但只是針對特定場景如此。你不須要考慮同步,也不須要考慮釋放。再沒有忘記回收的坑了,數據的局部性也更加符合預期,並且對於小對象的開銷也幾乎爲0。

這個模式特別適合不少類型的任務,包括短生命週期的重複分配(例如網絡請求處理),和長生命週期的不可變數據(例如frozen set;譯註:建立後再也不改變的集合)。你再也不須要逐個釋放對象(譯註:能夠最後批量釋放)。若是你能合理推測出平均須要多少內存,你還能夠將多餘的內存釋放,以便用於其餘目的。這能夠將內存分配問題簡化成簡單的指針運算。

並且你很走運 —— GNU libc 提供了,嗬,一整套API來幹這事兒。這就是 obstacks ,用棧來管理對象。它的 HTML 文檔[8] 寫得不咋地,不過拋開這些小缺陷,它容許你完成基於內存池的分配和回收(包括部分回收和全量回收)。

/* Define block allocator. */
#define obstack_chunk_alloc malloc
#define obstack_chunk_free free

/* Initialize obstack and allocate a bunch of animals. */
struct obstack animal_stack;
obstack_init (&animal_stack);
char *bob = obstack_alloc(&animal_stack, sizeof(animal));
char *fred = obstack_alloc(&animal_stack, sizeof(animal));
char *roger = obstack_alloc(&animal_stack, sizeof(animal));

/* Free everything after fred (i.e. fred and roger). */
obstack_free(&animal_stack, fred);

/* Free everything. */
obstack_free(&animal_stack, NULL);

譯註:obstack這些api實際都是宏;須要經過宏來指定找OS分配和回收整塊內存用的方法,如上第二、3行所示。因爲對象使用棧的方式管理(先分配的最後釋放),因此釋放 fred 的時候,會把 fred 和在 fred 以後分配的對象(roger)一塊兒釋放掉。

還有個小技巧:你能夠擴展棧頂的那個對象。例如帶緩衝的輸入,變長數組,或者用來替代 realloc()-strcpy() 模式(譯註:從新分配內存,而後把原數據拷貝過去):

/* This is wrong, I better cancel it. */
obstack_grow(&animal_stack, "long", 4);
obstack_grow(&animal_stack, "fred", 5);
obstack_free (&animal_stack, obstack_finish(&animal_stack));

/* This time for real. */
obstack_grow(&animal_stack, "long", 4);
obstack_grow(&animal_stack, "bob", 4);
char *result = obstack_finish(&animal_stack);
printf("%s\n", result); /* "longbob" */

譯註:前三行是做者逗逼了,看後四行就行;用 obstack_grow 擴展棧頂元素佔用的內存,擴展結束後調用 obstack_finish 結束擴展,並返回棧頂元素的地址。

對按需調頁(demand paging)的解釋

工具箱:

  • mlock() - 鎖定/解鎖內存(避免被換出到swap)
  • madvise() - 給(內核)建議指定內存範圍的處置方式

通用內存分配器不當即將內存返回給系統的緣由之一是,這個操做開銷很大。系統須要作兩件事:(1) 創建虛擬頁到真實(real)頁的映射,和 (2) 給你一個清零的真實頁。這個真實頁被稱爲幀(frame),如今你知道它們的差異了。每一幀都必須被清空,畢竟你不但願 OS 泄漏其餘進程的祕密,對吧。這裏還有個小技巧,還記得 overcommit 嗎?虛擬內存分配器只把這個交易剛開始的那部分當回事,而後就開始變魔術了 —— 頁表裏的大部分頁面並不指向一個真實頁,而是指向一個特殊的全 0 頁面。

每次你想要訪問這個頁面時,就會觸發一個 page fault,這意味着內核會暫停 進程的執行,分配一個真實頁、更新頁表,而後恢復進程,並僞裝什麼也沒發生。這是彙總在一句話裏、我能作出的最好解釋了,這裏[9]還有更個詳細的版本。這也被稱做「按需調頁」(demand paging)「延遲加載」(lazy loading)

斯波克船長說「人沒法召喚將來」,但這裏你能夠操控它。

(譯註:星際迷航,斯波克說「One man cannot summon the future.」,柯克說「But one man can change the present.」)

內存管理器不是先知,他只是保守地預測你訪問內存的方式,而你本身也未必更清楚(你將會怎樣訪問內存)。(若是你知道)你能夠將一段連續的內存塊鎖定在物理內存中,以免後續的page fault:

char *block = malloc(1024 * sizeof(char));
mlock(block, 1024 * sizeof(char));

(譯註:訪問被換出到swap的頁面會觸發page fault,而後內存管理器會從磁盤中載入頁面,這會致使較嚴重的性能問題;用 mlock 將這段區域鎖定後,OS就不會被操做系統換出到swap;例如,在容許的狀況下,MySQL會用mlock將索引保持在物理內存中)

注意:你還能夠根據本身的內存使用模式,給內核提出建議

char *block = malloc(1024 * sizeof(block));
madvise(block, 1024 * sizeof(block), MADV_SEQUENTIAL);

對建議的解釋是平臺相關的,系統甚至可能選擇忽略它,但大部分平臺都處理得很好。但不是全部建議都有良好的支持,有些平臺可能會改變建議的語義(如MADV_FREE移除私有髒頁;譯註:「髒頁」,dirty page,是指分配之後有過寫入,其中可能有未保存的數據),可是最經常使用的仍是MADV_SEQUENTIAL, MADV_WILLNEED, 和 MADV_DONTNEED 這神聖三人組(譯註:holy trinity,聖經裏的三位一體,做者用詞太跳脫……)。

譯註:還記得《踩坑記:go服務內存暴漲》裏對 MADV_DONTNEED 和 MADV_FREE 的解釋嗎?這裏再回顧下

  • MADV_DONTNEED:再也不須要的頁面,Linux會當即回收
  • MADV_FREE:再也不須要的頁面,Linux會在須要時回收
  • MADV_SEQUENTIAL:將會按順序訪問的頁面,內核能夠經過預讀隨後的頁面來優化,已經訪問過的頁面也能夠提早回收
  • MADV_WILLNEED:很快將訪問,建議內核提早加載


又到休息點,這篇暫時到這裏。

下一篇會繼續翻譯下一節《Fun with memory mapping》,還有不少有意思的內容,敬請關注~

順便再貼下以前推送的幾篇文章,祝過個充實的五一假期~

歡迎關注

weixin2s.png


參考連接:
1. What a C programmer should know about memory
2. sbrk(2) - Linux man page
3. C Programming/stdlib.h/malloc
4. The Slab Allocator: An Object-Caching Kernel Memory Allocator
5. linus torvalds answers your questions
6. Knot DNS - slab.h
7. glib  - memory slices

  1. GNU libc - Obstacks
  2. How the kernel manages your memory
相關文章
相關標籤/搜索