這周換換口味,記錄一下去年踩的一個大坑。node
大概是去年8月份,那會兒咱們還在用着64GB的「小內存」機器。git
因爲升級一次版本須要較長的時間(1~2小時),所以咱們天天只發一次車,由值班的同窗負責,發佈全部已merge的commit。 程序員
當天負責值班的我正開着車,忽然收到 Bytedance-System 的奪命連環call,打開Lark一看:github
[ 規則 ]:機器資源報警
[ 報警上下文 ]:
host: 10.x.x.x
內存使用率: 0.944
[ 報警方式 ]:電話&Lark
打開ganglia一看,更使人懼怕:golang
這看起來像是典型的內存泄漏case,那就按正常套路排查: 面試
一方面,通知車上的同窗review本身的commit,看看是否有代碼疑似內存泄漏,或者新增大量內存佔用的邏輯; 後端
另外一方面,咱們的go服務都默認開啓了pprof,因而找了一臺機器恢復到原版本,用來對比內存佔用狀況:ide
$ go tool pprof http://$IP:$PORT/debug/pprof/heap (pprof) top 10 Showing top 10 nodes out of 125 flat flat% sum% cum cum% 2925.01MB 17.93% 17.93% 3262.03MB 19.99% **[此處打碼]** 2384.37MB 14.61% 32.54% 4817.78MB 29.52% **[此處打碼]** 2142.40MB 13.13% 45.67% 2142.40MB 13.13% **[此處打碼]** ...
就這樣,一頓操做猛如虎,漲跌全靠特朗普,最終結果是,一方面沒看出啥問題,另外一方面也沒看出啥問題。性能
正在束手無策、準備回滾之際,內存它本身穩了:spa
雖然佔用率仍然很高,可是沒有繼續上升,也沒有出現OOM的狀況。
排查過程當中,咱們還發現一個現象:並非全部機器的內存都漲。
(確實有點「靈」……)
這些機器的硬件都是一致的,可是用 uname -a 能夠看到,內存異常的機器版本是 4.14,比內存正常機器的 3.16 高不少:
<異常機器>$ uname -a # Linux 4.14.81.xxx ...
<正常機器>$ uname -a `Linux 3.16.104.xxx ...`
說明兩個 kernel 版本的某些差異是緣由之一,但並不足以解釋前述問題:畢竟發車以前也是這些機器。
此外,Y同窗提到,他把編譯服務指定的 go 版本從 1.10 升級到了 1.12。
當時 go 1.12 已經發布半年,Y 同窗在開發環境編譯和運行正常,在線上灰度機器也運行了一段時間,看着沒毛病,因此就決定升級了。
既然其餘可能性都排查過了,那就先降回來看看吧。
咱們用 go 1.10 從新編譯了master,發佈到幾臺內存異常的機器上。
因而問題解決了。
爲何 go 1.12 會致使內存異常上漲呢?
查查 Go 1.12 Release Notes,能夠找到一點線索:
Runtime
Go 1.12 significantly improves the performance of sweeping when a large fraction of the heap remains live. This reduces allocation latency immediately following a garbage collection.(中間省略2段不太相關的內容)
On Linux, the runtime now uses MADV_FREE to release unused memory. This is more efficient but may result in higher reported RSS. The kernel will reclaim the unused data when it is needed.golang.org/doc/go1.12
翻譯一下:
在堆內存大部分活躍的狀況下,go 1.12 能夠顯著提升清理性能,下降 [緊隨某次gc的內存分配] 的延遲。在Linux上,Go Runtime如今使用 MADV_FREE 來釋放未使用的內存。這樣效率更高,可是可能致使更高的 RSS;內核會在須要時回收這些內存。
這兩段話每一個字都認識,合到一塊兒就
不過都寫到這了,我仍是試着解釋下,借用 C 語言的 malloc 和 free (Go的內存分配邏輯也相似):
在Linux下,malloc 須要在其管理的內存不夠用時,調用 brk 或 mmap 系統調用(syscall)找內核擴充其可用地址空間,這些地址空間對應前述的堆內存(heap)。
注意,是「擴充地址空間」:由於有些地址空間可能不會當即用到,甚至可能永遠不會用到,爲了提升效率,內核並不會馬上給進程分配這些內存,而只是在進程的頁表中作好標記(可用、但未分配)。
注:OS用頁表來管理進程的地址空間,其中記錄了頁的狀態、對應的物理頁地址等信息;一頁一般是 4KB。
當進程讀/寫還沒有分配的頁面時,會觸發一個缺頁中斷(page fault),這時內核纔會分配頁面,在頁表中標記爲已分配,而後再恢復進程的執行(在進程看來彷佛什麼都沒發生)。
注:相似的策略還用在不少其餘地方,包括被swap到磁盤的頁面(「虛擬內存」),以及 fork 後的 cow 機制。
當咱們不用內存時,調用 free(ptr) 釋放內存。
對應的,當 free 以爲有必要的時候,會調用 sbrk 或 munmap 縮小地址空間:這是針對一整段地址空間都空出來的狀況。
但更多的時候,free 可能只釋放了其中一部份內容(例如連續的 ABCDE 5個頁面中只釋放了C和D),並不須要(也不能)把地址空間縮小
這時最簡單的策略是:什麼也不幹。
但這種佔着茅坑不拉屎的行爲,會致使內核沒法將空閒頁面分配給其餘進程。
因此 free 能夠經過 madvise 告訴內存「這一段我不用了」。
經過 madvise(addr, length, advise) 這個系統調用,告訴內核能夠如何處理從 addr 開始的 length 字節。
在 Linux Kernel 4.5 以前,只支持 MADV_DONTNEED(上面提到 go 1.11 及之前的默認advise),內核會在進程的頁表中將這些頁標記爲「未分配」,從而進程的 RSS 就會變小。OS後續能夠將對應的物理頁分配給其餘進程。
注:RSS 是 Resident Set Size(常駐內存集)的縮寫,是進程在物理內存中實際佔用的內存大小(也就是頁表中實際分配、且未被換出到swap的內存頁總大小)。咱們在 ps 命令中會看到它,在 top 命令裏對應的是 REZ(man top有更多驚喜)。
被 madvise 標記的這段地址空間,該進程仍然能夠訪問(不會segment fault),可是當讀/寫其中某一頁時(例如malloc分配新的內存,或 Go 建立新的對象),內核會 從新分配 一個 用全0填充 的新頁面。
若是進程大量讀寫這段地址空間(即 release notes 說的 「a large fraction of the heap remains live」,堆空間大部分活躍),內核須要頻繁分配頁面、而且將頁面內容清零,這會致使分配的延遲變高。
從 kernel 4.5 開始,Linux 支持了 MADV_FREE (go 1.12 默認使用的advise),內核只會在頁表中將這些進程頁面標記爲可回收,在須要的時候纔回收這些頁面。
若是趕在內核回收前,進程讀寫了這段空間,就能夠繼續使用原頁面,相比 DONTNEED 模式,減小了從新分配內存、數據清零所需的時間,這對應 Release Notes 裏寫的 "reduces allocation latency immediately following a garbage collection",由於在 gc 之後當即分配內存,對應的頁面大機率尚未被 OS 回收。
但其代價是 "may result in higher reported RSS",因爲頁面沒有被OS回收,仍被計入進程的 RSS ,所以看起來進程的內存佔用會比較大。
差很少就解釋到這裏吧,建議再重讀一遍:
在堆內存大部分活躍的狀況下,go 1.12 能夠顯著提升清理性能,下降 [緊隨某次gc的內存分配] 的延遲。在Linux上,Go Runtime如今使用 MADV_FREE 來釋放未使用的內存。這樣效率更高,可是可能致使更高的 RSS;內核會在須要時回收這些內存。
若是仍然有不理解的地方,能夠留言探討。
對更多細節感興趣的同窗,推薦閱讀《What Every Programmer Should Know About Memory》(TL; DR),或者它的精簡版《What a C programmer should know about memory》(文末參考連接)。
至此前述內存暴漲問題也算是收尾了,但 Y 同窗仍然有點不放心:是否是有可能某個bug在 Go 1.12 纔會出現、致使內存泄漏?
這問題有點軸,可是好像頗有道理,畢竟前面那麼一大段,光說不練,像假把式。
但這要如何才能實錘呢?
前面提到 go 1.12 用 MADV_FREE ,內核會在須要的時候纔回收這些頁面。
若是咱們能想辦法讓內核以爲須要、去回收這些可回收的頁面,就能實錘了。
熟悉虛擬化(如xen、kvm)的同窗,可能會以爲這個問題很眼熟:若是宿主機(準確地說是hypervisor)可以回收客戶機再也不使用的內存,那就能夠 超賣更多VPS賺更多錢 大幅提升內存的利用率。
他們是怎麼作的呢?
xen的解決方案是:在客戶機裏植入一段程序,其主要工做是申請新的內存。能被它申請到的內存,就是客戶機能夠不用的內存(固然也不能申請得太過度,不然會致使客戶機使用swap,或其餘進程OOM)。而後宿主機就能夠放心將這些內存對應的物理頁挪做他用了。
這個過程就像在吹氣球,把客戶機裏能佔用的空間都佔住。
因此這段程序的名字叫作:balloon driver。
那麼實錘方案就呼之欲出了:
若是咱們也弄個不斷膨脹的氣球(申請內存),內核就會以爲須要去找其餘進程回收那些被FREE標記的內存。
說幹就幹:
#include <stdio.h> #include <string.h> #include <unistd.h> int main() { char *p = NULL; const int MB = 1024 * 1024; while (1) { p = malloc(100 * MB); memset(p, 0, 100 * MB); sleep(1); } return 0; }
(注意memset,不然內存不會實際分配)
效果以下:
能夠看到,雖然打碼進程的 VIRT(地址空間大小)仍是52G,可是實際佔用的內存已經降低到 35G,氣球生效了。
簡單彙總一下前面的內容:
順便一提,本文涉及的部分知識點,我在面試時偶爾會問到。
有些候選人就以爲我是在刁難他,反問我:
「你問的這些,工做中都用獲得嗎?」
~ 投遞連接 ~
網盟廣告(穿山甲)-後端開發(上海)
https://job.toutiao.com/s/sBAvKe
網盟廣告(穿山甲)-後端開發(北京)
https://job.toutiao.com/s/sBMyxk
其餘地區、其餘職能線
https://job.toutiao.com/s/sB9Jqk
關於字節跳動面試的詳情,可參考我以前寫的:
參考連接:
[1] Go 1.12 關於內存釋放的一個改進
https://ms2008.github.io/2019...
[2] What a C programmer should know about memory
https://marek.vavrusa.com/mem...
[3] tcmalloc2.1 淺析
https://wertherzhang.com/tcma...