核心是如何給容器中的程式分配CPU資源的?

張哥說技術發表於2023-03-16

大家好,我是飛哥!

現在很多公司的服務都是跑在容器下,我來問幾個容器 CPU 相關的問題,看大家對天天在用的技術是否熟悉。

  • 容器中的核是真的邏輯核嗎?
  • Linux 是如何對容器下的程式進行 CPU 限制的,底層是如何工作的?
  • 容器中的 throttle 是什麼意思?
  • 為什麼關注容器 CPU 效能的時候,除了關注使用率,還要關注 throttle 的次數和時間?

和真正使用物理機不同,Linux 容器中所謂的核並不是真正的 CPU 核。所以在理解容器 CPU 效能的時候,必然要有一些特殊的地方需要考慮。

各家公司的容器雲上,底層不管使用的是 docker 引擎,還是 containerd 引擎,都是依賴 Linux 的 cgroup 的 cpu 子系統來工作的,所以今天我們就來深入地學習一下 cgroup cpu 子系統 。理解了這個,你將會對容器程式的 CPU 效能有更深入的把握。

一、cgroup 的 cpu 子系統

在 Linux 下,  cgroup 提供了對 CPU、記憶體等資源實現精細化控制的能力。它的全稱是 control groups。允許對某一個程式,或者一組程式所用到的資源進行控制。現在流行的 Docker 就是在這個底層機制上成長起來的。

在你的機器執行執行下面的命令可以檢視當前 cgroup 都支援對哪些資源進行控制。

$ lssubsys -a
cpuset
cpu,cpuacct
...

其中 cpu 和 cpuset 都是對 CPU 資源進行控制的子系統。cpu 是透過執行時間來控制程式對 cpu 的使用,cpuset 是透過分配邏輯核的方式來分配 cpu。其它可控制的資源還包括 memory(記憶體)、net_cls(網路頻寬)等等。

cgroup 提供了一個原生介面並透過 cgroupfs 提供控制。類似於 procfs 和 sysfs,是一種虛擬檔案系統。預設情況下 cgroupfs 掛載在 /sys/fs/cgroup 目錄下,我們可以透過修改 /sys/fs/cgroup 下的檔案和檔案內容來控制程式對資源的使用。

比如,想實現讓某個程式只使用兩個核,我們可以透過 cgroupfs 介面這樣來實現,如下:

# cd /sys/fs/cgroup/cpu,cpuacct
# mkdir test
# cd test
# echo 100000 > cpu.cfs_period_us // 100ms 
# echo 100000 > cpu.cfs_quota_us //200ms 
# echo {$pid} > cgroup.procs

其中 cfs_period_us 用來配置時間週期長度,cfs_quota_us  用來配置當前 cgroup 在設定的週期長度內所能使用的 CPU 時間。這兩個檔案配合起來就可以設定 CPU 的使用上限。

上面的配置就是設定改 cgroup 下的程式每 100 ms 內只能使用 200 ms 的 CPU 週期,也就是說限制使用最多兩個“核”。

要注意的是這種方式只限制的是 CPU 使用時間,具體排程的時候是可能會排程到任意 CPU 上執行的。如果想限制程式使用的 CPU 核,可以使用 cpuset 子系統,詳情參見一次限制程式的 CPU 用量的實操過程

docker 預設情況下使用的就是 cgroupfs 介面,可以透過如下的命令來確認。

# docker info | grep cgroup
Cgroup Driver: cgroupfs

二、核心中程式和 cgroup 的關係

在上一節中,我們在 /sys/fs/cgroup/cpu,cpuacct 建立了一個目錄 test,這其實是建立了一個 cgroup 物件。當我們把某個程式的 pid 新增到 cgroup 後,又是建立了程式結構體和 cgroup 之間的關係。

所以要想理解清 cgroup 的工作過程,就得先來了解一下 cgroup 和 task_struct 結構體之間的關係。

2.1 cgroup 核心物件

一個 cgroup 物件中可以指定對 cpu、cpuset、memory 等一種或多種資源的限制。我們先來找到 cgroup 的定義。

//file:include/linux/cgroup-defs.h
struct cgroup {
 ...
 struct cgroup_subsys_state __rcu *subsys[CGROUP_SUBSYS_COUNT];
 ...
}

每個 cgroup 都有一個 cgroup_subsys_state 型別的陣列 subsys,其中的每一個元素代表的是一種資源控制,如 cpu、cpuset、memory 等等。

核心是如何給容器中的程式分配CPU資源的?

這裡要注意的是,其實 cgroup_subsys_state 並不是真實的資源控制統計資訊結構,對於 CPU 子系統真正的資源控制結構是 task_group。它是 cgroup_subsys_state 結構的擴充套件,類似父類和子類的概念。

