1.序言
內存對計算機系統來說是一項非常重要的資源,直接影響著系統運行的性能。最初的時候,系統是直接運行在物理內存上的,這存在著很多的問題,尤其是安全問題。后來出現了虛擬內存,內核和進程都運行在虛擬內存上,進程與進程之間有了空間隔離,增加了安全性。進程與內核之間有特權級的區別,進程運行在非特權級,內核運行在特權級,進程不能訪問內核空間,只能通過系統調用和內核進行交互,內核會對進程進行嚴格的權限檢查和參數檢查,使得系統更加安全。通過虛擬內存訪問物理內存,每次都需要解析頁表,這大大降低了內存訪問的性能,為此CPU的MMU里面加入了TLB用來緩存頁表解析的結果,這樣由于程序的時間局部性和空間局部性,能極大的提高內存訪問的速度。雖然和直接訪問物理內存相比,仍然存在著一些性能損耗,但是損耗已經降到很低了。因此虛擬內存機制在系統安全和性能之間達到了最大的平衡。雖然如此,但是虛擬內存機制也使得計算機的內存系統變得異常復雜,給我們的編程帶來了巨大的挑戰。內存問題,在很多軟件公司里面,都是一個非常重要非常讓人頭疼的問題,今天我們從OOM的角度來幫大家提高一點內存方面的知識,雖然不能說幫助人們來完全解決內存問題,但是也能從一個側面來提高大家分析內存問題相關的能力。2.內存的分配管理
我們已經知道了物理內存、虛擬內存、用戶空間、內核空間之間的區別,下面我們再來深入的了解一下這方面的知識。系統剛啟動的時候是運行在物理內存之上的,然后系統建立了一段足夠自己繼續運行的恒等映射的頁表,也就是把物理地址映射到相同地址的虛擬地址上。等到系統再進一步初始化之后,就會建立完整的頁表來映射物理內存,并把內核映射在虛擬地址空間的高部位,對于32位系統來說是3G之上的內存空間,對于64系統來說,是映射到比較接近虛擬地址頂端的地方。內核初始化之后就會啟動init進程,從而啟動整個用戶空間的所有進程。內核空間和用戶空間的內存管理方式的差別是非常大的,首先內核是不會缺頁也不會換頁的,不會缺頁是指內核的物理內存在啟動時就直接映射好了,使用時直接分配就行了,分配好虛擬內存的同時物理內存也分配好了。不會換頁是指,當系統內存不足時內核自身使用的物理內存不會被swap出去。與此相反,用戶空間的內存分配是先分配虛擬內存,此時并不會直接分配物理內存,而是延遲到程序運行時訪問到哪里的內存,如果這個內存還沒有對應的物理內存,MMU就會報缺頁異常從而陷入內核,執行內核的缺頁異常handler給分配物理內存,并建立頁表映射,然后再回到用戶空間剛才的那個指令處繼續執行。當系統內存不足時,用戶空間使用的物理內存會被swap到磁盤,從而回收物理內存。之后如果進程再訪問這段內存又會再發生缺頁異常從swap處把內存內容加載回來。3.進程的內存空間布局
明白了上面這些,我們再來看看進程的用戶空間內存布局。我們都知道進程的內存空間是由代碼區、數據區、堆區、棧區組成。我們先來看下面的圖,我們以32位進程為例進行講解,64位的數值太大不好畫的,但是原理都是一樣的。
進程啟動之后的內存布局如上圖所示,程序file的代碼段被映射到text區,數據段映射到data區,內核還會幫進程建立堆內存區映射和棧內存區映射,堆一般緊挨著data區的末尾往上增長,棧區在3G下面一點點往下增長。數據區和代碼區是在進程啟動時由內核之間分配好的,之后大小就不會再改變,heap區是隨著程序運行中不斷的malloc/free而增長或者縮小的,stack區是隨時程序運行的局部變量分配釋放而變化的,局部變量的分配釋放是自動的,因此這三個區域也分別被叫做靜態內存、動態內存、自動內存。由此我們可以看出,我們不必對靜態內存、自動內存太操心,我們最應該關系的是動態內存。我們可以brk系統調用擴大heap區域來增加堆內存,然后再自己管理使用堆內存,但是這樣做顯然很麻煩。因此C庫為我們準備了相關的API,malloc、free,來分配和釋放堆內存,這樣就方便到了。 C庫里面最早的malloc實現叫做dlmalloc,在計算機早期還是單CPU時代的時候非常流行,效率也非常高,但是隨著SMP多CPU時代的到來,dlmalloc的缺點也越來越明顯,尤其是多線程同時調用malloc的時候,鎖沖突越來越嚴重,嚴重影響了性能。后來業界相繼出現了ptmalloc、jemalloc、scudo等優秀的malloc庫。 Ptmalloc是Glibc的默認malloc實現,jemalloc庫是首先實現在FreeBSD的malloc庫,后廣泛應用于FireFox、Redis、Netty等眾多產品中,也長期是安卓的默認malloc庫實現。目前安卓已經把malloc庫替換為scudo了,據說scudo在安全和性能方面都很不錯。 程序簡單的時候還好說,但是對于很多產品級的軟件來說,其邏輯結構都非常復雜,進而導致其內存管理方面也很復雜,很容易出現棧溢出、野指針、內存泄漏等問題。我們有著很多方法和規則來規避這些問題,比如誰申請誰釋放,引用計數,智能指針等,但是仍然不能完全解決這些問題。尤其是內存泄漏,在很多公司里面都是令人頭疼的頑疾,對于內存泄漏也存在著很多工具,但是都無法完美的解決這個問題。我們今天要說的不是內存泄漏,而是由于內存泄漏或者內存使用不合理而導致的OOM問題。
4.內存回收基本框架
在講OOM之前,我們先來了解一下內核內存回收的總體框架。內存作為系統最寶貴的資源,總是不夠用的,經常需要進行回收。內存回收可分為兩種方式,同步回收和異步回收,同步回收是在分配內存時發現內存不足直接調用函數進行回收,異步回收是喚醒專門的回收線程kswapd進行回收。我們先看一下它們的總體架構圖,然后再一一說明。
同步回收的話是在alloc_pages時發現內存不足就直接進行回收,首先嘗試的是內存規整,也就是內存碎片整理,比如說系統當前有10個不連續的空閑page,但是你要分配兩個連續的page,顯然是無法分配的,此時就要進行內存規整,通過移動movable page,使空閑page盡量連在一起,這樣能有可能分配出多個連續的page了。如果內存規整之后還是無法分配到內存,此時就會進行頁幀回收了。用戶空間的物理內存可以分為兩種類型,文件頁和匿名頁,文件頁是text data段對應的頁幀,它們都有文件做后備存儲,匿名是棧和堆對應的內存頁,它們沒有對應的文件,一般用swap分區或者swap文件做它們的后備存儲。系統會首先考慮干凈的文件頁進行回收,因為回收它們只要直接丟棄內容就可以了,需要的時候再直接從文件里讀取回來,這樣不會有數據丟失。如果沒有干凈的文件頁或者干凈的文件頁不太多,此時就要從dirty 文件頁和匿名頁進行回收了,因為它們都要進行IO操作,所以會非常的慢。如果頁幀回收也回收不到內存的話,內核只能使出最后一招了,OOM Killer,直接殺進程進行內存回收,雖然這招好像不太文雅,但是也是沒有辦法,因為不這樣做的話,系統沒有多余的內存就沒法繼續運行,系統就會卡死,用戶就會重啟系統,結果更糟,所以殺進程也是最后的無奈之舉。一般能走到這一步都是因為進程有長期或者嚴重的內存泄漏導致的。 異步回收線程kswapd是被周期性的喚醒來執行回收任務的,當然同步回收的時候也會順便喚醒它來一起回收內存。有一點需要注意的是kswapd線程不是per CPU的,而是per node的,是一個NUMA節點一個線程,這是因為內存的分配是per node不是 per CPU的,大部分內存分配都是優先從本node分配或者只能從本node分配,因此哪個node的內存不足了就喚醒哪個node的kswapd線程就行內存回收工作。對于家庭電腦和手機來說都是一個node,所以一般就只有一個kswapd線程。Kswapd完成回收工作之后,它會喚醒kcompactd線程進行內存規整,對的,內存規整也可以異步執行。
5.OOM基本原理
在講內核的OOM Killer之前,我們先來說一下OOM基本概念。OOM,out of memory,就是內存用完了耗盡了的意思。OOM分為虛擬內存OOM和物理內存OOM,兩者是不一樣的。虛擬內存OOM發生在用戶空間,因為用戶空間分配的就是虛擬內存,不能分配物理內存,程序在運行的時候觸發缺頁異常從而需要分配物理內存,內核自身在運行的時候也需要分配物理內存,如果此時物理內存不足了,就會發生物理內存OOM。用戶空間虛擬內存OOM表現為malloc、mmap等內存分配接口返回失敗,錯誤碼為ENOMEM。大家也許會想,虛擬內存會OOM嗎,虛擬內存那么大,對于32位進程來說就有3G,對于64位進程來說至少也得有上百G,應有盡有,而且很多教科書上都說的是虛擬內存可以隨意分配,不受物理內存的限制,事實上真的是這樣嗎,讓我們來看一看。
5.1、虛擬內存OOM
虛擬內存我們是不是可以隨意分配,虛擬空間有多大我們就能分配多少?事實不是這樣的。UNIX世界有個著名的哲學原理,提供機制而不是策略,對于這個問題,Linux也提供了機制,我們可以通過 /proc/sys/vm/overcommit_memory文件來選擇策略。我們有三種選擇,我們可以往這個文件里面寫入0、1、2來選擇不同的策略,這三個值對應的宏是:
-
#define OVERCOMMIT_GUESS 0
-
#define OVERCOMMIT_ALWAYS 1
-
#define OVERCOMMIT_NEVER 2
通過宏名我們也可以大概猜出來是啥意思,下面我們一一解析一下,先從最簡單的開始,OVERCOMMIT_ALWAYS,從名字就可以看出來,只要虛擬內存空間還有富余,你malloc多少內存就給你多少虛擬內存,不管它物理內存到底還夠不夠用。OVERCOMMIT_GUESS,名為GUESS,實在不好guess的,通過看代碼發現,這個模式允許你最多分配的虛擬內存不能超過系統總的物理內存(這里說的總物理內存是物理內存加swap的總和,因為swap在一定意義上也相當于是增加了物理內存),也就是說一個進程分配的總虛擬內存可以和系統的總物理內存相同,還是夠可以的。OVERCOMMIT_NEVER,這個就比較苛刻了,它像一位勤儉持家的媽媽,總是只給你勉強夠用的零花錢,從來不多給一分。我們來看一下它的計算過程,它先計算一個基準值,默認等于50%的物理內存加上swap大小,然后再減去系統管理保留的內存,再減去用戶管理保留的內存,如果系統所有已分配的虛擬內存大于這個值,就返回分配失敗。具體情況大家可以去看代碼:
linux-src/mm/util.c:__vm_enough_memory。
我們再來看一個這個三個宏的公共部分OVERCOMMIT,過度承諾,這個詞想表達什么含義呢,過程承諾always never guess,我們可以看出來,過程承諾指的是,系統允許分配給你的虛擬內存是對你的承諾,后面當你具體用訪問內存的時候,是要給你分配物理內存來實現對你的承諾的,那么這個承諾到底能不能實現呢,如果不能實現會怎么樣呢?
5.2、物理內存OOM
出來混遲早是要還的,分配出去的虛擬內存遲早是要兌現物理內存的。內核運行時會分配物理內存,程序運行時也會通過缺頁異常去分配物理。如果此時沒有足夠的物理內存,內核會通過各種手段來收集物理內存,比如內存規整、回收緩存、swap等,如果這些手段都用盡了,還是沒有收集到足夠的物理內存,那么就只能使出最后一招了,OOM Killer,通過殺死進程來回收內存。代碼實現在linux-src/mm/oom_kill.c:out_of_memory,觸發點在linux-src/mm/page_alloc.c:__alloc_pages_may_oom,當使用各種方法都回收不到不到內存時會調用out_of_memory函數。
out_of_memory函數的實現還是有點復雜,我們把各種檢測代碼和輔助代碼都去除之后,高度簡化之后的函數如下:
這樣就看邏輯就很簡單了,bool out_of_memory(struct oom_control *oc)
{
select_bad_process(oc);
oom_kill_process(oc, "Out of memory");
}
-
1先選擇一個要殺死的進程
-
2殺死它,就是這么簡單。
下面我們來分析一下select_bad_process函數的實現:
static void select_bad_process(struct oom_control *oc)
{
oc->chosen_points = LONG_MIN;
struct task_struct *p;
rcu_read_lock();
for_each_process(p)
if (oom_evaluate_task(p, oc))
break;
rcu_read_unlock();
}
函數首先把chosen_points初始化為最小的Long值,這個值是用來比較所有的oom_score值,最后誰的值最大就選中哪個進程。然后函數已經遍歷所有進程,計算其oom_score,并更新chosen_points和被選中的task,有點類似于選擇排序。我們繼續看oom_evaluate_task函數是如何評估每個進程的函數。
static int oom_evaluate_task(struct task_struct *task, void *arg)
{
struct oom_control *oc = arg;
long points;
if (oom_unkillable_task(task))
goto next;
/* p may not have freeable memory in nodemask */
if (!is_memcg_oom(oc) && !oom_cpuset_eligible(task, oc))
goto next;
if (oom_task_origin(task)) {
points = LONG_MAX;
goto select;
}
points = oom_badness(task, oc->totalpages);
if (points == LONG_MIN || points < oc->chosen_points)
goto next;
select:
if (oc->chosen)
put_task_struct(oc->chosen);
get_task_struct(task);
oc->chosen = task;
oc->chosen_points = points;
next:
return 0;
abort:
if (oc->chosen)
put_task_struct(oc->chosen);
oc->chosen = (void *)-1UL;
return 1;
}
此函數首先會跳軌所有不適合kill的進程,如init進程、內核線程、OOM_DISABLE進程等。然后通過select_bad_process算出此進程的得分points 也就是oom_score,并和上一次的勝出進程進行比較,如果小的會話就會goto next 返回,如果大的話就會更新oc->chosen的task 和 chosen_points也就是目前最高的oom_score。那么oom_badness是如何計算的呢?
long oom_badness(struct task_struct *p, unsigned long totalpages)
{
long points;
long adj;
if (oom_unkillable_task(p))
return LONG_MIN;
p = find_lock_task_mm(p);
if (!p)
return LONG_MIN;
adj = (long)p->signal->oom_score_adj;
if (adj == OOM_SCORE_ADJ_MIN ||
&p->mm->flags) ||
{
task_unlock(p);
return LONG_MIN;
}
points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) +
/ PAGE_SIZE;
task_unlock(p);
adj *= totalpages / 1000;
points += adj;
return points;
}
oom_badness首先把unkiller的進程也就是init進程內核線程直接返回LONG_MIN,這樣他們就不會被選中而殺死了,這里看好像和前面的檢測冗余了,但是實際上這個函數還被/proc/
可能很多會覺得這里講的不對,和自己在網上的看到的邏輯不一樣,那是因為網上有很多講oom_score算法的文章都是基于2.6版本的內核講的,那個算法比較復雜,會考慮進程的nice值,nice值小的,oom_score會相應的降低,也會考慮進程的運行時間,運行時間越長,oom_score值也會相應的降低,因為當時認為進程運行的時間長消耗內存多是合理的。但是這個算法會讓那些緩慢內存泄漏的進程逃脫制裁。因此后來這個算法就改成現在這樣的了,只考慮誰用的內存多就殺誰,簡潔高效。
5.3、安卓LMK簡介
除了OOM Killer,Android上還開發了low memory killer機制,我們在此也簡單介紹一下。LMK是在系統內存較低時就開始殺進程,而不是等到內存不足時再殺。LMK復用了OOMKiller 的 /proc/
6.總結
Linux內存管理是一門龐大的學問,內存回收作為其中的一部分也是十分復雜的,我們今天給大家大概介紹了內核的內存回收概覽,并詳細的介紹了OOM Killer機制,也算是拋磚引玉讓大家對內存回收有個初步的認識。另外如果你在工作中遇到你的進程莫名其妙掛掉了,如果你能在內核log中找到OOM Killer的log的話(搜索 out of memory 關鍵字并過濾你的進程名),那么你就可以快速的斷定你的是因為系統內存不足了,而且你的進程占用物理內存最多,所以被殺了,此時你就有很大的理由懷疑自己的進程內存泄漏了,就可以開始進行內存相關問題的排查了。
原文標題:Linux OOM 基本原理解析
文章出處:【微信公眾號:Linux閱碼場】歡迎添加關注!文章轉載請注明出處。
審核編輯:湯梓紅
聲明:本文內容及配圖由入駐作者撰寫或者入駐合作網站授權轉載。文章觀點僅代表作者本人,不代表電子發燒友網立場。文章及其配圖僅供工程師學習之用,如有內容侵權或者其他違規問題,請聯系本站處理。
舉報投訴
-
Linux
+關注
關注
87文章
11304瀏覽量
209524 -
計算機
+關注
關注
19文章
7494瀏覽量
87963 -
內存
+關注
關注
8文章
3025瀏覽量
74056
原文標題:Linux OOM 基本原理解析
文章出處:【微信號:LinuxDev,微信公眾號:Linux閱碼場】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
Linux內存管理之頁面回收
請求調頁機制,只要用戶態進程繼續執行,他們就能獲得頁框,然而,請求調頁沒有辦法強制進程釋放不再使用的頁框。因此,遲早所有空閑內存將被分配給進程和高速緩存,Linux內核的頁面回收算法(
發表于 05-19 14:09
?1092次閱讀
基于Linux內存管理與Android內存分配機制
Android采取了一種有別于Linux的進程管理策略,有別于Linux的在進程活動停止后就結束該進程,Android把這些進程都保留在內存中,直到系統需要更多
評論