[譯] C程式設計師該知道的記憶體知識 (4)

felix021發表於2020-05-24

系列更新:

這是本系列的第4篇,也是最後一篇,含淚填完這個坑不容易,感謝閱讀~

這個系列太乾了,閱讀量一篇比一篇少,但我仍然認為這個系列非常有價值,在翻譯的過程中我也藉機進行系統性的梳理、並學習了很多新知識,收穫滿滿。希望你也能有收穫(但肯定沒我多)。

那,開始吧。


理解記憶體消耗

工具箱:

  • vmtouch[2] - portable virtual memory toucher

(譯註:vmtouch這個工具用來診斷和控制系統對檔案系統的快取,例如檢視某個檔案被快取了多少頁,清空某個檔案的快取頁,或將某個檔案的頁面鎖定在記憶體中;基於這些功能可以實現很多有意思的應用;詳情參考該工具的文件。)

然而共享記憶體的概念導致傳統方案 —— 測量對記憶體的佔用 —— 變得無效了,因為沒有一個公正的方法可以測量你程式的獨佔空間。這會引起困惑甚至恐懼,可能是兩方面的:

用上了基於 mmap 的I/O操作後,我們的應用現在幾乎不佔用記憶體.

— CorporateGuy

求救!我這寫入共享記憶體的程式有嚴重的記憶體洩漏!!!

— HeavyLifter666

頁面有兩種狀態:清潔(clean)頁和髒(dirty)頁。區別是,髒頁在被回收之前需要被寫回到持久儲存中(譯註:寫回檔案實際存放的地方)。MADV_FREE 這個建議通過將髒標誌位清零這種方式來實現更輕量的記憶體釋放,而不是修改整個頁表項(譯註:page table entry,常縮寫為PTE,記錄頁面的物理頁號及若干標誌位,如能否讀寫、是否髒頁、是否在記憶體中等)。此外,每一頁都可能是私有的或共享的,這正是導致困惑的源頭。

前面引用的兩個都是(部分)真實的,取決於視角。在系統緩衝區的頁面需要計入程式的記憶體消耗裡嗎?如果程式修改了緩衝區裡那些對映檔案的那些頁面呢?在這混亂中可以整出點有用的東西麼?

假設有一個程式,索倫之眼(the_eye) 會寫入對魔都(mordor)的共享對映(譯註:指環王的梗)。寫入共享記憶體不計入 RSS(resident set size,常駐記憶體集)的,對吧?

$ ps -p $$ -o pid,rss
  PID  RSS
17906  1574944 # <-- 什麼鬼? 佔用1.5GB?

(譯註:$$ 是 bash 變數,儲存了在執行當前script的shell的PID;這裡應該是用來指代the_eye的PID)

呃,讓我們回到小黑板。

PSS(Proportional Set Size)

PSS(譯註:Proportional 意思是 “比例的”) 計入了私有對映,以及按比例計入共享對映。這是我們能得到的最合理的記憶體計算方式了。關於“比例”,是指將共享記憶體除以共享它的程式數量。舉個例子,有個應用需要讀寫某個共享記憶體對映:

$ cat /proc/$$/maps
00400000-00410000         r-xp 0000 08:03 1442958 /tmp/the_eye
00bda000-01a3a000         rw-p 0000 00:00 0       [heap]
7efd09d68000-7f0509d68000 rw-s 0000 08:03 4065561 /tmp/mordor.map
7f0509f69000-7f050a108000 r-xp 0000 08:03 2490410 libc-2.19.so
7fffdc9df000-7fffdca00000 rw-p 0000 00:00 0       [stack]
... 以下截斷 ...

(譯註:cat /proc/$PID/maps 是從核心中讀取程式的所有記憶體對映)

這是個被簡化並截斷了的對映,第一列是地址範圍,第二列是許可權資訊,其中 r 表示可讀, w 表示可寫,x 表示可執行 —— 這都是老知識點了 —— 然後 s 表示共享,p 表示私有。然後是對映檔案的偏移量,裝置號(OS分配的),inode號(檔案系統上的),以及最後是檔案的路徑名。具體參見這個文件[3](譯註:kernel.org 對 /proc 檔案系統的說明文件),超級詳細。

我得承認我刪掉了一些輸出中一些不太有意思的資訊。如果你對被私有對映的庫感興趣的話可以讀一下 FAQ-為什麼“strict overcommit”是個蠢主意[4](譯註:根據這個FAQ,strict overcommit應該是指允許overcommmit、但要為申請的每一個虛擬頁分配一個真實頁,不管是用物理頁還是swap,確實聽起來很蠢……)。不過這裡我們感興趣的是魔都(mordor)這個對映:

$ grep -A12 mordor.map /proc/$$/smaps
Size:           33554432 kB
Rss:             1557632 kB
Pss:             1557632 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:   1557632 kB
Private_Dirty:         0 kB
Referenced:      1557632 kB
Anonymous:             0 kB
AnonHugePages:         0 kB
Swap:                  0 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Locked:                0 kB
VmFlags: rd wr sh mr mw me ms sd

譯註:這個檔案大小 32GB,已載入了 1521MB 到記憶體中,因為只有這一個程式對映了它,所以在這個程式的PSS中佔比是100%,也是 1521MB。

