騰訊一面:記憶體滿了,會發生什麼?

作者:小林coding

電腦八股文刷題網站://xiaolincoding.com

大家好,我是小林。

前幾天有位讀者留言說,面騰訊時,被問了兩個記憶體管理的問題:

先來說說第一個問題:虛擬記憶體有什麼作用?

  • 第一,由於每個進程都有自己的頁表,所以每個進程的虛擬記憶體空間就是相互獨立的。進程也沒有辦法訪問其他進程的頁表,所以這些頁表是私有的。這就解決了多進程之間地址衝突的問題。
  • 第二,頁表裡的頁表項中除了物理地址之外,還有一些標記屬性的比特,比如控制一個頁的讀寫許可權,標記該頁是否存在等。在記憶體訪問方面,作業系統提供了更好的安全性。

然後今天主要是聊聊第二個問題,「系統記憶體緊張時,會發生什麼?

發車!

記憶體分配的過程是怎樣的?

應用程式通過 malloc 函數申請記憶體的時候,實際上申請的是虛擬記憶體,此時並不會分配物理記憶體。

當應用程式讀寫了這塊虛擬記憶體,CPU 就會去訪問這個虛擬記憶體, 這時會發現這個虛擬記憶體沒有映射到物理記憶體, CPU 就會產生缺頁中斷,進程會從用戶態切換到內核態,並將缺頁中斷交給內核的 Page Fault Handler (缺頁中斷函數)處理。

缺頁中斷處理函數會看是否有空閑的物理記憶體,如果有,就直接分配物理記憶體,並建立虛擬記憶體與物理記憶體之間的映射關係。

如果沒有空閑的物理記憶體,那麼內核就會開始進行回收記憶體的工作,回收的方式主要是兩種:直接記憶體回收和後台記憶體回收。

  • 後台記憶體回收(kswapd):在物理記憶體緊張的時候,會喚醒 kswapd 內核執行緒來回收記憶體,這個回收記憶體的過程非同步的,不會阻塞進程的執行。
  • 直接記憶體回收(direct reclaim):如果後台非同步回收跟不上進程記憶體申請的速度,就會開始直接回收,這個回收記憶體的過程是同步的,會阻塞進程的執行。

如果直接記憶體回收後,空閑的物理記憶體仍然無法滿足此次物理記憶體的申請,那麼內核就會放最後的大招了 ——觸發 OOM (Out of Memory)機制

OOM Killer 機制會根據演算法選擇一個佔用物理記憶體較高的進程,然後將其殺死,以便釋放記憶體資源,如果物理記憶體依然不足,OOM Killer 會繼續殺死佔用物理記憶體較高的進程,直到釋放足夠的記憶體位置。

申請物理記憶體的過程如下圖:

哪些記憶體可以被回收?

系統記憶體緊張的時候,就會進行回收內測的工作,那具體哪些記憶體是可以被回收的呢?

主要有兩類記憶體可以被回收,而且它們的回收方式也不同。

  • 文件頁(File-backed Page):內核快取的磁碟數據(Buffer)和內核快取的文件數據(Cache)都叫作文件頁。大部分文件頁,都可以直接釋放記憶體,以後有需要時,再從磁碟重新讀取就可以了。而那些被應用程式修改過,並且暫時還沒寫入磁碟的數據(也就是臟頁),就得先寫入磁碟,然後才能進行記憶體釋放。所以,回收乾淨頁的方式是直接釋放記憶體,回收臟頁的方式是先寫回磁碟後再釋放記憶體
  • 匿名頁(Anonymous Page):應用程式通過 mmap 動態分配的堆記憶體叫作匿名頁,這部分記憶體很可能還要再次被訪問,所以不能直接釋放記憶體,它們回收的方式是通過 Linux 的 Swap 機制,Swap 會把不常訪問的記憶體先寫到磁碟中,然後釋放這些記憶體,給其他更需要的進程使用。再次訪問這些記憶體時,重新從磁碟讀入記憶體就可以了。