核心是如何給容器中的程式分配CPU資源的?

當 task_group 需要被當成 cgroup_subsys_state 型別使用的時候,只需要強制型別轉換就可以。

對於記憶體子系統控制統計資訊結構是 mem_cgroup,其它子系統也類似。

核心是如何給容器中的程式分配CPU資源的?

之所以要這麼設計,目的是各個 cgroup 子系統都統一對外暴露 cgroup_subsys_state,其餘部分不對外暴露,在自己的子系統內部維護和使用。

2.2 程式和 cgroup 子系統

一個 Linux 程式既可以對它的 cpu 使用進行限制,也可以對它的記憶體進行限制。所以,一個程式 task_struct 是可以和多種子系統有關聯關係的。

和 cgroup 和多個子系統關聯定義類似,task_struct 中也定義了一個 cgroup_subsys_state 型別的陣列 subsys,來表達這種一對多的關係。

核心是如何給容器中的程式分配CPU資源的?

我們來簡單看下原始碼的定義。

//file:include/linux/sched.h
struct task_struct {
 ...
 struct css_set __rcu *cgroups;
 ...
}
//file:include/linux/cgroup-defs.h
struct css_set {
 ...
 struct cgroup_subsys_state *subsys[CGROUP_SUBSYS_COUNT];
}   

其中subsys是一個指標陣列,儲存一組指向 cgroup_subsys_state 的指標。一個 cgroup_subsys_state 就是程式與一個特定的子系統相關的資訊。

透過這個指標,程式就可以獲得相關聯的 cgroups 控制資訊了。能查到限制該程式對資源使用的 task_group、cpuset、mem_group 等子系統物件。

2.3 核心物件關係圖彙總

我們把上面的核心物件關係圖彙總起來看一下。

核心是如何給容器中的程式分配CPU資源的?

可以看到無論是程式、還是 cgroup 物件,最後都能找到和其關聯的具體的 cpu、記憶體等資源控制自系統的物件。

2.4 cpu 子系統

因為今天我們重點是介紹程式的 cpu 限制,所以我們把 cpu 子系統相關的物件 task_group 專門拿出來理解理解。

//file:kernel/sched/sched.h
struct task_group {
 struct cgroup_subsys_state css;
 ...

 // task_group 樹結構
 struct task_group   *parent;
 struct list_head    siblings;
 struct list_head    children;

 //task_group 持有的 N 個排程實體(N = CPU 核數)
 struct sched_entity **se;

 //task_group 自己的 N 個公平排程佇列(N = CPU 核數)
 struct cfs_rq       **cfs_rq;

 //公平排程頻寬限制
 struct cfs_bandwidth    cfs_bandwidth;
 ...
}

第一個 cgroup_subsys_state css 成員我們在前面說過了,這相當於它的“父類”。再來看 parent、siblings、children 等幾個物件。這些成員是樹相關的資料結構。在整個系統中有一個 root_task_group。

//file:kernel/sched/core.c
struct task_group root_task_group;

所有的 task_group 都是以 root_task_group 為根節點組成了一棵樹。

接下來的 se 和 cfs_rq 是完全公平排程的兩個物件。它們兩都是陣列,元素個數等於當前系統的 CPU 核數。每個 task_group 都會在上一級 task_group(比如 root_task_group)的 N 個排程佇列中有一個排程實體。

cfs_rq 是 task_group 自己所持有的完全公平排程佇列。是的,你沒看錯。每一個 task_group 內部都有自己的一組排程佇列,其數量和 CPU 的核數一致。

假如當前系統有兩個邏輯核,那麼一個 task_group 樹和 cfs_rq 的簡單示意圖大概是下面這個樣子。

核心是如何給容器中的程式分配CPU資源的?

Linux 中的程式排程是一個層級的結構。對於容器來講,宿主機中進行程式排程的時候,先排程到的實際上不是容器中的具體某個程式,而是一個 task_group。然後接下來再進入容器 task_group 的排程佇列 cfs_rq 中進行排程,才能最終確定具體的程式 pid。

還有就是 cpu 頻寬限制 cfs_bandwidth, cpu 分配的管控相關的欄位都是在 cfs_bandwidth 中定義維護的。

cgroup 相關的核心物件我們就先介紹到這裡,接下來我們看一下 cpu 子系統到底是如何實現的。

三、CPU 子系統的實現

在第一節中我們展示透過 cgroupfs 對 cpu 子系統使用,使用過程大概可以分成三步:

  • 第一步:透過建立目錄來建立 cgroup
  • 第二步:在目錄中設定 cpu 的限制情況
  • 第三步:將程式新增到 cgroup 中進行資源管控

