通過性能指標學習 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;
}

那麼一個疑問就產生了,這個不就是調度延遲 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 等等事件。

  1. 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 */
}

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 */
}

來看看最終計算的地方:

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;
}
  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;
  }

所以追蹤完 atop 對調度延遲的處理後,我們就可以發現獲取數據的思路是開啓 atop之後,按照我們指定的 interval,在大循環中每一次 interval 到來以後,就讀取一次 proc 文件系統,將這個值保存,因此結論就是目前的 atop 工具對調度延遲的提取方式就是每隔 interval 秒,讀取一次 proc下的 schedstat 文件。
因此 atop 獲取的是每 interval 時間的系統當前進程的調度延遲快照數據,並且是秒級別的提取頻率。

  1. 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;

進入到 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);
}
  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