文件頁和匿名頁的回收都是基於 LRU 演算法,也就是優先回收不常訪問的記憶體。LRU 回收演算法,實際上維護著 active 和 inactive 兩個雙向鏈表,其中:

  • active_list 活躍記憶體頁鏈表,這裡存放的是最近被訪問過(活躍)的記憶體頁;
  • inactive_list 不活躍記憶體頁鏈表,這裡存放的是很少被訪問(非活躍)的記憶體頁;

越接近鏈表尾部,就表示記憶體頁越不常訪問。這樣,在回收記憶體時,系統就可以根據活躍程度,優先回收不活躍的記憶體。

活躍和非活躍的記憶體頁,按照類型的不同,又分別分為文件頁和匿名頁。可以從 /proc/meminfo 中,查詢它們的大小,比如:

# grep表示只保留包含active的指標(忽略大小寫)
# sort表示按照字母順序排序
[root@xiaolin ~]# cat /proc/meminfo | grep -i active | sort
Active:           901456 kB
Active(anon):     227252 kB
Active(file):     674204 kB
Inactive:         226232 kB
Inactive(anon):    41948 kB
Inactive(file):   184284 kB

回收記憶體帶來的性能影響

在前面我們知道了回收記憶體有兩種方式。

  • 一種是後台記憶體回收,也就是喚醒 kswapd 內核執行緒,這種方式是非同步回收的,不會阻塞進程。
  • 一種是直接記憶體回收,這種方式是同步回收的,會阻塞進程,這樣就會造成很長時間的延遲,以及系統的 CPU 利用率會升高,最終引起系統負荷飆高。

可被回收的記憶體類型有文件頁和匿名頁:

  • 文件頁的回收:對於乾淨頁是直接釋放記憶體,這個操作不會影響性能,而對於臟頁會先寫回到磁碟再釋放記憶體,這個操作會發生磁碟 I/O 的,這個操作是會影響系統性能的。
  • 匿名頁的回收:如果開啟了 Swap 機制,那麼 Swap 機制會將不常訪問的匿名頁換出到磁碟中,下次訪問時,再從磁碟換入到記憶體中,這個操作是會影響系統性能的。

可以看到,回收記憶體的操作基本都會發生磁碟 I/O 的,如果回收記憶體的操作很頻繁,意味著磁碟 I/O 次數會很多,這個過程勢必會影響系統的性能,整個系統給人的感覺就是很卡。

下面針對回收記憶體導致的性能影響,說說常見的解決方式。

調整文件頁和匿名頁的回收傾向

從文件頁和匿名頁的回收操作來看,文件頁的回收操作對系統的影響相比匿名頁的回收操作會少一點,因為文件頁對於乾淨頁回收是不會發生磁碟 I/O 的,而匿名頁的 Swap 換入換出這兩個操作都會發生磁碟 I/O。

Linux 提供了一個 /proc/sys/vm/swappiness 選項,用來調整文件頁和匿名頁的回收傾向。

swappiness 的範圍是 0-100,數值越大,越積極使用 Swap,也就是更傾向於回收匿名頁;數值越小,越消極使用 Swap,也就是更傾向於回收文件頁。

[root@xiaolin ~]# cat /proc/sys/vm/swappiness
0

一般建議 swappiness 設置為 0(默認就是 0),這樣在回收記憶體的時候,會更傾向於文件頁的回收,但是並不代表不會回收匿名頁。

儘早觸發 kswapd 內核執行緒非同步回收記憶體

如何查看系統的直接記憶體回收和後台記憶體回收的指標?

我們可以使用 sar -B 1 命令來觀察:

圖中紅色框住的就是後台記憶體回收和直接記憶體回收的指標,它們分別表示:

  • pgscank/s : kswapd(後台回收執行緒) 每秒掃描的 page 個數。
  • pgscand/s: 應用程式在記憶體申請過程中每秒直接掃描的 page 個數。
  • pgsteal/s: 掃描的 page 中每秒被回收的個數(pgscank+pgscand)。