那本小節我們就從上面三步展開,看看在每一步中,核心都具體做了哪些事情。限於篇幅所限,我們只講 cpu 子系統,對於其他的子系統也是類似的分析過程。

3.1 建立 cgroup 物件

核心定義了對 cgroupfs 操作的具體處理函式。在 /sys/fs/cgroup/ 下的目錄建立操作都將由下面 cgroup_kf_syscall_ops 定義的方法來執行。

//file:kernel/cgroup/cgroup.c
static struct kernfs_syscall_ops cgroup_kf_syscall_ops = {
 .mkdir          = cgroup_mkdir,
 .rmdir          = cgroup_rmdir,
 ...
};

建立目錄執行整個過程鏈條如下

vfs_mkdir
  ->kernfs_iop_mkdir
 ->cgroup_mkdir
   ->cgroup_apply_control_enable
  ->css_create
    ->cpu_cgroup_css_alloc

其中關鍵的建立過程有:

  • cgroup_mkdir:在這裡建立了 cgroup 核心物件
  • css_create:建立每一個子系統資源管理物件,對於 cpu 子系統會建立 task_group

cgroup 核心物件是在 cgroup_mkdir 中建立的。除了 cgroup 核心物件,這裡還建立了檔案系統重要展示的目錄。

//file:kernel/cgroup/cgroup.c
int cgroup_mkdir(struct kernfs_node *parent_kn, const char *name, umode_t mode)
{
 ...
 //查詢父 cgroup
 parent = cgroup_kn_lock_live(parent_kn, false);

 //建立cgroup物件出來
 cgrp = cgroup_create(parent);

 //建立檔案系統節點
 kn = kernfs_create_dir(parent->kn, name, mode, cgrp);
 cgrp->kn = kn;
 ...
}

在 cgroup 中,是有層次的概念的,這個層次結構和 cgroupfs 中的目錄層次結構一樣。所以在建立 cgroup 物件之前的第一步就是先找到其父 cgroup, 然後建立自己,並建立檔案系統中的目錄以及檔案。

在 cgroup_apply_control_enable 中,執行子系統物件的建立。

//file:kernel/cgroup/cgroup.c
static int cgroup_apply_control_enable(struct cgroup *cgrp)
{
 ...
 cgroup_for_each_live_descendant_pre(dsct, d_css, cgrp) {
  for_each_subsys(ss, ssid) {
   struct cgroup_subsys_state *css = cgroup_css(dsctss);
   css = css_create(dsct, ss);
   ...
  }
 }
 return 0;
}

透過 for_each_subsys 遍歷每一種 cgroup 子系統,並呼叫其 css_alloc 來建立相應的物件。

//file:kernel/cgroup/cgroup.c
static struct cgroup_subsys_state *css_create(struct cgroup *cgrp,
        struct cgroup_subsys *ss)

{
 css = ss->css_alloc(parent_css);
 ...
}

上面的 css_alloc 是一個函式指標,對於 cpu 子系統來說,它指向的是 cpu_cgroup_css_alloc。這個對應關係在 kernel/sched/core.c 檔案仲可以找到

//file:kernel/sched/core.c
struct cgroup_subsys cpu_cgrp_subsys = {
 .css_alloc  = cpu_cgroup_css_alloc,
 .css_online = cpu_cgroup_css_online,
 ...
};

透過 cpu_cgroup_css_alloc => sched_create_group 呼叫後,建立出了 cpu 子系統的核心物件 task_group。

//file:kernel/sched/core.c
struct task_group *sched_create_group(struct task_group *parent)
{
 struct task_group *tg;
 tg = kmem_cache_alloc(task_group_cache, GFP_KERNEL | __GFP_ZERO);
 ...
}

3.2 設定 CPU 子系統限制

第一節中,我們透過對 cpu 子系統目錄下的 cfs_period_us 和 cfs_quota_us 值的修改,來完成了 cgroup 中限制的設定。我們這個小節再看看看這個設定過程。

當使用者讀寫這兩個檔案的時候,核心中也定義了對應的處理函式。

//file:kernel/sched/core.c
static struct cftype cpu_legacy_files[] = {
 ...
 {
  .name = "cfs_quota_us",
  .read_s64 = cpu_cfs_quota_read_s64,
  .write_s64 = cpu_cfs_quota_write_s64,
 },
 {
  .name = "cfs_period_us",
  .read_u64 = cpu_cfs_period_read_u64,
  .write_u64 = cpu_cfs_period_write_u64,
 },
 ...
}

寫處理函式 cpu_cfs_quota_write_s64、cpu_cfs_period_write_u64 最終又都是呼叫 tg_set_cfs_bandwidth 來完成設定的。

