Linux核心記憶體效能調優的一些筆記

語言: CN / TW / HK

一、前言

在工作生活中,我們時常會遇到一些效能問題:比如手機用久了,在滑動視窗或點選 APP 時會出現頁面反應慢、卡頓等情況;比如執行在某臺伺服器上程序的某些效能指標(影響使用者體驗的 PCT99 指標等)不達預期,產生告警等;造成效能問題的原因多種多樣,可能是網路延遲高、磁碟 IO 慢、排程延遲高、記憶體回收等,這些最終都可能影響到使用者態程序,進而被使用者感知。

在 Linux 伺服器場景中,記憶體是影響效能的主要因素之一,本文從記憶體管理的角度,總結歸納了一些常見的影響因素(比如記憶體回收、Page Fault 增多、跨 NUMA 記憶體訪問等),並介紹其對應的調優方法。

二、記憶體回收

作業系統總是會盡可能利用速度更快的儲存介質來快取速度更慢的儲存介質中的內容,這樣就可以顯著的提高使用者訪問速度。比如,我們的檔案一般都儲存在磁碟上,磁碟對於程式執行的記憶體來說速度很慢,因此作業系統在讀寫檔案時,都會將磁碟中的檔案內容快取到記憶體上(也叫做 page cache),這樣下次再讀取到相同內容時就可以直接從記憶體中讀取,不需要再去訪問速度更慢的磁碟,從而大大提高檔案的讀寫效率。上述情況需要在記憶體資源充足的前提條件下,然而在記憶體資源緊缺時,作業系統自身難保,會選擇儘可能回收這些快取的記憶體,將其用到更重要的任務中去。這時候,如果使用者再去訪問這些檔案,就需要訪問磁碟,如果此時磁碟也很繁忙,那麼使用者就會感受到明顯的卡頓,也就是效能變差了。

在 Linux 系統中,記憶體回收分為兩個層面:整機和 memory cgroup。

在整機層面,設定了三條水線:min、low、high;當系統 free 記憶體降到 low 水線以下時,系統會喚醒kswapd 執行緒進行非同步記憶體回收,一直回收到 high 水線為止,這種情況不會阻塞正在進行記憶體分配的程序;但如果 free 記憶體降到了 min 水線以下,就需要阻塞記憶體分配程序進行回收,不然就有 OOM(out of memory)的風險,這種情況下被阻塞程序的記憶體分配延遲就會提高,從而感受到卡頓。

圖 1. per-zone watermark

這些水線可以通過核心提供的 /proc/sys/vm/watermark_scale_factor 介面來進行調整,該介面 合法取值的範圍為 [0, 1000],預設為 10,當該值設定為 1000 時,意味著 low 與 min 水線,以及 high 與 low 水線間的差值都為總記憶體的 10% (1000/10000)。

針對 page cache 型的業務場景,我們可以通過該介面擡高 low 水線,從而更早的喚醒 kswapd 來進行非同步的記憶體回收,減少 free 記憶體降到 min 水線以下的概率,從而避免阻塞到業務程序,以保證影響業務的效能指標。

在 memory cgroup 層面,目前核心沒有設定水線的概念,當記憶體使用達到 memory cgroup 的記憶體限制後,會阻塞當前程序進行記憶體回收。不過核心在 v5.19核心 中為 memory cgroup提供了 memory.reclaim 介面,使用者可以向該介面寫入想要回收的記憶體大小,來提早觸發 memory cgroup 進行記憶體回收,以避免阻塞 memory cgroup 中的程序。

三、Huge Page

記憶體作為寶貴的系統資源,一般都採用延遲分配的方式,應用程式第一次向分配的記憶體寫入資料的時候會觸發 Page Fault,此時才會真正的分配物理頁,並將物理頁幀填入頁表,從而與虛擬地址建立對映。

圖 2. Page Table

此後,每次 CPU 訪問記憶體,都需要通過 MMU 遍歷頁表將虛擬地址轉換成實體地址。為了加速這一過程,一般都會使用 TLB(Translation-Lookaside Buffer)來快取虛擬地址到實體地址的對映關係,只有 TLB cache miss 的時候,才會遍歷頁表進行查詢。

頁的預設大小一般為 4K, 隨著應用程式越來越龐大,使用的記憶體越來越多,記憶體的分配與地址翻譯對效能的影響越加明顯。試想,每次訪問新的 4K 頁面都會觸發 Page Fault,2M 的頁面就需要觸發 512 次才能完成分配。

另外 TLB cache 的大小有限,過多的對映關係勢必會產生 cacheline 的沖刷,被沖刷的虛擬地址下次訪問時又會產生 TLB miss,又需要遍歷頁表才能獲取實體地址。