如果系統時不時發生抖動,並且在抖動的時間段里如果通過 sar -B 觀察到 pgscand 數值很大,那大概率是因為「直接記憶體回收」導致的。

針對這個問題,解決的辦法就是,可以通過儘早的觸發「後台記憶體回收」來避免應用程式進行直接記憶體回收。

什麼條件下才能觸發 kswapd 內核執行緒回收記憶體呢?

內核定義了三個記憶體閾值(watermark,也稱為水位),用來衡量當前剩餘記憶體(pages_free)是否充裕或者緊張,分別是:

  • 頁最小閾值(pages_min);
  • 頁低閾值(pages_low);
  • 頁高閾值(pages_high);

這三個記憶體閾值會劃分為四種記憶體使用情況,如下圖:

kswapd 會定期掃描記憶體的使用情況,根據剩餘記憶體(pages_free)的情況來進行記憶體回收的工作。

  • 圖中綠色部分:如果剩餘記憶體(pages_free)大於 頁高閾值(pages_high),說明剩餘記憶體是充足的;

  • 圖中藍色部分:如果剩餘記憶體(pages_free)在頁高閾值(pages_high)和頁低閾值(pages_low)之間,說明記憶體有一定壓力,但還可以滿足應用程式申請記憶體的請求;

  • 圖中橙色部分:如果剩餘記憶體(pages_free)在頁低閾值(pages_low)和頁最小閾值(pages_min)之間,說明記憶體壓力比較大,剩餘記憶體不多了。這時 kswapd0 會執行記憶體回收,直到剩餘記憶體大於高閾值(pages_high)為止。雖然會觸發記憶體回收,但是不會阻塞應用程式,因為兩者關係是非同步的。

  • 圖中紅色部分:如果剩餘記憶體(pages_free)小於頁最小閾值(pages_min),說明用戶可用記憶體都耗盡了,此時就會觸發直接記憶體回收,這時應用程式就會被阻塞,因為兩者關係是同步的。

可以看到,當剩餘記憶體頁(pages_free)小於頁低閾值(pages_low),就會觸發 kswapd 進行後台回收,然後 kswapd 會一直回收到剩餘記憶體頁(pages_free)大於頁高閾值(pages_high)。

也就是說 kswapd 的活動空間只有 pages_low 與 pages_min 之間的這段區域,如果剩餘內測低於了 pages_min 會觸發直接記憶體回收,高於了 pages_high 又不會喚醒 kswapd。

頁低閾值(pages_low)可以通過內核選項 /proc/sys/vm/min_free_kbytes (該參數代表系統所保留空閑記憶體的最低限)來間接設置。

min_free_kbytes 雖然設置的是頁最小閾值(pages_min),但是頁高閾值(pages_high)和頁低閾值(pages_low)都是根據頁最小閾值(pages_min)計算生成的,它們之間的計算關係如下:

pages_min = min_free_kbytes
pages_low = pages_min*5/4
pages_high = pages_min*3/2

如果系統時不時發生抖動,並且通過 sar -B 觀察到 pgscand 數值很大,那大概率是因為直接記憶體回收導致的,這時可以增大 min_free_kbytes 這個配置選項來及早地觸發後台回收,然後繼續觀察 pgscand 是否會降為 0。

增大了 min_free_kbytes 配置後,這會使得系統預留過多的空閑記憶體,從而在一定程度上降低了應用程式可使用的記憶體量,這在一定程度上浪費了記憶體。極端情況下設置 min_free_kbytes 接近實際物理記憶體大小時,留給應用程式的記憶體就會太少而可能會頻繁地導致 OOM 的發生。

所以在調整 min_free_kbytes 之前,需要先思考一下,應用程式更加關注什麼,如果關注延遲那就適當地增大 min_free_kbytes,如果關注記憶體的使用量那就適當地調小 min_free_kbytes。

NUMA 架構下的記憶體回收策略

什麼是 NUMA 架構?

再說 NUMA 架構前,先給大家說說 SMP 架構,這兩個架構都是針對 CPU 的。