//file:kernel/sched/core.c
static int tg_set_cfs_bandwidth(struct task_group *tg, u64 period, u64 quota)
{
 //定位 cfs_bandwidth 物件
 struct cfs_bandwidth *cfs_b = &tg->cfs_bandwidth;
 ...

 //對 cfs_bandwidth 進行設定
 cfs_b->period = ns_to_ktime(period);
 cfs_b->quota = quota;
 ...
}

在 task_group 中,其頻寬管理控制都是由 cfs_bandwidth 來完成的,所以一開始就需要先獲取 cfs_bandwidth 物件。接著將使用者設定的值都設定到 cfs_bandwidth 型別的物件 cfs_b 上。

3.3 寫 proc 進 group

cgroup 建立好了,cpu 限制規則也制定好了,下一步就是將程式新增到這個限制中。在 cgroupfs 下的操作方式就是修改 cgroup.procs 檔案。

核心定義了修改 cgroup.procs 檔案的處理函式為 cgroup_procs_write。

//file:kernel/cgroup/cgroup.c
static struct cftype cgroup_base_files[] = {
 ...
 {
  .name = "cgroup.procs",
  ...
  .write = cgroup_procs_write,
 },
}

在 cgroup_procs_write 的處理中,主要做了這麼幾件事情。

  • 第一、邏根據使用者輸入的 pid 來查詢 task_struct 核心物件。
  • 第二、從舊的排程組中退出,加入到新的排程組 task_group 中
  • 第三、修改程式其 cgroup 相關的指標,讓其指向上面建立好的 task_group。

我們來看下加入新排程組的過程,核心的呼叫鏈條如下。

cgroup_procs_write
  ->cgroup_attach_task
 ->cgroup_migrate
   ->cgroup_migrate_execute

在 cgroup_migrate_execute 中遍歷各個子系統,完成每一個子系統的遷移。

static int cgroup_migrate_execute(struct cgroup_mgctx *mgctx)
{
 do_each_subsys_mask(ss, ssid, mgctx->ss_mask) {
  if (ss->attach) {
   tset->ssid = ssid;
   ss->attach(tset);
  }
 } while_each_subsys_mask();
 ...
}

對於 cpu 子系統來講,attach 對應的處理方法是 cpu_cgroup_attach。這也是在 kernel/sched/core.c 下的 cpu_cgrp_subsys 中定義的。

cpu_cgroup_attach 呼叫 sched_move_task 來完成將程式加入到新排程組的過程。

//file:kernel/sched/core.c
void sched_move_task(struct task_struct *tsk)
{
 //找到task所在的runqueue
 rq = task_rq_lock(tsk, &rf);

 //從runqueue中出來
 queued = task_on_rq_queued(tsk);
 if (queued)
  dequeue_task(rq, tsk, queue_flags);

 //修改task的group
 //將程式先從舊tg的cfs_rq中移除且更新cfs_rq的負載;再將程式新增入新tg的cfs_rq並更新新cfs_rq的負載
 sched_change_group(tsk, TASK_MOVE_GROUP);

 //此時程式的排程組已經更新,重新將程式加回runqueue
 if (queued)
  enqueue_task(rq, tsk, queue_flags);
 ...
}

這個函式做了三件事。

  • 第一、先呼叫 dequeue_task 從原歸屬的 queue 中退出來,
  • 第二、修改程式的 task_group
  • 第三、重新將程式新增到新 task_group 的 runqueue 中。
//file:kernel/sched/core.c
static void sched_change_group(struct task_struct *tsk, int type)
{
 struct task_group *tg;

 //查詢 task_group
 tg = container_of(task_css_check(tsk, cpu_cgrp_id, true),
     struct task_group, css);
 tg = autogroup_task_group(tsk, tg);

 //修改 task_struct 所對應的 task_group
 tsk->sched_task_group = tg;
 ...
}

程式 task_struct 的 sched_task_group 是表示其歸屬的 task_group, 這裡設定到新歸屬上。

四、程式 CPU 頻寬控制過程

在前面的操作完畢之後,我們只是將程式新增到了 cgroup 中進行管理而已。相當於只是初始化,而真正的限制是貫穿在 Linux 執行是的程式排程過程中的。

所新增的程式將會受到 cpu 子系統 task_group 下的 cfs_bandwidth 中記錄的 period 和 quota 的限制。

你的新程式是如何被核心排程執行到的?一文中我們介紹過完全公平排程器在選擇程式時的核心方法 pick_next_task_fair。

這個方法的整個執行過程一個自頂向下搜尋可執行的 task_struct 的過程。整個系統中有一個 root_task_group。