在共享對映裡的私有頁面 —— 搞得我像巫師一樣?在Linux上,即使共享記憶體也會被認為是私有的,除非它真的被共享了(譯註:不止一個程式建立共享對映)。讓我們看看它是否在系統緩衝區裡:

# 好像開頭的那一塊在記憶體中...
$ vmtouch -m 64G -v mordor.map
[OOo                    ] 389440/8388608

           Files: 1
     Directories: 0
  Resident Pages: 389440/8388608  1G/32G  4.64%
         Elapsed: 0.27624 seconds

# 將它全都載入到Cache!
$ cat mordor.map > /dev/null
$ vmtouch -m 64G -v mordor.map
[ooooooooo      oooOOOOO] 2919606/8388608

           Files: 1
     Directories: 0
  Resident Pages: 2919606/8388608  11G/32G  34.8%
         Elapsed: 0.59845 seconds

譯註:

  1. “-m 64G” 表示允許 vmtouch 將小於 64G 的檔案載入到記憶體中,應當是用於需要載入一個目錄下的檔案、但排除其中過大的檔案,似乎不適用於這裡;至少忽略這個引數不影響閱讀
  2. o 表示這一塊部分被載入,O 表示全部被載入。因為實體記憶體有限,雖然全量讀取了檔案,但只有部分內容被快取

嗬,只是簡單地讀取一個檔案就會把它快取起來?先不管這,我們的程式呢?

$ ps -p $$ -o pid,rss
  PID   RSS
17906 286584 # <-- 等了足足一分鐘

常見的誤解是,對映檔案會消耗記憶體,而通過檔案API讀取不會。實際上,無論哪一種方式,包含檔案內容的頁面都會被放進系統緩衝區。但還有個小的區別是,使用mmap的方式需要在程式的頁表中建立對應的頁表項(PTE),而這些包含檔案內容的頁面是可以被共享的。有趣的是,我們這個程式的RSS縮小了,因為系統 _需要_ 程式的頁面了(譯註:因為 mordor 太大,可用實體記憶體頁不夠,系統將 the_eye 的部分頁面swap了;所以前述命令才會需要等一分鐘,因為涉及到磁碟IO)。

有時我們的所有想法都是錯的

對映檔案的記憶體總是可被回收的,區別只在於該頁是否髒頁 —— 髒頁在回收前需要被清理(譯註:寫回底層儲存)。所以當你在 top 命令發現有一個程式佔用了大量記憶體時是否需要恐慌?當這個程式有很多匿名的髒頁的時候才需要恐慌——因為這些頁面無法被回收。如果你發現有個匿名對映段在增長,你可能就有麻煩了(而且是雙倍的麻煩)。但是不要盲目相信 RSS 甚至 PSS 。

另一個常見錯誤是認為程式的虛擬記憶體和實際消耗記憶體之間總有某種關係,甚至認為所有記憶體對映都一樣。任何可回收的記憶體,實際上都可以認為是空閒的。簡而言之,它不會導致你下次記憶體分配失敗,但_可能_會增加分配的延遲 —— 這點我會解釋:

記憶體管理器需要花很大功夫來決定哪些東西需要儲存在實體記憶體裡。它可能會決定將程式記憶體中的一部分調到swap,以便給系統快取騰出空間,因此該程式下次訪問這一塊時需要再將這些頁面調回到實體記憶體中。幸運的是這通常是可以配置的。例如,Linux 有一個叫做 swappiness[5] 的選項,用來指導核心何時開始將匿名對映的記憶體頁調出到swap。當它取值為 0 是表示“直到絕對絕對有必要的時候”(譯註:取值[0, 100],值越低,系統越傾向於先清理系統緩衝區的頁面)。

終章,一勞永逸地

如果你看到這裡,向你致敬!我在工作之餘寫的這篇文章,希望能用一種更方便的方式,不僅能解釋這些說過上千遍的概念,還能幫我整理這些思維,以及幫助其他人。我花了比預期更長的時間。遠超預期。

我對文章的作者們只有無盡的敬意,因為寫作真是個冗長乏味、令人頭禿的過程,需要永無止境的修改和重寫。Jeff Atwood(譯註:stack overflow的創始人)曾說過,最好的學程式設計書籍是教你蓋房子的那本。我不記得在哪兒了,所以無法引用它。我只能說,第二好的是教你寫作的那本。說到底,程式設計本質上就是寫故事,簡明扼要。

EDIT:我修正了關於 alloca() 和 將 sizeof(char) 誤寫為 sizeof(char*) 的錯誤,多虧了 immibis 和 BonzaiThePenguin。感謝 sWvich 指出在 slab + sizeof(struct slab) 裡漏了的型別轉換。顯然我應該用靜態分析跑一下這篇文章,但並沒有 —— 漲經驗了。

開放問題 —— 有沒有比 Markdown 程式碼塊更好的實現?我希望能展示帶註釋的摘錄,並且能下載整個程式碼塊。

寫於 2015 年 2 月 20 日。



讀到這裡都是真愛,喜歡的話請點贊,感謝~

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

歡迎關注

weixin1.png


參考連結:

[1] What a C programmer should know about memory

[2] vmtouch - the Virtual Memory Toucher

[3] kernel.org - THE /proc FILESYSTEM

[4] FAQ (Why is “strict overcommit” a dumb idea?)

[5] wikipedia - Paging - swapinness

相關文章