SMP 指的是一種多個 CPU 處理器共享資源的電腦硬體架構,也就是說每個 CPU 地位平等,它們共享相同的物理資源,包括匯流排、記憶體、IO、作業系統等。每個 CPU 訪問記憶體所用時間都是相同的,因此,這種系統也被稱為一致存儲訪問結構(UMA,Uniform Memory Access)。

隨著 CPU 處理器核數的增多,多個 CPU 都通過一個匯流排訪問記憶體,這樣匯流排的頻寬壓力會越來越大,同時每個 CPU 可用頻寬會減少,這也就是 SMP 架構的問題。

SMP 與 NUMA 架構SMP 與 NUMA 架構

為了解決 SMP 架構的問題,就研製出了 NUMA 結構,即非一致存儲訪問結構(Non-uniform memory access,NUMA)。

NUMA 架構將每個 CPU 進行了分組,每一組 CPU 用 Node 來表示,一個 Node 可能包含多個 CPU 。

每個 Node 有自己獨立的資源,包括記憶體、IO 等,每個 Node 之間可以通過互聯模組匯流排(QPI)進行通訊,所以,也就意味著每個 Node 上的 CPU 都可以訪問到整個系統中的所有記憶體。但是,訪問遠端 Node 的記憶體比訪問本地記憶體要耗時很多。

NUMA 架構跟回收記憶體有什麼關係?

在 NUMA 架構下,當某個 Node 記憶體不足時,系統可以從其他 Node 尋找空閑記憶體,也可以從本地記憶體中回收記憶體。

具體選哪種模式,可以通過 /proc/sys/vm/zone_reclaim_mode 來控制。它支援以下幾個選項:

  • 0 (默認值):在回收本地記憶體之前,在其他 Node 尋找空閑記憶體;
  • 1:只回收本地記憶體;
  • 2:只回收本地記憶體,在本地回收記憶體時,可以將文件頁中的臟頁寫回硬碟,以回收記憶體。
  • 4:只回收本地記憶體,在本地回收記憶體時,可以用 swap 方式回收記憶體。

在使用 NUMA 架構的伺服器,如果系統出現還有一半記憶體的時候,卻發現系統頻繁觸發「直接記憶體回收」,導致了影響了系統性能,那麼大概率是因為 zone_reclaim_mode 沒有設置為 0 ,導致當本地記憶體不足的時候,只選擇回收本地記憶體的方式,而不去使用其他 Node 的空閑記憶體。

雖然說訪問遠端 Node 的記憶體比訪問本地記憶體要耗時很多,但是相比記憶體回收的危害而言,訪問遠端 Node 的記憶體帶來的性能影響還是比較小的。因此,zone_reclaim_mode 一般建議設置為 0。

如何保護一個進程不被 OOM 殺掉呢?

在系統空閑記憶體不足的情況,進程申請了一個很大的記憶體,如果直接記憶體回收都無法回收出足夠大的空閑記憶體,那麼就會觸發 OOM 機制,內核就會根據演算法選擇一個進程殺掉。

Linux 到底是根據什麼標準來選擇被殺的進程呢?這就要提到一個在 Linux 內核里有一個 oom_badness() 函數,它會把系統中可以被殺掉的進程掃描一遍,並對每個進程打分,得分最高的進程就會被首先殺掉。

進程得分的結果受下面這兩個方面影響:

  • 第一,進程已經使用的物理記憶體頁面數。
  • 第二,每個進程的 OOM 校準值 oom_score_adj。它是可以通過 /proc/[pid]/oom_score_adj 來配置的。我們可以在設置 -1000 到 1000 之間的任意一個數值,調整進程被 OOM Kill 的幾率。

函數 oom_badness() 里的最終計算方法是這樣的:

// points 代表打分的結果
// process_pages 代表進程已經使用的物理記憶體頁面數
// oom_score_adj 代表 OOM 校準值
// totalpages 代表系統總的可用頁面數
points = process_pages + oom_score_adj*totalpages/1000