//file:kernel/sched/core.c
struct task_group root_task_group;

核心是如何給容器中的程式分配CPU資源的?

CFS 中排程佇列是一顆紅黑樹, 紅黑樹的節點是 struct sched_entity, sched_entity 中既可以指向 struct task_struct 也可以指向 struct cfs_rq(可理解為 task_group)

排程 pick_next_task_fair()函式中的 prev 是本次排程時在執行的上一個程式。該函式透過 do {} while 迴圈,自頂向下搜尋到下一步可執行程式。

//file:kernel/sched/fair.c
static struct task_struct *
pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
 struct cfs_rq *cfs_rq = &rq->cfs;
 ...

 //選擇下一個排程的程式
 do {
  ...
  se = pick_next_entity(cfs_rq, curr);
  cfs_rq = group_cfs_rq(se);
 }while (cfs_rq)
 p = task_of(se);

 //如果選出的程式和上一個程式不同
 if (prev != p) {
  struct sched_entity *pse = &prev->se;
  ...

  //對要放棄 CPU 的程式執行一些處理
  put_prev_entity(cfs_rq, pse);
 }

}

如果新程式和上一次執行的程式不是同一個,則要呼叫 put_prev_entity 做兩件和 CPU 的頻寬控制有關的事情。

//file: kernel/sched/fair.c
static void put_prev_entity(struct cfs_rq *cfs_rq, struct sched_entity *prev)
{
 //4.1 執行佇列頻寬的更新與申請
 if (prev->on_rq)
  update_curr(cfs_rq);

 //4.2 判斷是否需要將容器掛起
 check_cfs_rq_runtime(cfs_rq);

 //更新負載資料
 update_load_avg(cfs_rq, prev, 0);
 ...
}

在上述程式碼中,和 CPU 頻寬控制相關的操作有兩個。

  • 執行佇列頻寬的更新與申請
  • 判斷是否需要進行頻寬限制

接下來我們分兩個小節詳細展開看看這兩個操作具體都做了哪些事情。

4.1 執行佇列頻寬的更新與申請

在這個小節中我們專門來看看 cfs_rq 佇列中 runtime_remaining 的更新與申請

在實現上頻寬控制是在 task_group 下屬的 cfs_rq 佇列中進行的。cfs_rq 對頻寬時間的操作歸總起來就是更新與申請。申請到的時間儲存在欄位 runtime_remaining 欄位中,每當有時間支出需要更新的時候也是從這個欄位值從去除。

其實除了上述場景外,系統在很多情況下都會呼叫 update_curr,包括任務在入隊、出隊時,排程中斷函式也會週期性地呼叫該方法,以確保任務的各種時間資訊隨時都是最新的狀態。在這裡會更新 cfs_rq 佇列中的 runtime_remaining 時間。如果 runtime_remaining 不足,會觸發時間申請。

//file:kernel/sched/fair.c
static void update_curr(struct cfs_rq *cfs_rq)
{
 //計算一下執行了多久
 u64 now = rq_clock_task(rq_of(cfs_rq));
 u64 delta_exec;
 delta_exec = now - curr->exec_start;
 ...

 //更新頻寬限制
 account_cfs_rq_runtime(cfs_rq, delta_exec);
}

在 update_curr 先計算當前執行了多少時間。然後在 cfs_rq 的 runtime_remaining 減去該時間值,具體減的過程是在 account_cfs_rq_runtime 中處理的。

//file:kernel/sched/fair.c
static void __account_cfs_rq_runtime(struct cfs_rq *cfs_rq, u64 delta_exec)
{
 cfs_rq->runtime_remaining -= delta_exec;

 //如果還有剩餘時間,則函式返回
 if (likely(cfs_rq->runtime_remaining > 0))
  return;
 ...
 //呼叫 assign_cfs_rq_runtime 申請時間餘額
 if (!assign_cfs_rq_runtime(cfs_rq) && likely(cfs_rq->curr))
  resched_curr(rq_of(cfs_rq));
}

更新頻寬時間的邏輯比較簡單,先從 cfs->runtime_remaining 減去本次執行的物理時間。如果減去之後仍然大於 0 ,那麼本次更新就算是結束了。

如果相減後發現是負數,表示當前 cfs_rq 的時間餘額已經耗盡,則會立即嘗試從任務組中申請。具體的申請函式是 assign_cfs_rq_runtime。如果申請沒能成功,呼叫 resched_curr 標記 cfs_rq->curr 的 TIF_NEED_RESCHED 位,以便隨後將其排程出去。

我們展開看下申請過程 assign_cfs_rq_runtime 。