對此,Linux 核心提供了大頁機制。 上圖的 4 級頁表中,每個 PTE entry 對映的物理頁就是 4K,如果採用 PMD entry 直接對映物理頁,則一次 Page Fault 可以直接分配並對映 2M 的大頁,並且只需要一個 TLB entry 即可儲存這 2M 記憶體的對映關係,這樣可以大幅提升記憶體分配與地址翻譯的速度。

因此,一般推薦佔用大記憶體應用程式使用大頁機制分配記憶體。當然大頁也會有弊端:比如初始化耗時高,程序記憶體佔用可能變高等。

可以使用 perf 工具對比程序使用大頁前後的 PageFault 次數的變化:

perf stat -e page-faults -p <pid> -- sleep 5

目前核心提供了兩種大頁機制,一種是需要提前預留的靜態大頁形式,另一種是透明大頁(THP, Transparent Huge Page) 形式。

3.1 靜態大頁

首先來看靜態大頁,也叫做 HugeTLB。靜態大頁可以設定 cmdline 引數在系統啟動階段預留,比如指定大頁 size 為 2M,一共預留 512 個這樣的大頁:

hugepagesz=2M hugepages=512

還可以在系統執行時動態預留,但該方式可能因為系統中沒有足夠的連續記憶體而預留失敗。

  • 預留預設 size(可以通過 cmdline 引數 default_hugepagesz=指定size)的大頁:
echo 20 > /proc/sys/vm/nr_hugepages

  • 預留特定 size 的大頁:
echo 5 > /sys/kernel/mm/hugepages/hugepages-*/nr_hugepages

  • 預留特定 node 上的大頁:
echo 5 > /sys/devices/system/node/node*/hugepages/hugepages-*/nr_hugepages

當預留的大頁個數小於已存在的個數,則會釋放多餘大頁(前提是未被使用)

程式設計中可以使用 mmap(MAP_HUGETLB) 申請記憶體

詳細使用可以參考核心文件

這種大頁的優點是一旦預留成功,就可以滿足程序的分配請求,還避免該部分記憶體被回收;缺點是:

  • 需要使用者顯式地指定預留的大小和數量

  • 需要應用程式適配,比如:

    • mmap、shmget 時指定 MAP_HUGETLB;
    • 掛載 hugetlbfs,然後 open 並 mmap

    當然也可以使用開源 libhugetlbfs.so,這樣無需修改應用程式

  • 預留太多大頁記憶體後,free 記憶體大幅減少,容易觸發系統記憶體回收甚至 OOM

    緊急情況下可以手動減少 nr_hugepages,將未使用的大頁釋放回系統;也可以使用 v5.7 引入的HugeTLB + CMA 方式,細節讀者可以自行查閱。

3.2 透明大頁

再來看透明大頁,在 THP always 模式下,會在 Page Fault 過程中,為符合要求的 vma 儘量分配大頁進行對映;如果此時分配大頁失敗,比如整機實體記憶體碎片化嚴重,無法分配出連續的大頁記憶體,那麼就會 fallback 到普通的 4K 進行對映,但會記錄下該程序的地址空間 mm_struct;然後 THP 會在後臺啟動khugepaged 執行緒,定期掃描這些記錄的 mm_struct,並進行合頁操作。因為此時可能已經能分配出大頁記憶體了,那麼就可以將此前 fallback 的 4K 小頁對映轉換為大頁對映,以提高程式效能。整個過程完全不需要使用者程序參與,對使用者程序是透明的,因此稱為透明大頁。

雖然透明大頁使用起來非常方便、智慧,但也有一定的代價:

1)程序記憶體佔用可能遠大所需:因為每次Page Fault 都儘量分配大頁,即使此時應用程式只讀寫幾KB

2)可能造成效能抖動:

  • 在第 1 種程序記憶體佔用可能遠大所需的情況下,可能造成系統 free 記憶體更少,更容易觸發記憶體回收;系統記憶體也更容易碎片化。
  • khugepaged 執行緒合頁時,容易觸發頁面規整甚至記憶體回收,該過程費時費力,容易造成 sys cpu 上升。
  • mmap lock 本身是目前核心的一個性能瓶頸,應當儘量避免 write lock 的持有,但 THP 合頁等操作都會持有寫鎖,且耗時較長(資料拷貝等),容易激化 mmap lock 鎖競爭,影響效能。

因此 THP 還支援 madvise 模式,該模式需要應用程式指定使用大頁的地址範圍,核心只對指定的地址範圍做 THP 相關的操作。這樣可以更加針對性、更加細緻地優化特定應用程式的效能,又不至於造成反向的負面影響。

可以通過 cmdline 引數和 sysfs 介面設定 THP 的模式:

  • cmdline 引數:
transparent_hugepage=madvise

  • sysfs 介面:
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled

詳細使用可以參考核心文件

四、mmap_lock 鎖

上一小節有提到 mmap_lock 鎖,該鎖是記憶體管理中的一把知名的大鎖,保護了諸如mm_struct 結構體成員、 vm_area_struct 結構體成員、頁表釋放等很多變數與操作。

