Linux OOM 基本原理解析

作者簡介:

程磊_,__一線碼農,在某手機公司擔任系統開發工程師,日常喜歡研究內核基本原理__。_

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 來選擇不同的策略,這三個值對應的宏是:

通過宏名我們也可以大概猜出來是啥意思,下面我們一一解析一下,先從最簡單的開始,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");
}

這樣就看邏輯就很簡單了,

oom_kill_process 函數的目的很簡單,但是實現過程也有點複雜,這裏就不展開分析了,大家可以自行去看一下代碼。我們重點分析一下 select_bad_process 函數的邏輯,select_bad_process 主要是依靠 oom_score 來進行進程選擇的。我們先來看一下和每一個進程相關聯的三個文件。

/proc//oom_score 系統計算出來的 oom_score 值,只讀文件,取值範圍 0 –- 1000,0 代表 never kill,1000 代表 aways kill,值越大,進程被選中的概率越大。

/proc//oom_score_adj

讓用戶空間調節 oom_score 之值的接口,root 可讀寫,取值範圍 -1000 --- 1000,默認爲 0,若爲 -1000,則 oom_score 加上此值一定小於等於 0,從而變成 never kill 進程。OS 可以把一些關鍵的系統進程的 oom_score_adj 設爲 -1000,從而避免被 oom kill。

/proc//oom_adj

舊的接口文件,爲兼容而保留,root 可讀寫,取值範圍 -16 — 15,會被線性映射到 oom_score_adj,特殊值 -17 代表 OOM_DISABLE,大家儘量不用再用此接口。

下面我們來分析一下 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 ||
      test_bit(MMF_OOM_SKIP, &p->mm->flags) ||
      in_vfork(p)) {
    task_unlock(p);
    return LONG_MIN;
  }
  points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) +
    mm_pgtables_bytes(p->mm) / PAGE_SIZE;
  task_unlock(p);
  adj *= totalpages / 1000;
  points += adj;
  return points;
}

oom_badness 首先把 unkiller 的進程也就是 init 進程內核線程直接返回 LONG_MIN,這樣他們就不會被選中而殺死了,這裏看好像和前面的檢測冗餘了,但是實際上這個函數還被 /proc//oom_score 的 show 函數調用用來顯示數值,所以還是有必要的,這裏也說明了一點,oom_score 的值是不保留的,每次都是即時計算。然後又把 oom_score_adj 爲 -1000 的進程直接也返回 LONG_MIN,這樣用戶空間專門設置的進程就不會被 kill 了。最後就是計算 oom_score 了,計算方法比較簡單,就是此進程使用的 RSS 駐留內存、頁表、swap 之和越大,也就是此進程所用的總內存越大,oom_score 的值就越大,邏輯簡單直接,誰用的物理內存最多就殺誰,這樣就能夠回收更多的物理內存,而且使用內存最多的進程很可能是內存泄漏了,所以此算法雖然很簡單,但是也很合理。

可能很多會覺得這裏講的不對,和自己在網上的看到的邏輯不一樣,那是因爲網上有很多講 oom_score 算法的文章都是基於 2.6 版本的內核講的,那個算法比較複雜,會考慮進程的 nice 值,nice 值小的,oom_score 會相應的降低,也會考慮進程的運行時間,運行時間越長,oom_score 值也會相應的降低,因爲當時認爲進程運行的時間長消耗內存多是合理的。但是這個算法會讓那些緩慢內存泄漏的進程逃脫制裁。因此後來這個算法就改成現在這樣的了,只考慮誰用的內存多就殺誰,簡潔高效。

5.3、安卓 LMK 簡介

除了 OOM Killer,Android 上還開發了 low memory killer 機制,我們在此也簡單介紹一下。LMK 是在系統內存較低時就開始殺進程,而不是等到內存不足時再殺。LMK 複用了 OOM Killer 的 /proc//oom_score_adj 文件接口,但是沒有使用 /proc//oom_score。LMK 僅根據 oom_score_adj 值的大小選擇殺進程,而不會考慮進程本身佔用內存的大小。apk 進程的 oom_score_adj 的值由 AMS 根據 apk 的生命週期和其他一些因素進行設置,會動態變。apk 進程的 oom_score_adj 都大於等於 0,native 進程的 oom_score_adj 的值由 rc 文件設置或者繼承自父進程,一般都是靜態的,不會變化,其值一般都小於 0。很多重要的系統進程的 oom_score_adj 值爲 -1000,在 oom killer 的情況下也免殺。LMK 默認只管理 oom_score_adj 大於等於 0 的進程,所以只能殺死 apk 進程。

LMK 的優點是,1. 它在系統內存開始緊張時就開始殺進程,而不是拖到最後一刻一點內存都沒有了纔去殺進程,2. 安卓 framework 對 apk 的運行狀態很瞭解,知道哪個進程重要不重要,哪個進程處於什麼狀態,能更針對性的選擇殺哪些進程。LMK 和 OOM Killer 共同構成了系統內存不足的兩道防線,LMK 在前,內存有些不足時就殺進程,OOM killer 在後,作爲最後一道屏障,作最後的努力去回收內存。

6. 總結

Linux 內存管理是一門龐大的學問,內存回收作爲其中的一部分也是十分複雜的,我們今天給大家大概介紹了內核的內存回收概覽,並詳細的介紹了 OOM Killer 機制,也算是拋磚引玉讓大家對內存回收有個初步的認識。另外如果你在工作中遇到你的進程莫名其妙掛掉了,如果你能在內核 log 中找到 OOM Killer 的 log 的話(搜索 out of memory 關鍵字並過濾你的進程名),那麼你就可以快速的斷定你的是因爲系統內存不足了,而且你的進程佔用物理內存最多,所以被殺了,此時你就有很大的理由懷疑自己的進程內存泄漏了,就可以開始進行內存相關問題的排查了。

致謝

非常感謝華章圖書贈送的《深度探索 Linux 系統虛擬化原理與實現》,書寫的很不錯,有興趣的同學可以購買學習一下。

參考文獻:

linux/Documentation/vm/overcommit-accounting.rst

https://lwn.net/Articles/317814/

https://lwn.net/Articles/359998/

https://lwn.net/Articles/785709/

https://lwn.net/Articles/668126/

Linux 閱碼場 專業的 Linux 技術社區和 Linux 操作系統學習平臺,內容涉及 Linux 內核, Linux 內存管理, Linux 進程管理, Linux 文件系統和 IO,Linux 性能調優, Linux 設備驅動以及 Linux 虛擬化和雲計算等各方各面.

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/5siWY_-2G3O8IN_CgvU_3g