//file:kernel/sched/fair.c
static int assign_cfs_rq_runtime(struct cfs_rq *cfs_rq)
{
 //獲取當前 task_group 的 cfs_bandwidth
 struct task_group *tg = cfs_rq->tg;
 struct cfs_bandwidth *cfs_b = tg_cfs_bandwidth(tg);

 //申請時間數量為保持下次有 sysctl_sched_cfs_bandwidth_slice 這麼多
 min_amount = sched_cfs_bandwidth_slice() - cfs_rq->runtime_remaining;

 //如果沒有限制,則要多少給多少
 if (cfs_b->quota == RUNTIME_INF)
  amount = min_amount;
 else {
  //保證定時器是開啟的,保證週期性地為任務組重置頻寬時間
  start_cfs_bandwidth(cfs_b);

  //如果本週期內還有時間,則可以分配 
  if (cfs_b->runtime > 0) {
   //確保不要透支
   amount = min(cfs_b->runtime, min_amount);
   cfs_b->runtime -= amount;
   cfs_b->idle = 0;
  }
 }

 cfs_rq->runtime_remaining += amount;
 return cfs_rq->runtime_remaining > 0;
}

首先,獲取當前 task_group 的 cfs_bandwidth,因為整個任務組的頻寬資料都是封裝在這裡的。接著呼叫 sched_cfs_bandwidth_slice 來獲取後面要留有多長時間,這個函式訪問的 sysctl 下的 sched_cfs_bandwidth_slice 引數。

//file:kernel/sched/fair.c
static inline u64 sched_cfs_bandwidth_slice(void)
{
 return (u64)sysctl_sched_cfs_bandwidth_slice * NSEC_PER_USEC;
}

這個引數在我的機器上是 5000 us(也就是說每次申請 5 ms)。

$ sysctl -a | grep sched_cfs_bandwidth_slice
kernel.sched_cfs_bandwidth_slice_us = 5000

在計算要申請的時間的時候,還需要考慮現在有多少時間。如果 cfs_rq->runtime_remaining 為正的話,那可以少申請一點,如果已經變為負數的話,需要在 sched_cfs_bandwidth_slice 基礎之上再多申請一些。

所以,最終要申請的時間值  min_amount = sched_cfs_bandwidth_slice() - cfs_rq->runtime_remaining

計算出 min_amount 後,直接在向自己所屬的 task_group 下的 cfs_bandwidth 把時間申請出來。整個 task_group 下可用的時間是儲存在 cfs_b->runtime 中的。

這裡你可能會問了,那 task_group 下的 cfs_b->runtime 的時間又是哪兒給分配的呢?我們將在 5.1 節來討論這個過程。

4.2 頻寬限制

check_cfs_rq_runtime 這個函式檢測 task group 的頻寬是否已經耗盡, 如果是則呼叫 throttle_cfs_rq 對程式進行限流。

//file: kernel/sched/fair.c
static bool check_cfs_rq_runtime(struct cfs_rq *cfs_rq)
{
 //判斷是不是時間餘額已用盡
 if (likely(!cfs_rq->runtime_enabled || cfs_rq->runtime_remaining > 0))
  return false;
 ...

 throttle_cfs_rq(cfs_rq);
 return true;
}

我們再來看看 throttle_cfs_rq 的執行過程。

//file:kernel/sched/fair.c
static void throttle_cfs_rq(struct cfs_rq *cfs_rq)
{
 //1.查詢到所屬的 task_group 下的 se
 se = cfs_rq->tg->se[cpu_of(rq_of(cfs_rq))];
 ...

 //2.遍歷每一個可排程實體,並從隸屬的 cfs_rq 上面刪除。
 for_each_sched_entity(se) {
  struct cfs_rq *qcfs_rq = cfs_rq_of(se);

  if (dequeue)
   dequeue_entity(qcfs_rq, se, DEQUEUE_SLEEP);
  ...
 }

 //3.設定一些 throttled 資訊。
 cfs_rq->throttled = 1;
 cfs_rq->throttled_clock = rq_clock(rq);

 //4.確保unthrottle的高精度定時器處於被啟用的狀態
 start_cfs_bandwidth(cfs_b);
 ...
}

在 throttle_cfs_rq 中,找到其所屬的 task_group 下的排程實體 se 陣列,遍歷每一個元素,並從其隸屬的 cfs_rq 的紅黑樹上刪除。這樣下次再排程的時候,就不會再排程到這些程式了。

那麼 start_cfs_bandwidth 是幹啥的呢?這正好是下一節的引子。

五、程式的可執行時間的分配

在第四小節我們看到,task_group 下的程式的執行時間都是從它的 cfs_b->runtime 中申請的。這個時間是在定時器中分配的。負責給 task_group 分配執行時間的定時器包括兩個,一個是 period_timer,另一個是 slack_timer。

