一、什么是 PSI
Pressure Stall Information 提供了一種評估系統資源壓力的方法。系統有三個基礎資源:CPU、Memory 和 IO,無論這些資源配置如何增加,似乎永遠無法滿足軟件的需求。一旦產生資源競爭,就有可能帶來延遲增大,使用戶體驗到卡頓。
如果沒有一種相對准確的方法檢測系統的資源壓力程度,有兩種后果。一種是資源使用者過度克制,沒有充分使用系統資源;另一種是經常產生資源競爭,過度使用資源導致等待延遲過大。准確的檢測方法可以幫忙資源使用者確定合適的工作量,同時也可以幫助系統制定高效的資源調度策略,最大化利用系統資源,最大化改善用戶體驗。
Facebook 在 2018 年開源了一套解決重要計算集群管理問題的 Linux 內核組件和相關工具,PSI 是其中重要的資源度量工具,它提供了一種實時檢測系統資源競爭程度的方法,以競爭等待時間的方式呈現,簡單而准確地供用戶以及資源調度者進行決策。
二、為什么出現 PSI
在此之前,Linux 也有一些資源壓力的評估方法,最具代表性的是 load average 和 vmpressure。
1、Load Average
系統平均負載是指在特定時間間隔內運行隊列中(在 CPU 上運行或者等待運行)的平均進程數。Linux 進程中 running 和 uninterruptible 狀態進程數量加起來的占比就是當前系統 load。其具體算法為:
for_each_possible_cpu(cpu)
nr_active += cpu_of(cpu)->nr_running + cpu_of(cpu)->nr_uninterruptible;
avenrun[n] = avenrun[0] * exp_n + nr_active * (1 - exp_n)
Linux 命令 uptime、top 等都可以獲得 load average 的輸出,例如:
$ uptime
16:08 up 13 days, 5:06, 1 user, load averages: 0.15 0.41 0.26
Load averages 的三個值分別代表最近 1/5/15 分鍾的平均系統負載。在多核系統中,這些值有可能經常大於1,比如四核系統的 100% 負載為 4,八核系統的 100% 負載為 8。
Loadavg 有它固有的一些缺陷:
-
uninterruptible的進程,無法區分它是在等待 CPU 還是 IO。無法精確評估單個資源的競爭程度;
-
最短的時間粒度是 1 分鍾,以 5 秒間隔采樣。很難精細化管理資源競爭毛刺和短期過度使用;
-
結果以進程數量呈現,還要結合 cpu 數量運算,很難直觀判斷當前系統資源是否緊張,是否影響任務吞吐量。
2、Vmpressure
Vmpressure 的計算在每次系統嘗試做do_try_to_free_pages 回收內存時進行。其計算方法非常簡單:
(1 - reclaimed/scanned)*100,也就是說回收失敗的內存頁越多,內存壓力越大。
同時 vmpressure 提供了通知機制,用戶態或內核態程序都可以注冊事件通知,應對不同等級的壓力。
默認定義了三級壓力:low/medium/critical。low 代表正常回收;medium 代表中等壓力,可能存在頁交換或回寫,默認值是 65%;critical 代表內存壓力很大,即將 OOM,建議應用即可采取行動,默認值是 90%。
vmpressure 也有一些缺陷:
-
結果僅體現內存回收壓力,不能反映系統在申請內存上的資源等待時間;
-
計算周期比較粗;
-
粗略的幾個等級通知,無法精細化管理。
三、PSI 軟件架構
1、藍圖
PSI 相關的軟件結構圖如下所示:
對上,PSI 模塊通過文件系統節點向用戶空間開放兩種形態的接口。一種是系統級別的接口,即輸出整個系統級別的資源壓力信息。另外一種是結合 control group,進行更精細化的分組。
對下,PSI 模塊通過在內存管理模塊以及調度器模塊中插樁,我們可以跟蹤每一個任務由於 memory、io 以及 CPU 資源而進入等待狀態的信息。例如系統中處於 iowait 狀態的 task 數目、由於等待 memory 資源而處於阻塞狀態的任務數目。
基於 task 維度的信息,PSI 模塊會將其匯聚成 PSI group 上的 per cpu 維度的時間信息。例如該cpu上部分任務由於等待 IO 操作而阻塞的時間長度(CPU 並沒有浪費,還有其他任務在執行)。PSI group 還會設定一個固定的周期去計算該采樣周期內核的當前 psi 值(基於該 group 的 per cpu 時間統計信息)。
為了避免 PSI 值的抖動,實際上上層應用通過系統調用獲取某個 PSI group 的壓力值的時候會上報近期一段時間值的滑動平均值。
2、PSI 用戶接口定義
每類資源的壓力信息都通過 proc 文件系統的獨立文件來提供,路徑為 /proc/pressure/ -- cpu, memory, and io.
其中 CPU 壓力信息格式如下:
some avg10=2.98 avg60=2.81 avg300=1.41 total=268109926
memory 和 io 格式如下:
some avg10=0.30 avg60=0.12 avg300=0.02 total=4170757
full avg10=0.12 avg60=0.05 avg300=0.01 total=1856503
avg10、avg60、avg300 分別代表 10s、60s、300s 的時間周期內的阻塞時間百分比。total 是總累計時間,以毫秒為單位。
some 這一行,代表至少有一個任務在某個資源上阻塞的時間占比,full 這一行,代表所有的非idle任務同時被阻塞的時間占比,這期間 cpu 被完全浪費,會帶來嚴重的性能問題。我們以 IO 的 some 和 full 來舉例說明,假設在 60 秒的時間段內,系統有兩個 task,在 60 秒的周期內的運行情況如下圖所示:
紅色陰影部分表示任務由於等待 IO 資源而進入阻塞狀態。Task A 和 Task B 同時阻塞的部分為 full,占比 16.66%;至少有一個任務阻塞(僅 Task B 阻塞的部分也計算入內)的部分為 some,占比 50%。
some 和 full 都是在某一時間段內阻塞時間占比的總和,阻塞時間不一定連續,如下圖所示:
IO 和 memory 都有 some 和 full 兩個維度,那是因為的確有可能系統中的所有任務都阻塞在 IO 或者 memory 資源,同時 CPU 進入 idle 狀態。
但是 CPU 資源不可能出現這個情況:不可能全部的 runnable 的任務都等待 CPU 資源,至少有一個 runnable 任務會被調度器選中占有 CPU 資源,因此 CPU 資源沒有 full 維度的 PSI 信息呈現。
通過這些阻塞占比數據,我們可以看到短期以及中長期一段時間內各種資源的壓力情況,可以較精確的確定時延抖動原因,並制定對應的負載管理策略。
四、源碼解析
PSI 相關源代碼比較簡單,核心功能都在 kernel/sched/psi.c 文件中實現。
1、初始化
第一步,在 psi_proc_init 函數中完成 PSI 接口文件節點的創建。首先建立proc/pressure目錄,然后 3 個 proc_create 函數創建了 io、memory 和 cpu 三個 proc 屬性文件:
proc_mkdir("pressure", NULL);
proc_create("pressure/io", 0, NULL, &psi_io_fops);
proc_create("pressure/memory", 0, NULL, &psi_memory_fops);
proc_create("pressure/cpu", 0, NULL, &psi_cpu_fops);
第二步,在 psi_init 函數中初始化統計管理結構和更新任務的周期:
psi_period = jiffies_to_nsecs(PSI_FREQ);
group_init(&psi_system);
我們把相關的任務組成一個 group,然后針對這個任務組計算其 PSI 值。如果不支持 control group,那么實際上系統中只有一個 PSI group:
static DEFINE_PER_CPU(struct psi_group_cpu, system_group_pcpu);
static struct psi_group psi_system = {
.pcpu = &system_group_pcpu,
};
如果支持 cgroup(需要 mount cgroup2 文件系統),那么系統中會有多個 PSI group,形成層級結構。我們可以在掛載的 cgroup 文件系統下面獲取 per-group 的 PSI 信息。
我們也可以從 proc 文件系統下面獲取整個系統級別的 PSI 信息。Cgroup 中各個分組的 PSI 信息跟蹤是類似的,后續我們的文章主要基於系統級別的 PSI 信息跟蹤來描述代碼邏輯流程。
struct psi_group 用來定義 PSI 統計管理數據,其中包括各 cpu 狀態、周期性更新函數、更新時間戳、以及各 PSI 狀態的時間記錄。PSI 狀態一共有六種:
enum psi_states {
PSI_IO_SOME,
PSI_IO_FULL,
PSI_MEM_SOME,
PSI_MEM_FULL,
PSI_CPU_SOME,
/* Only per-CPU, to weigh the CPU in the global average: */
PSI_NONIDLE,
NR_PSI_STATES,
};
前 5 種狀態的定義在本文上一節已經介紹,PSI_NONIDLE 是指 CPU 非空閑狀態,最終的時間占比是以 CPU 非空閑時間來計算的。
2、狀態埋點
整個 PSI 技術的核心難點其實在於如何准確捕捉到任務狀態的變化,並統計狀態持續時間。我們首先看看 task 維度的埋點信息。
PSI 作者在 task_struct 結構中加入了一個成員:PSI_flags,用於標注任務所處狀態,狀態定義有以下幾種:
#define TSK_IOWAIT(1 << NR_IOWAIT)
#define TSK_MEMSTALL(1 << NR_MEMSTALL)
#define TSK_RUNNING(1 << NR_RUNNING)
狀態的標記主要通過函數 psi_task_change,這個函數在任務每次進出調度隊列時,都會被調用,從而准確標注任務狀態。
其中 psi_memstall_tick 並沒有任務狀態的轉換,只是在每個調度 tick 及時更新各狀態的積累時間。
3、周期性統計
周期性的更新任務 psi_update_work 函數非常簡單,更新統計數據,然后設定下一次任務喚醒的時間。周期間隔為 PSI_FREQ,2s。
更新統計數據的函數 update_stats,主要有兩步:
第一步 get_recent_times,對每個 cpu 更新各狀態的時間並統計各狀態系統總時間;
第二步 calc_avgs,更新每個狀態的 10s、60s、300s 三個間隔的時間占比。
計算一個 PSI group 的 PS I值的過程示意圖如下所示:
從底層看,一個 psi group 的 PSI 值是基於任務數目統計的,當一個任務狀態發生變化的時候,首先需要遍歷該任務所屬的 PSI group(如果不支持 cgroup,那么系統只有一個全局的 PSI group),更新 PSI group 的 task counter。
一旦 task counter 發生了變化,那么我們需要進一步更新對應 CPU 上的時間統計信息。例如 iowait task count 從 0 變成 1,那么 SOME 維度的 io wait time 需要更新。具體的 per-CPU PSI 狀態時間統計信息如下:
完成了上面 6 種狀態的時間統計之后,在系統的每個 cpu 上就建立了 6 條 time line,而上層的 PSI group 會以固定周期來采樣 time line 的數組。采樣點之間相減就可以得到該周期內各種狀態的時間長度值。通過下面的公式我們可以計算單個 CPU 上的 PSI 值:
%SOME = time(SOME) / period
%FULL = time(FULL) / period
在多 CPU 場景下,我們要綜合考慮 CPU 個數和 non idle task 的個數,計算公式如下:
tNONIDLE = sum(tNONIDLE[i])
tSOME = sum(tSOME[i] * tNONIDLE[i]) / tNONIDLE
tFULL = sum(tFULL[i] * tNONIDLE[i]) / tNONIDLE
%SOME = tSOME / period
%FULL = tFULL / period
tNONIDLE[i]、tSOME[i] 和 tFULL[i] 已經在 per-CPU 狀態統計中獲取了,通過上面的公式即可以計算該 psi group 在當前周期內的 PSI 值。
在計算三種間隔的時間占比時,有人可能會有疑問,周期是 2s,如何做到每次都更新三種數據呢?這個問題其實在上面講到的老技術 load average 計算時已經解決,采用公式:a1 = a0 * e + a * (1 - e);
於是得到:newload = load * exp + active * (FIXED_1 - exp)
其中 active 是當前更新周期的 load average,load 是上個周期得到的 load average,exp 的定義如下:
#define EXP_10s 1677 /* 1/exp(2s/10s) as fixed-point */
#define EXP_60s 1981 /* 1/exp(2s/60s) */
#define EXP_300s 2034 /* 1/exp(2s/300s) */
五、PSI 的應用
有了 PSI 對系統資源壓力的准確評估,可以做很多有意義的功能來最大化系統資源的利用。比如 facebook 開發的 cgroup2 和 oomd。oomd 是一個用戶態的 out of memory 監控管理服務。
Android 早期在 kernel 新增了一個功能叫 lmk(low memory killer),在有了 PSI 之后,android 將默認的 LMK 替換成了用戶態的 LMKD。其代碼存放於 android/system/core/lmkd/。
其核心思想是給 /proc/pressure/memory 的 SOME 和 FULL 設定閾值,當延時超過閾值時,觸發 lmkd daemon 進程選擇進程殺死。同時,還可以結合 meminfo 的剩余內存大小來判斷需要清理的程度和所選進程的優先級。
參考文獻:
[1]Getting Started with PSI, https://facebookmicrosites.github.io/psi/docs/overview#pressure-metric-definitions
[2]psi: pressure stall information for CPU, memory, and IO v2, https://lwn.net/Articles/759658/

“內核工匠”微信公眾號
Linux 內核黑科技 | 技術文章 | 精選教程