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

續上篇:php

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


照例放圖鎮樓: nginx

linuxFlexibleAddressSpaceLayout.png

來源:Linux地址空間佈局 - by Gustavo Duarte程序員

關於圖片的解釋參見第一篇面試

開始吧。數據庫

有趣的內存映射

工具箱:segmentfault

  • sysconf() - 在運行時獲取配置信息
  • mmap() - 映射虛擬內存
  • mincore() - 判斷頁是否在內存中
  • shmat() - 共享內存操做

有些事情是內存分配器無法完成的,須要內存映射來救場。好比說,你沒法選擇分配的地址範圍。爲了這個,咱們得犧牲一些溫馨性 —— 接下來將和整頁內存打交道了。注意,雖然一頁一般是 4KB,但你不該該依賴這個「一般」,而是應該用 sysconf() 來獲取的實際大小:數組

long page_size = sysconf(_SC_PAGESIZE); /* Slice and dice. */

備註 —— 即便系統宣稱使用統一的page size(譯註:這裏指sysconf的返回值),它在底層可能用了其餘尺寸。例如Linux有個叫 transparent huge page(THP)[2]的概念,能夠減小地址翻譯的開銷(譯註:地址翻譯指 虛擬地址->線性地址->物理地址,細節比較多,涉及到多級頁表、MMU、TLB等,詳情可參考知乎這篇文章《虛擬地址轉換》[3])和連續內存塊訪問致使的page fault(譯註:原本4KB一次,如今4MB一次,少了3個量級)。但這裏還要打個問號,尤爲是當物理內存碎片化,致使連續的大塊內存較少的狀況。一次page fault的開銷也會隨着頁面大小提升,所以對於少許隨機IO負載的狀況,huge page的效率並不高。很不幸這對你是透明的,但Linux有一個專有的 mmap 選項 MAP_HUGETLB 容許你明確指定使用這個特性,所以你應該瞭解它的開銷。緩存

固定內存映射

舉個栗子,假如你如今得爲一個小可憐的進程間通訊(IPC)創建一個固定映射(譯註:兩個進程都映射到相同的地址),你該如何選擇映射的地址呢?這有個在 x86-32 上可能有點風險的提案,可是在 64 bit上,大約在 TASK_SIZE 2/3 位置的地址(用戶空間最高的可用地址;譯註:見鎮樓圖右上方)大體是安全的。你能夠不用固定映射,可是就別想用指向共享內存的指針了(譯註:不固定起始地址的話,共享內存中同一個對象在兩個不一樣進程的地址就不同了,這樣的指針沒法在兩個進程中通用)。安全

#define TASK_SIZE 0x800000000000
#define SHARED_BLOCK (void *)(2 * TASK_SIZE / 3)

void *shared_cats = shmat(shm_key, SHARED_BLOCK, 0);
if(shared_cats == (void *)-1) {
    perror("shmat"); /* Sad :( */
}

譯註:shmat是「shared memory attach」的縮寫,表示將 shm_key 指定的共享內存映射到 SHARED_BLOCK 開始的虛擬地址上。shm_key 是由 shmget(key, size, flag) 建立的一塊共享內存的標識。詳細用法請google。

OKay,我知道,這是個幾乎沒法移植的例子,可是大意你應該能理解了。固定地址映射一般被認爲至少是不安全的,由於它不檢查那裏是否已經映射了其餘東西。有一個 mincore() 函數能夠告訴你一個頁面是否被映射了,可是在多線程環境裏你可能不那麼走運(譯註:可能你剛檢查的時候沒被映射,但在你映射以前被另外一個線程映射了;做者這裏使用 mincore 可能不太恰當,由於它只檢查頁面是否在物理內存中,而一個頁面可能被映射了、可是被換出到swap)。

然而,固定地址映射不只在未使用的地址範圍上有用,並且對已用的地址範圍也有用。還記得內存分配器如何使用 mmap() 來分配大塊內存嗎?因爲按需調頁機制,咱們能夠實現高效的稀疏數組。假設你建立了一個稀疏數組,而後如今你打算釋放掉其中一些數據佔用的空間,該怎麼作呢?你不能 free() 它(譯註:由於不是malloc分配的),而 mmap () 會讓這段地址空間不可用(譯註:由於這段地址空間屬於稀疏數組,仍可能被訪問到,不能被unmap)。你能夠調用 madvise() ,用 MADV_FREE /  MADV_DONTNEED 將這些頁面標記爲空閒(譯註:頁面可被回收,但地址空間仍然可用),從性能上來說這是最佳解決方案,由於這些頁面可能再也不會因觸發 page fault 被載入,不過這些「建議」的語義可能根據具體的實現而變化(譯註:換句話說就是雖然性能好,但可移植性很差,例如在Linux不一樣版本以及其餘Unix-like系統這些建議的語義會有差異;關於這些建議的說明詳見上一篇)。

一種可移植的作法是在這貨上面覆蓋映射:

void *array = mmap(NULL, length, PROT_READ|PROT_WRITE,
                   MAP_ANONYMOUS, -1, 0);