struct cfs_bandwidth {
 ktime_t         period;
 u64             quota;
 ...
 struct hrtimer      period_timer;
 struct hrtimer      slack_timer;
 ...
}

peroid_timer 是週期性給 task_group 新增時間,缺點是 timer 週期比較長,通常是100ms。而 slack_timer 用於有 cfs_rq 處於 throttle 狀態且全域性時間池有時間供分配但是 period_timer 有還有比較長時間(通常大於7ms)才超時的場景。這個時候我們就可以啟用比較短的slack_timer(5ms超時)進行throttle,這樣的設計可以提升系統的實時性。

這兩個 timer 在 cgroup 下的 cfs_bandwidth 初始化的時候,都設定好了到期回撥函式,分別是 sched_cfs_period_timer 和 sched_cfs_slack_timer。

//file:kernel/sched/fair.c
void init_cfs_bandwidth(struct cfs_bandwidth *cfs_b)
{
 cfs_b->runtime = 0;
 cfs_b->quota = RUNTIME_INF;
 cfs_b->period = ns_to_ktime(default_cfs_period());

 //初始化 period_timer 並設定回撥函式
 hrtimer_init(&cfs_b->period_timer, CLOCK_MONOTONIC, HRTIMER_MODE_ABS_PINNED);
 cfs_b->period_timer.function = sched_cfs_period_timer;

 //初始化 slack_timer 並設定回撥函式
 hrtimer_init(&cfs_b->slack_timer, CLOCK_MONOTONIC, HRTIMER_MODE_REL);
 cfs_b->slack_timer.function = sched_cfs_slack_timer;
 ...
}

在上一節最後提到的 start_cfs_bandwidth 就是在開啟 period_timer 定時器。

//file:kernel/sched/fair.c
void start_cfs_bandwidth(struct cfs_bandwidth *cfs_b)
{
 ...
 hrtimer_forward_now(&cfs_b->period_timer, cfs_b->period);
 hrtimer_start_expires(&cfs_b->period_timer, HRTIMER_MODE_ABS_PINNED);
}

在 hrtimer_forward_now 呼叫時傳入的第二個參數列示是觸發的延遲時間。這個就是在 cgroup 是設定的 period,一般為 100 ms。

我們來分別看看這兩個 timer 是如何給 task_group 定期發工資(分配時間)的。

5.1 period_timer

在 period_timer 的回撥函式 sched_cfs_period_timer 中,週期性地為任務組分配頻寬時間,並且解掛當前任務組中所有掛起的佇列。

分配頻寬時間是在 __refill_cfs_bandwidth_runtime 中執行的,它的呼叫堆疊如下。

sched_cfs_period_timer
  ->do_sched_cfs_period_timer
 ->__refill_cfs_bandwidth_runtime
//file:kernel/sched/fair.c
void __refill_cfs_bandwidth_runtime(struct cfs_bandwidth *cfs_b)
{
 if (cfs_b->quota != RUNTIME_INF)
  cfs_b->runtime = cfs_b->quota;
}

可見,這裡直接給 cfs_b->runtime 增加了 cfs_b->quota 這麼多的時間。其中 cfs_b->quota 你就可以認為是在 cgroupfs 目錄下,我們配置的那個值。在第一節中,我們配置的是 500 ms。

# echo 500000 > cpu.cfs_period_us // 500ms 

5.2 slack_timer

設想一下,假如說某個程式申請了 5 ms 的執行時間,但是當程式剛一啟動執行便執行了同步阻塞的邏輯,這時候所申請的時間根本都沒有用完。在這種情況下,申請但沒用完的時間大部分是要返還給 task_group 中的全域性時間池的。

在核心中的呼叫鏈如下

dequeue_task_fair
  –>dequeue_entity
 –>return_cfs_rq_runtime
   –>__return_cfs_rq_runtime

具體的返還是在 __return_cfs_rq_runtime 中處理的。

//file:kernel/sched/fair.c
static void __return_cfs_rq_runtime(struct cfs_rq *cfs_rq)
{
 //給自己留一點
 s64 slack_runtime = cfs_rq->runtime_remaining - min_cfs_rq_runtime;
 if (slack_runtime <= 0)
  return;

 //返還到全域性時間池中
 if (cfs_b->quota != RUNTIME_INF) {
  cfs_b->runtime += slack_runtime;

  //如果時間又足夠多了,並且還有程式被限制的話
  //則呼叫 start_cfs_slack_bandwidth 來開啟 slack_timer
  if (cfs_b->runtime > sched_cfs_bandwidth_slice() &&
   !list_empty(&cfs_b->throttled_cfs_rq))
   start_cfs_slack_bandwidth(cfs_b);
 }
 ...
}