mmap_lock 的實現是讀寫訊號量, 當寫鎖被持有時,所有的其他讀鎖與寫鎖路徑都會被阻塞。Linux 核心已經儘可能減少了寫鎖的持有場景以及時間,但不少場景還是不可避免的需要持有寫鎖,比如 mmap 以及 munmap 路徑、mremap 路徑和 THP 轉換大頁對映路徑等場景。

應用程式應該避免頻繁的呼叫會持有 mmap_lock 寫鎖的系統呼叫 (syscall), 比如有時可以使用 madvise(MADV_DONTNEED)釋放實體記憶體,該引數下,madvise 相比 munmap 只持有 mmap_lock 的讀鎖,並且只釋放實體記憶體,不會釋放 VMA 區域,因此可以再次訪問對應的虛擬地址範圍,而不需要重新呼叫 mmap 函式。

另外對於 MADV_DONTNEED,再次訪問還是會觸發 Page Fault 分配實體記憶體並填充頁表,該操作也有一定的效能損耗。 如果想進一步減少這部分損耗,可以改為 MADV_FREE 引數,該引數也只會持有 mmap_lock 的讀鎖,區別在於不會立刻釋放實體記憶體,會等到記憶體緊張時才進行釋放,如果在釋放之前再次被訪問則無需再次分配記憶體,進而提高記憶體訪問速度。

一般 mmap_lock 鎖競爭激烈會導致很多 D 狀態程序(TASK_UNINTERRUPTIBLE),這些 D 程序都是程序組的其他執行緒在等待寫鎖釋放。因此可以打印出所有 D 程序的呼叫棧,看是否有大量 mmap_lock 的等待。

for i in `ps -aux | grep " D" | awk '{ print $2}'`; do echo $i; cat /proc/$i/stack; done

核心社群專門封裝了 mmap_lock 相關函式,並在其中增加了 tracepoint,這樣可以使用 bpftrace 等工具統計持有寫鎖的程序、呼叫棧等,方便排查問題,確定優化方向。

bpftrace -e 'tracepoint:mmap_lock:mmap_lock_start_locking /args->write == true/{ @[comm, kstack] = count();}'

五、跨 numa 記憶體訪問

在 NUMA 架構下,CPU 訪問本地 node 記憶體的速度要大於遠端 node,因此應用程式應儘可能訪問本地 node 上的記憶體。可以通過 numastat 工具檢視 node 間的記憶體分配情況:

  • 觀察整機是否有很多 other_node 指標(遠端記憶體訪問)上漲:
watch -n 1 numastat -s

  • 檢視單個程序在各個node上的記憶體分配情況:
numastat -p <pid>

5.1 綁 node

可以通過 numactl 等工具把程序繫結在某個 node 以及對應的 CPU 上,這樣該程序只會從該本地 node 上分配記憶體。

但這樣做也有相應的弊端, 比如:該 node 剩餘記憶體不夠時,程序也無法從其他 node 上分配記憶體,只能期待記憶體回收後釋放足夠的記憶體,而如果進入直接記憶體回收會阻塞記憶體分配,就會有一定的效能損耗。

此外,程序組的執行緒數較多時,如果都繫結在一個 node 的 CPU 上,可能會造成 CPU 瓶頸,該損耗可能比遠端 node 記憶體訪問還大,比如 ngnix 程序與網絡卡就推薦繫結在不同的 node 上,這樣雖然網絡卡收包時分配的記憶體在遠端 node 上,但減少了本地 node 的 CPU 上的網絡卡中斷,反而可以獲得更好的效能提升。

5.2 numa balancing

核心還提供了 numa balancing 機制,可以通過 /proc/sys/kernel/numa_balancing 檔案或者cmdline引數 numa_balancing=進行開啟。

該機制可以動態的將程序訪問的 page 從遠端 node 遷移到本地 node 上,從而使程序可以儘可能的訪問本地記憶體。

但該機制實現也有相應的代價, 在 page 的遷移是通過 Page Fault 機制實現的,會有相應的效能損耗;另外如果遷移時找不到合適的目標 node,可能還會把程序遷移到正在訪問的 page 的 node 的 CPU 上,這可能還會導致 cpu cache miss,從而對效能造成更大的影響。

因此需要根據業務程序的具體行為,來決定是否開啟 numa balancing 功能。

六、總結

效能優化一直是大家關注的話題,其優化方向涉及到 CPU 排程、記憶體、IO等,本文重點針對記憶體優化提出了幾點思路。但是魚與熊掌不可兼得,文章提到的調優操作都有各自的優點和缺點,不存在一個適用於所有情況的優化方法。針對於不同的 workload,需要分析出具體的效能瓶頸,從而採取對應的調優方法,不能一刀切的進行設定。在沒有發現明顯效能抖動的情況下,往往可以繼續保持當前配置。