用「系統總的可用頁面數」乘以 「OOM 校準值 oom_score_adj」再除以 1000,最後再加上進程已經使用的物理頁面數,計算出來的值越大,那麼這個進程被 OOM Kill 的幾率也就越大

每個進程的 oom_score_adj 默認值都為 0,所以最終得分跟進程自身消耗的記憶體有關,消耗的記憶體越大越容易被殺掉。我們可以通過調整 oom_score_adj 的數值,來改成進程的得分結果:

  • 如果你不想某個進程被首先殺掉,那你可以調整該進程的 oom_score_adj,從而改變這個進程的得分結果,降低該進程被 OOM 殺死的概率。
  • 如果你想某個進程無論如何都不能被殺掉,那你可以將 oom_score_adj 配置為 -1000。

我們最好將一些很重要的系統服務的 oom_score_adj 配置為 -1000,比如 sshd,因為這些系統服務一旦被殺掉,我們就很難再登陸進系統了。

但是,不建議將我們自己的業務程式的 oom_score_adj 設置為 -1000,因為業務程式一旦發生了記憶體泄漏,而它又不能被殺掉,這就會導致隨著它的記憶體開銷變大,OOM killer 不停地被喚醒,從而把其他進程一個個給殺掉。

參考資料:

  • //time.geekbang.org/column/article/277358
  • //time.geekbang.org/column/article/75797
  • //www.jianshu.com/p/e40e8813842f

總結

內核在給應用程式分配物理記憶體的時候,如果空閑物理記憶體不夠,那麼就會進行記憶體回收的工作,主要有兩種方式:

  • 後台記憶體回收:在物理記憶體緊張的時候,會喚醒 kswapd 內核執行緒來回收記憶體,這個回收記憶體的過程非同步的,不會阻塞進程的執行。
  • 直接記憶體回收:如果後台非同步回收跟不上進程記憶體申請的速度,就會開始直接回收,這個回收記憶體的過程是同步的,會阻塞進程的執行。

可被回收的記憶體類型有文件頁和匿名頁:

  • 文件頁的回收:對於乾淨頁是直接釋放記憶體,這個操作不會影響性能,而對於臟頁會先寫回到磁碟再釋放記憶體,這個操作會發生磁碟 I/O 的,這個操作是會影響系統性能的。
  • 匿名頁的回收:如果開啟了 Swap 機制,那麼 Swap 機制會將不常訪問的匿名頁換出到磁碟中,下次訪問時,再從磁碟換入到記憶體中,這個操作是會影響系統性能的。

文件頁和匿名頁的回收都是基於 LRU 演算法,也就是優先回收不常訪問的記憶體。回收記憶體的操作基本都會發生磁碟 I/O 的,如果回收記憶體的操作很頻繁,意味著磁碟 I/O 次數會很多,這個過程勢必會影響系統的性能。

針對回收記憶體導致的性能影響,常見的解決方式。

  • 設置 /proc/sys/vm/swappiness,調整文件頁和匿名頁的回收傾向,盡量傾向於回收文件頁;
  • 設置 /proc/sys/vm/min_free_kbytes,調整 kswapd 內核執行緒非同步回收記憶體的時機;
  • 設置 /proc/sys/vm/zone_reclaim_mode,調整 NUMA 架構下記憶體回收策略,建議設置為 0,這樣在回收本地記憶體之前,會在其他 Node 尋找空閑記憶體,從而避免在系統還有很多空閑記憶體的情況下,因本地 Node 的本地記憶體不足,發生頻繁直接記憶體回收導致性能下降的問題;

在經歷完直接記憶體回收後,空閑的物理記憶體大小依然不夠,那麼就會觸發 OOM 機制,OOM killer 就會根據每個進程的記憶體佔用情況和 oom_score_adj 的值進行打分,得分最高的進程就會被首先殺掉。

我們可以通過調整進程的 /proc/[pid]/oom_score_adj 值,來降低被 OOM killer 殺掉的概率。

完!