這個函式做了這麼幾件事情。

  • min_cfs_rq_runtime 的值是 1 ms,我們選擇至少保留 1ms 時間給自己
  • 剩下的時間 slack_runtime 歸還給當前的 cfs_b->runtime
  • 如果時間又足夠多了,並且還有程式被限制的話,開啟slack_timer,嘗試接觸程式 CPU 限制

在 start_cfs_slack_bandwidth 中啟動了 slack_timer。

//file:kernel/sched/fair.c
static void start_cfs_slack_bandwidth(struct cfs_bandwidth *cfs_b)
{
 ...

 //啟動 slack_timer
 cfs_b->slack_started = true;
 hrtimer_start(&cfs_b->slack_timer,
   ns_to_ktime(cfs_bandwidth_slack_period),
   HRTIMER_MODE_REL);
 ...
}

可見 slack_timer 的延遲迴調時間是 cfs_bandwidth_slack_period,它的值是 5 ms。這就比 period_timer 要實時多了。

slack_timer 的回撥函式 sched_cfs_slack_timer 我們就不展開看了,它主要就是操作對程式解除 CPU 限制

六、總結

今天我們介紹了 Linux cgroup 的 cpu 子系統給容器中的程式分配 cpu 時間的原理。

和真正使用物理機不同,Linux 容器中所謂的核並不是真正的 CPU 核,而是轉化成了執行時間的概念。在容器程式排程的時候給其滿足一定的 CPU 執行時間,而不是真正的分配邏輯核。

cgroup 提供了的原生介面是透過 cgroupfs 提供控制各個子系統的設定的。預設是在 /sys/fs/cgroup/ 目錄下,核心這個檔案系統的處理是定義了特殊的處理,和普通的檔案完全不一樣的。

核心處理 cpu 頻寬控制的核心物件就是下面這個 cfs_bandwidth。

//file:kernel/sched/sched.h
struct cfs_bandwidth {
 //頻寬控制配置
 ktime_t     period;
 u64         quota;

 //當前 task_group 的全域性可執行時間
 u64         runtime;
 ...

 //定時分配
 struct hrtimer      period_timer;
 struct hrtimer      slack_timer;
}

使用者在建立 cgroup cpu 子系統控制過程主要分成三步:

  • 第一步:透過建立目錄來建立 cgroup 物件。在 /sys/fs/cgroup/cpu,cpuacct 建立一個目錄 test,實際上核心是建立了 cgroup、task_group 等核心物件。
  • 第二步:在目錄中設定 cpu 的限制情況。在 task_group 下有個核心的 cfs_bandwidth 物件,使用者所設定的 cfs_quota_us 和 cfs_period_us 的值最後都存到它下面了。
  • 第三步:將程式新增到 cgroup 中進行資源管控。當在 cgroup 的 cgroup.proc 下新增程式 pid 時,實際上是將該程式加入到了這個新的 task_group 排程組了。將使用 task_group 的 runqueue,以及它的時間配額

當建立完成後,核心的 period_timer 會根據 task_group->cfs_bandwidth 下使用者設定的 period 定時給可執行時間 runtime 上加上 quota 這麼多的時間(相當於按月發工資),以供 task_group 下的程式執行(消費)的時候使用。

struct cfs_rq {
 ...
 int         runtime_enabled;
 s64         runtime_remaining;
}

在完全公平器排程的時候,每次 pick_next_task_fair 時會做兩件事情

  • 第一件:將從 cpu 上拿下來的程式所在的執行佇列進行執行時間的更新與申請。會將 cfs_rq 的 runtime_remaining 減去已經執行了的時間。如果減為了負數,則從 cfs_rq 所在的 task_group 下的 cfs_bandwidth 去申請一些。
  • 第二件:判斷 cfs_rq 上是否申請到了可執行時間,如果沒有申請到,需要將這個佇列上的所有程式都從完全公平排程器的紅黑樹上取下。這樣再次排程的時候,這些程式就不會被排程了。

當 period_timer 再次給 task_group 分配時間的時候,或者是自己有申請時間沒用完回收後觸發 slack_timer 的時候,被限制排程的程式會被解除排程限制,重新正常參與執行。

這裡要注意的是,一般 period_timer 分配時間的週期都是 100 ms 左右。假如說你的程式前 50 ms 就把 cpu 給用光了,那你收到的請求可能在後面的 50 ms 都沒有辦法處理,對請求處理耗時會有影響。這也是為啥在關注 CPU 效能的時候要關注對容器 throttle 次數和時間的原因了。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024923/viewspace-2940022/,如需轉載,請註明出處,否則將追究法律責任。

相關文章