/* ... 某些魔法玩脫了 ... */

/* Let's clear some pages. */
mmap(array + offset, length, MAP_FIXED|MAP_ANONYMOUS, -1, 0);

譯註:如前文所述,開頭用 mmap() 建立了一個稀疏數組 array;第四行應該是指代前述須要清理掉其中一部分數據;第7行用 mmap 從新映射從 array + offset 開始、長度爲 length 字節的空間,注意這行的 length 應當是須要清理的數據長度,不一樣於第一行的length(整個稀疏數組的長度)。

這等價於取消舊頁面的映射,並將它們從新映射到那個特殊頁面(譯註:指上一篇說到的全 0 頁面)。這會如何影響進程的內存消耗呢——進程仍然佔用一樣大小的虛擬內存,可是駐留在物理內存的尺寸減小了(譯註:取消舊頁面映射時,對應的真實頁面被OS回收了)。這是咱們能作到的最接近 內存打洞 的辦法了。

基於文件的內存映射

工具箱:

  • msync() - 將映射到內存的文件內容同步到文件系統
  • ftruncate() - 將文件截斷到指定的長度
  • vmsplice() - 將用戶頁面內容寫入到管道

到這裏咱們已經知道關於匿名內存的全部知識了,可是在64bit地址空間中真正讓人亮瞎眼的仍是基於文件的內存映射,它能夠提供智能的緩存、同步和寫時複製(copy-on-write;譯註:常縮寫爲COW)。是否是太多了點?

對於大多數人來講,相比直接使用文件系統,LMDB就像是魔法般的性能如雨點般撒落。

Baby_Food[4] on r/programming

譯註:LMDB(Lightning Memory-mapped DataBase)是一個輕量級的、基於內存映射的kv數據庫,因爲能夠直接返回指針、避免值拷貝,因此性能很是高;更多細節詳見wikipedia。

基於文件的共享內存映射使用一個新的模式 MAP_SHARED ,表示你對頁面的修改會被寫回到文件,從而能夠和其餘進程共享。具體什麼時候同步取決於內存管理器,不過還好有個 msync() 能夠強制將改動同步到底層存儲。這對於數據庫來講很重要,能夠保證被寫入數據的持久性(durability)。但不是誰都須要它,尤爲是不須要持久化的場景下,徹底不須要同步,你也不用擔憂丟失 寫入數據的可見性(譯註:這裏應該是指修改後當即可讀取)。這多虧了頁面緩存,得益於此你也能夠用內存映射來實現高效的進程間通訊。

/* Map the contents of a file into memory (shared). */
int fd = open(...);
void *db = mmap(NULL, file_size, PROT_READ|PROT_WRITE,
                MAP_SHARED, fd, 0);
if (db == (void *)-1) {
  /* Mapping failed */
}

/* Write to a page */
char *page = (char *)db;
strcpy(page, "bob");
/* This is going to be a durable page. */
msync(page, 4, MS_SYNC);
/* This is going to be a less durable page. */
page = page + PAGE_SIZE;
strcpy(page, "fred");
msync(page, 5, MS_ASYNC);

譯註:MS_SYNC會等待寫入底層存儲後才返回;MS_ASYNC會當即返回,OS會異步寫回存儲,但期間若是系統異常崩潰就會致使數據丟失。

注意,你不能映射比文件內容更長的內存,因此你沒法經過這種方式增長或者減小文件的長度。不過你能夠提早用 ftruncate() 來建立(或加長)一個稀疏文件(譯註:稀疏文件是指,你能夠建立一個很大的文件,但文件裏只有少許數據;不少文件系統如ext*、NTFS系列都支持只存儲有數據的部分)。但稀疏文件的壞處是,會讓緊湊的存儲更困難,由於它同時要求文件系統和OS都支持才行。

在Linux下,fallocate(FALLOC_FL_PUNCH_HOLE) 是最佳選項,但最適合移植(也最簡單的)方法是建立一個空文件:

/* Resize the file. */
int fd = open(...);
ftruncate(fd, expected_length);

一個文件被內存映射,並不意味着不能再以文件來用它。這對於須要區分不一樣訪問狀況的場景頗有用,好比說你能夠一邊把這個文件用只讀模式映射到內存中,一邊用標準的文件API來寫入它。這對於有安全要求的狀況頗有用,由於暴露的內存映射是有寫保護的,但還有些須要注意的地方。msync() 的實現沒有嚴格定義,因此 MS_SYNC 每每就是一系列同步的寫操做。呸,這樣的話速度還不如用標準文件API,異步的 pwrite() 寫入,以及 fsync() 或 fdatasync() 完成同步或使緩存失效。(譯註:pwrite(fd, buf, count, offset) 往fd的offset位置寫入從buf開始的count個字節,適合多線程環境,不受fd當前offset的影響;fsync(fd)、fdatasync(fd) 用於將文件的改動同步寫回到磁盤)

