通過性能指標學習 Linux Kernel - (上)
作者介紹:
趙晨雨,師從陳莉君教授,Linux 內核之旅社區 maintainer,現就職於 thoughtworks 安全與系統研發事業部,thoughtworks 未濟實驗室成員。
B 站錄屏鏈接:
GLCC 編程夏令營——LMP 課題 週會分享
https://www.bilibili.com/video/BV1oY4y177Wh?spm_id_from=333.337.search-card.all.click
Linux 內核之旅社區聯合 thoughtworks 未濟實驗室在中科院開源之夏和 CCF 暑期夏令營活動中發佈了 13 個題目,我們也一直在思考如何讓大家通過這次暑期活動更好地提升自己。這次分享是通過具體的一個性能指標,利用現有的工具來定位內核代碼,從而圈定學習 Kernel 的目標範圍,因此這次分享是想給社區同學提供一種學習方法,因此可以將重點放在方法上,原理部分就交給社區同學深入挖掘了,而且根據目前社區同學的反饋,效果超出我的預期,期待社區同學後續精彩的分享。
同時我也在思考一個問題,就是怎麼才能把 eBPF 的優勢講明白,或者說把 eBPF 在某一個點上的應用優勢是什麼講明白,社區的題目大部分都是圍繞 eBPF 展開的,因此也是在嘗試解釋這件事情。
調度延遲
本次圈定的性能指標是調度延遲,那首要的目標就是看看到底什麼是調度延遲,調度延遲是保證每一個可運行進程都至少運行一次的時間間隔,翻譯一下,是指一個 task
的狀態變成了 TASK_RUNNING
,然後從進入 CPU 的 runqueue
開始,到真正執行(獲得 CPU 的執行權)的這段時間間隔。
需要說明的是調度延遲在 Linux Kernel 中實現的時候是分爲兩種方式的:面向task
和麪向rq
,我們現在關注的是 task
層面。
那麼 runqueue
和調度器的一個 sched period
的關係就顯得比較重要了。首先來看調度週期,調度週期的含義就是所有可運行的 task
都在 CPU 上執行一遍的時間週期,而 Linux CFS
中這個值是不固定的,當進程數量小於 8 的時候,sched period
就是一個固定值 6ms,如果 runqueue
數量超過了 8 個,那麼就保證每個 task
都必須運行一定的時間,這個一定的時間還叫最小粒度時間,CFS 的默認最小粒度時間是 0.75ms,使用 sysctl_sched_min_granularity
保存, sched period
是通過下面這個內核函數來決定的:
/*
* The idea is to set a period in which each task runs once.
*
* When there are too many tasks (sched_nr_latency) we have to stretch
* this period because otherwise the slices get too small.
*
* p = (nr <= nl) ? l : l*nr/nl
*/
static u64 __sched_period(unsigned long nr_running)
{
if (unlikely(nr_running > sched_nr_latency))
return nr_running * sysctl_sched_min_granularity;
else
return sysctl_sched_latency;
}
nr_running
就是可執行task
數量
那麼一個疑問就產生了,這個不就是調度延遲 scheduling latency
嗎,並且每一次計算都會給出一個確定的調度週期的值是多少,但是這個調度週期僅僅是用於調度算法裏面,因爲這裏的調度週期是爲了確保 runqueue
上的 task
的最小調度週期,也就是在這段時間內,所有的 task
至少被調度一次,但是這僅僅是目標,而實際是達不到的。因爲系統的狀態、task
的狀態、task
的 slice 等等都是不斷變化的,週期性調度器會在每一次 tick
來臨的時候檢查當前 task
的 slice 是否到期,如果到期了就會發生 preempt
搶,而週期性調度器本身的精度就很有限,不考慮 hrtick 的情況下,我們查看系統的時鐘頻率:
$ grep CONFIG_HZ /boot/config-$(uname -r)
# CONFIG_HZ_PERIODIC is not set
# CONFIG_HZ_100 is not set
CONFIG_HZ_250=y
# CONFIG_HZ_300 is not set
# CONFIG_HZ_1000 is not set
CONFIG_HZ=250
僅僅是 250HZ,也就是 4ms 一次時鐘中斷,所以都無法保證每一個 task
在 CPU 上運行的 slice 是不是它應該有的 slice,更不要說保證調度週期了,外加還有 wakeup、preempt 等等事件。
- atop 的統計方法
既然不能直接使用計算好的值,那麼就得通過其他方法進行統計了,首先 Linux kernel 本身是有統計每一個 task
的調度延遲的,在內核中調度延遲使用的說法是 run delay
,並且通過 proc
文件系統暴露了出來,因此大概率現有的傳統工具提取調度延遲的源數據是來自於 proc
的,例如 atop
工具。
run delay
在 proc
中的位置:
進程的調度延遲:/proc/<PID>/schedstat
線程的調度延遲:/proc/<PID>/task/<TID>/schedstat
現在的目標變爲搞清楚 atop
工具是怎麼統計調度延遲的。
現有的工具 atop
是可以輸出用戶態每一個進程和線程的調度延遲指標的,在開啓 atop
後按下 s
鍵,就會看到 RDELAY
列,這一列就是調度延遲了。我們來看看 atop 工具是怎麼統計這個指標值的,clone atop
工具的代碼:
git@github.com:Atoptool/atop.git
由於目前的目標是搞清楚 atop
對調度延遲指標的統計方法,因此我只關心和這個部分相關的代碼片段,可視化展示的部分並不關心。
整體來說,atop 工作的大體流程是:
int
main(int argc, char *argv[])
{
···
// 獲取 interval
interval = atoi(argv[optind]);
// 開啓收集引擎
engine();
···
return 0; /* never reached */
}
-
這裏的
interval
就是我們使用 atop 的時候以什麼時間間隔來提取數據,這個時間間隔就是interval
。 -
所有的計算等操作都在
engine()
函數中完成
engine() 的工作流程如下:
static void
engine(void)
{
···
/*
** install the signal-handler for ALARM, USR1 and USR2 (triggers
* for the next sample)
*/
memset(&sigact, 0, sizeof sigact);
sigact.sa_handler = getusr1;
sigaction(SIGUSR1, &sigact, (struct sigaction *)0);
···
if (interval > 0)
alarm(interval);
···
for (sampcnt=0; sampcnt < nsamples; sampcnt++)
{
···
if (sampcnt > 0 && awaittrigger)
pause();
awaittrigger = 1;
···
do
{
curtlen = counttasks(); // worst-case value
curtpres = realloc(curtpres,
curtlen * sizeof(struct tstat));
ptrverify(curtpres, "Malloc failed for %lu tstats\n",
curtlen);
memset(curtpres, 0, curtlen * sizeof(struct tstat));
}
while ( (ntaskpres = photoproc(curtpres, curtlen)) == curtlen);
···
} /* end of main-loop */
}
- 代碼細節上不再詳細介紹,整體運行的大循環是在 16 行開始的,真正得到調度延遲指標值的是在 34 行的
photoproc()
函數中計算的,傳入的是需要計算的task
列表和task
的數量
來看看最終計算的地方:
unsigned long
photoproc(struct tstat *tasklist, int maxtask)
{
···
procschedstat(curtask); /* from /proc/pid/schedstat */
···
if (curtask->gen.nthr > 1)
{
···
curtask->cpu.rundelay = 0;
···
/*
** open underlying task directory
*/
if ( chdir("task") == 0 )
{
···
while ((tent=readdir(dirtask)) && tval<maxtask)
{
struct tstat *curthr = tasklist+tval;
···
// totalize delays of all threads
curtask->cpu.rundelay +=
procschedstat(curthr);
···
}
···
}
}
···
return tval;
}
- 第 5 行的函數就是在讀取
proc
的schedstat
文件:
static count_t
procschedstat(struct tstat *curtask)
{
FILE *fp;
char line[4096];
count_t runtime, rundelay = 0;
unsigned long pcount;
static char *schedstatfile = "schedstat";
/*
** open the schedstat file
*/
if ( (fp = fopen(schedstatfile, "r")) )
{
curtask->cpu.rundelay = 0;
if (fgets(line, sizeof line, fp))
{
sscanf(line, "%llu %llu %lu\n",
&runtime, &rundelay, &pcount);
curtask->cpu.rundelay = rundelay;
}
/*
** verify if fgets returned NULL due to error i.s.o. EOF
*/
if (ferror(fp))
curtask->cpu.rundelay = 0;
fclose(fp);
}
else
{
curtask->cpu.rundelay = 0;
}
return curtask->cpu.rundelay;
}
- 15 行是在判斷是不是有多個 thread,如果有多個 thread,那麼就把所有的 thread 的調度延遲相加就得到了這個任務的調度延遲。
所以追蹤完 atop
對調度延遲的處理後,我們就可以發現獲取數據的思路是開啓 atop
之後,按照我們指定的 interval
,在大循環中每一次 interval
到來以後,就讀取一次 proc
文件系統,將這個值保存,因此結論就是目前的 atop
工具對調度延遲的提取方式就是每隔 interval
秒,讀取一次 proc
下的 schedstat
文件。
因此 atop
獲取的是每 interval
時間的系統當前進程的調度延遲快照數據,並且是秒級別的提取頻率。
- proc 的底層方法—面向 task
那麼數據源頭我們已經定位好了,就是來源於 proc
,而 proc
的數據全部都是內核運行過程中自己統計的,那現在的目標就轉爲內核內部是怎麼統計每一個 task
的調度延遲的,因此需要定位到內核中 proc 計算調度延遲的地點是哪裏。
方法很簡單,寫一個讀取 schedstat
文件的簡單程序,使用 ftrace
追蹤一下,就可以看到 proc
裏面是哪個函數來生成的 schedstat
文件中的數據,ftrace 的結果如下:
2) 0.125 us | single_start();
2) | proc_single_show() {
2) | get_pid_task() {
2) 0.124 us | rcu_read_unlock_strict();
2) 0.399 us | }
2) | proc_pid_schedstat() {
2) | seq_printf() {
2) 1.145 us | seq_vprintf();
2) 1.411 us | }
2) 1.722 us | }
2) 2.599 us | }
很容易可以發現是第六行的函數:
#ifdef CONFIG_SCHED_INFO
/*
* Provides /proc/PID/schedstat
*/
static int proc_pid_schedstat(struct seq_file *m, struct pid_namespace *ns,
struct pid *pid, struct task_struct *task)
{
if (unlikely(!sched_info_on()))
seq_puts(m, "0 0 0\n");
else
seq_printf(m, "%llu %llu %lu\n",
(unsigned long long)task->se.sum_exec_runtime,
(unsigned long long)task->sched_info.run_delay,
task->sched_info.pcount);
return 0;
}
#endif
第 8 行是在判斷一個內核配置選項,一般默認都是開啓的,或者能看到 schedstat
文件有輸出,那麼就是開啓的,或者可以用 make menuconfig
查找一下這個選項的狀態。
可以發現 proc
在拿取這個調度延遲指標的時候是直接從傳進來的 task_struct
中的 sched_info
中記錄的 run_delay
,而且是一次性讀取,沒有做平均值之類的數據處理,因此也是一個快照形式的數據。
首先說明下 sched_info
結構:
struct sched_info {#ifdef CONFIG_SCHED_INFO
/* Cumulative counters: */
/* # of times we have run on this CPU: */
unsigned long pcount;
/* Time spent waiting on a runqueue: */
unsigned long long run_delay;
/* Timestamps: */
/* When did we last run on a CPU? */
unsigned long long last_arrival;
/* When were we last queued to run? */
unsigned long long last_queued;#endif /* CONFIG_SCHED_INFO */};
和上面 proc 函數的宏是一樣的,所以可以推測出來這個宏很有可能是用來開啓內核統計 task
的調度信息的。每個字段的含義代碼註釋已經介紹的比較清晰了,kernel 對調度延遲給出的解釋是在 runqueue 中等待的時間。
現在的目標轉變爲內核是怎麼對這個 run_delay
字段進行計算的。需要回過頭來看一下sched_info
的結構,後兩個是用於計算 run_delay
參數的,另外這裏就需要 Linux 調度器框架和 CFS 調度器相關了,首先需要梳理一下和進程調度信息統計相關的函數,其實就是看 CONFIG_SCHED_INFO
這個宏包起來了哪些函數,找到這些函數的聲明點,相關的函數位於 kernel/sched/stats.h
中。
涉及到的函數如下:
sched_info_queued(rq, t)
sched_info_reset_dequeued(t)
sched_info_dequeued(rq, t)
sched_info_depart(rq, t)
sched_info_arrive(rq, next)
sched_info_switch(rq, t, next)
BTW,調度延遲在rq
中統計的函數是:
rq_sched_info_arrive()
rq_sched_info_dequeued()
rq_sched_info_depart()
注意的是這些函數的作用只是統計調度信息,查看這些函數的代碼,其中和調度延遲相關的函數有以下三個:
sched_info_depart(rq, t)
sched_info_queued(rq, t)
sched_info_arrive(rq, next)
並且一定是在關鍵的調度時間節點上被調用的:
1. 進入runqueuetask 從其他狀態(休眠,不可中斷等)切換到可運行狀態後,進入 runqueue 的起始時刻;
2. 調度下CPU,然後進入runqueuetask 從一個 cpu 的 runqueue 移動到另外一個 cpu 的 runqueue 時,更新進入新的 runqueue 的起始時刻;task 正在運行被調度下CPU,放入 runqueue 的起始時刻,被動下CPU;
3. 產生新task然後進入runqueue;
4. 調度上CPU進程從 runqueue 中被調度到cpu上運行時更新last_arrival;
- 可以這麼理解要麼上 CPU,要麼下 CPU,下 CPU 並且狀態還是
TASK_RUNNING
狀態的其實就是進入runqueue
的時機。
進入到 runqueue
都會最終調用到 sched_info_queued
,而第二種情況會先走 sched_info_depart
函數:
static inline void sched_info_depart(struct rq *rq, struct task_struct *t)
{
unsigned long long delta = rq_clock(rq) - t->sched_info.last_arrival;
rq_sched_info_depart(rq, delta);
if (t->state == TASK_RUNNING)
sched_info_queued(rq, t);
}
-
第 3 行的代碼在計算上次在 CPU 上執行的時間戳是多少,用現在的時間減去
last_arrival
(上次被調度上 CPU 的時間)就可以得到,然後傳遞給了rq_sched_info_depart()
函數 -
第 2 種情況下,在第 8 行,如果進程這個時候的狀態還是
TASK_RUNNING
,那麼說明這個時候task
是被動下 CPU 的,表示該task
又開始在runqueue
中等待了,爲什麼不統計其它狀態的task
,因爲其它狀態的task
是不能進入runqueue
的,例如等待 IO 的task
,這些 task 只有在完成等待後纔可以進入runqueue
,這個時候就有變成了第 1 種情況;第 1 種情況下會直接進入sched_info_queued()
函數;因此這兩種情況下都是 task 進入了runqueue
然後最終調用sched_info_queued()
函數記錄上次(就是現在)進入 runqueue 的時間戳last_queued
。 -
sched_info_queued()
的代碼如下:
static inline void sched_info_queued(struct rq *rq, struct task_struct *t)
{
if (unlikely(sched_info_on())) {
if (!t->sched_info.last_queued)
t->sched_info.last_queued = rq_clock(rq);
}
}
然後就到了最後一個關鍵節點,task 被調度 CPU 了,就會觸發 sched_info_arrive()
函數:
static void sched_info_arrive(struct rq *rq, struct task_struct *t)
{
unsigned long long now = rq_clock(rq), delta = 0;
if (t->sched_info.last_queued)
delta = now - t->sched_info.last_queued;
sched_info_reset_dequeued(t);
t->sched_info.run_delay += delta;
t->sched_info.last_arrival = now;
t->sched_info.pcount++;
rq_sched_info_arrive(rq, delta);
}
這個時候就可以來計算調度延遲了,代碼邏輯是如果有記錄上次的 last_queued
時間戳,那麼就用現在的時間戳減去上次的時間戳,就是該 task 的調度延遲,然後保存到 run_delay
字段裏面,並且標記這次到達 CPU 的時間戳到 last_arrival
裏面,pcount
記錄的是上 cpu 上了多少次。
公式就是:
(待續)
由於作者水平有限,本文錯漏缺點在所難免,希望讀者批評指正。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/haPqclOZwIEciCa4nPtFjw