照例這有個警告——系統應當有一個統一的緩衝和緩存(unified buffer cache)。歷史上,頁面緩存(page cache,按頁緩存文件的內容)和塊設備緩存(block device cache,緩存磁盤的原始block數據)是兩個不一樣的概念。這意味着同時使用標準API寫入文件和使用內存映射讀文件,兩者會產生不一致,除非你在每次寫入以後都使緩存失效。攤手。不過,你一般不用擔憂,只要你不是在跑OpenBSD或低於2.4版本的Linux。

寫時複製(Copy-On-Write)

前面講的都仍是關於共享的內存映射,但其實還有另外一種用法——映射文件的一份拷貝,且對它的修改不會影響原文件。注意這些頁面不會當即被複制,由於這沒啥意義,而是在你修改時才被複制(譯註:一方面,一般來講大部分頁面不會被修改,另外一方面,延遲到寫時才複製,能夠下降STW致使的延時)。這不只有助於建立新進程(譯註:fork新進程的時候只須要拷貝頁表)或者加載共享庫的場景,也有助於處理來自多個進程的大數據集的場景。

int fd = open(...);

/* Copy-on-write mapping */
void *db = mmap(NULL, file_size, PROT_READ|PROT_WRITE,
                    MAP_PRIVATE, fd, 0);
if (db == (void *)-1) {
  /* Mapping failed */
}

/* This page will be copied as soon as we write to it */
char *page = (char *)db;
strcpy(page, "bob");

譯註:MAP_PRIVATE 這個 flag 用於建立 copy-on-write 映射,對該映射的改動不影響其餘進程,也不會寫回到被映射的文件。當寫入該映射時,會觸發 page fault,內核的中斷程序會拷貝一份該頁,修改頁表,而後再恢復進程的運行。

零拷貝串流(Zero-copy streaming)

因爲(被映射的)文件本質上就是一塊內存,你能夠將它「串流」(stream)到管道(也包括socket),用零拷貝模式(譯註:「零拷貝」不是指徹底不拷貝,而是避免在內核空間和用戶空間之間來回拷貝,其典型實現是先 read(src, buf, len) 再 write(dest, buf, len) )。和 splice() 不一樣的是,vmsplice 適用於 copy-on-write 版本的數據(譯註:splice的源數據用fd指定,vmsplice的源數據用指針指定)。免責聲明:這隻適用於使用Linux的老哥!

int sock = get_client();
struct iovec iov = { .iov_base = cat_db, .iov_len = PAGE_SIZE };
int ret = vmsplice(sock, &iov, 1, 0);
if (ret != 0) {
  /* No streaming :( */
}

譯註:vmsplice第二個參數 iov 是一個指針,上例只指向一個 struct iovec,實際上它能夠是一個數組,數組的長度由第三個參數標明。

譯註:舉幾個具體的場景,例如 nginx 使用 sendfile(底層就是splice)來提升靜態文件的性能;php也提供了一個 readfile() 方法來實現零拷貝發送文件;kafka將partition數據發送給consumer時也使用了零拷貝技術,consumer數量越多,節約的開銷越顯著。

mmap不頂用的場景

還有些奇葩的場景,映射文件性能會比常規實現差得多。按理來講,處理page fault會比簡單讀取文件塊要慢,由於除了讀取文件還須要作其餘事情(譯註:修改頁表等)。但實際上,基於映射的文件IO也可能更快,由於能夠避免對數據的雙重甚至三重緩存(譯註:多是指文件庫的緩存,例如os自己會有緩存,c的fopen/fread還內建了緩存),而且能夠在後臺預讀數據。但有時這也有害。一個例子是「小塊隨機讀取大於可用內存的文件」(譯註:如2G內存,4G的文件,每次從隨機位置讀取幾個字節),在這個場景下,系統預讀的塊大機率不會被用上,而每一次訪問都會觸發page fault。固然你也能夠用 madvise() 作必定程度的優化(譯註:用上 MADV_RANDOM 這個建議,告訴OS預讀沒用)。

還有 TLB 抖動(thrashing)的問題。將虛擬頁的地址翻譯到物理地址是有硬件輔助的,CPU會緩存最近的翻譯 —— 這就是 TLB(Translation Lookaside Buffer;譯註:可譯做「後備緩衝器」,CPU中的MMU專用的緩存,用來加速地址翻譯)。隨機訪問的頁面數量超過緩存能力必然會致使抖動(thrashing)_,_由於(在緩存不頂用時)系統必須遍歷頁表才能完成地址翻譯。對於其餘場景能夠考慮使用 huge page ,但這裏行不通,由於僅僅爲了訪問幾個字節而讀取幾MB的數據會讓性能變得更糟。


下一篇會繼續翻譯最後一節《Understanding memory consumption》,敬請關注~

以及照例再貼下以前推送的幾篇文章:

歡迎關注

weixin2s.png


參考連接:

[1] What a C programmer should know about memory
https://marek.vavrusa.com/mem...

[2] Linux - Transparent huge pages
https://lwn.net/Articles/423584/

[3] 虛擬地址轉換
https://zhuanlan.zhihu.com/p/...

[4] Reddit - What every programmer should know about solid-state drives
https://www.reddit.com/r/prog...

相關文章
相關標籤/搜索