Linux 核心排程器原始碼分析 - 初始化

騰訊雲原生發表於2021-05-14

導語

上篇系列文 混部之殤-論雲原生資源隔離技術之CPU隔離(一) 介紹了雲原生混部場景中CPU資源隔離核心技術:核心排程器,本系列文章《Linux核心排程器原始碼分析》將從原始碼的角度剖析核心排程的具體原理和實現,我們將以 Linux kernel 5.4 版本(TencentOS Server3 預設核心版本)為物件,從排程器子系統的初始化程式碼開始,分析 Linux 核心排程器的設計與實現。

排程器(Scheduler)子系統是核心的核心子系統之一,負責系統內 CPU 資源的合理分配,需要能處理紛繁複雜的不同型別任務的排程需求,還需要能處理各種複雜的併發競爭環境,同時還需要兼顧整體吞吐效能和實時性要求(本身是一對矛盾體),其設計與實現都極具挑戰。

為了能夠理解 Linux 排程器的設計與實現,我們將以 Linux kernel 5.4 版本(TencentOS Server3 預設核心版本)為物件,從排程器子系統的初始化程式碼開始,分析 Linux 核心排程器的設計與實現。

本(系列)文通過分析 Linux 排程器(主要針對 CFS)的設計與實現,希望能夠讓讀者瞭解:

  • 排程器的基本概念
  • 排程器的初始化(包括排程域相關的種種)
  • 程式的建立、執行與銷燬
  • 程式切換原理與實現
  • CFS 程式排程策略(單核)
  • 如何在全域性系統的排程上保證 CPU 資源的合理使用
  • 如何平衡 CPU 快取熱度與 CPU 負載之間的關係
  • 很 special 的排程器 features 分析

排程器的基本概念

在分析排程器的相關程式碼之前,需要先了解一下排程器涉及的核心資料(結構)以及它們的作用

執行佇列(rq)

核心會為每個 CPU 建立一個執行佇列,系統中的就緒態(處於 Running 狀態的)程式(task)都會被組織到核心執行佇列上,然後根據相應的策略,排程執行佇列上的程式到 CPU 上執行。

排程類(sched_class)

核心將排程策略(sched_class)進行了高度的抽象,形成排程類(sched_class)。通過排程類可以將排程器的公共程式碼(機制)和具體不同排程類提供的排程策略進行充分解耦,是典型的 OO(物件導向)的思想。通過這樣的設計,可以讓核心排程器極具擴充套件性,開發者通過很少的程式碼(基本不需改動公共程式碼)就可以增加一個新的排程類,從而實現一種全新的排程器(類),比如,deadline排程類就是3.x中新增的,從程式碼層面看只是增加了 dl_sched_class 這個結構體的相關實現函式,就很方便的新增了一個新的實時排程型別。

目前的5.4核心,有5種排程類,優先順序從高到底分佈如下:

stop_sched_class:

優先順序最高的排程類,它與 idle_sched_class 一樣,是一個專用的排程型別(除了 migration 執行緒之外,其他的 task 都是不能或者說不應該被設定為 stop 排程類)。該排程類專用於實現類似 active balance 或 stop machine 等依賴於 migration 執行緒執行的“緊急”任務。

dl_sched_class:

deadline 排程類的優先順序僅次於 stop 排程類,它是一種基於 EDL 演算法實現的實時排程器(或者說排程策略)。

rt_sched_class:

rt 排程類的優先順序要低於 dl 排程類,是一種基於優先順序實現的實時排程器。

fair_sched_class:

CFS 排程器的優先順序要低於上面的三個排程類,它是基於公平排程思想而設計的排程型別,是 Linux 核心的預設排程類。

idle_sched_class:

idle 排程型別是 swapper 執行緒,主要是讓 swapper 執行緒接管 CPU,通過 cpuidle/nohz 等框架讓 CPU 進入節能狀態。

排程域(sched_domain)

排程域是在2.6裡引入核心的,通過多級排程域引入,能夠讓排程器更好的適應硬體的物理特性(排程域可以更好的適配 CPU 多級快取以及 NUMA 物理特性對負載均衡所帶來的挑戰),實現更好的排程效能(sched_domain 是為 CFS 排程類負載均衡而開發的機制)。

排程組(sched_group)

排程組是與排程域一起被引入核心的,它會與排程域一起配合,協助 CFS 排程器完成多核間的負載均衡。

根域(root_domain)

根域主要是負責實時排程類(包括 dl 和 rt 排程類)負載均衡而設計的資料結構,協助 dl 和 rt 排程類完成實時任務的合理排程。在沒有用 isolate 或者 cpuset cgroup 修改排程域的時候,那麼預設情況下所有的CPU都會處於同一個根域。

組排程(group_sched)

為了能夠對系統裡的資源進行更精細的控制,核心引入了 cgroup 機制來進行資源控制。而 group_sched 就是 cpu cgroup 的底層實現機制,通過 cpu cgroup 我們可以將一些程式設定為一個 cgroup,並且通過 cpu cgroup 的控制介面配置相應的頻寬和 share 等引數,這樣我們就可以按照 group 為單位,對 CPU 資源進行精細的控制。

排程器初始化(sched_init)

下面進入正題,開始分析核心排程器的初始化流程,希望能通過這裡的分析,讓大家瞭解:

1、執行佇列是如何被初始化的

2、組排程是如何與 rq 關聯起來的(只有關聯之後才能通過 group_sched 進行組排程)

3、CFS 軟中斷 SCHED_SOFTIRQ 註冊

排程初始化(sched_init)

start_kernel

​ |----setup_arch

​ |----build_all_zonelists

​ |----mm_init

​ |----sched_init 排程初始化

排程初始化位於 start_kernel 相對靠後的位置,這個時候記憶體初始化已經完成,所以可以看到 sched_init 裡面已經可以呼叫 kzmalloc 等記憶體申請函式了。

sched_init 需要為每個 CPU 初始化執行佇列(rq)、dl/rt 的全域性預設頻寬、各個排程類的執行佇列以及 CFS 軟中斷註冊等工作。

接下來我們看看 sched_init 的具體實現(省略了部分程式碼):

void __init sched_init(void)
{
    unsigned long ptr = 0;
    int i;
 
    /*
     * 初始化全域性預設的rt和dl的CPU頻寬控制資料結構
     *
     * 這裡的rt_bandwidth和dl_bandwidth是用來控制全域性的DL和RT的使用頻寬,防止實時程式
     * CPU使用過多,從而導致普通的CFS程式出現飢餓的情況
     */
    init_rt_bandwidth(&def_rt_bandwidth, global_rt_period(), global_rt_runtime());
    init_dl_bandwidth(&def_dl_bandwidth, global_rt_period(), global_rt_runtime());
 
#ifdef CONFIG_SMP
    /*
     * 初始化預設的根域
     *
     * 根域是dl/rt等實時程式做全域性均衡的重要資料結構,以rt為例
     * root_domain->cpupri 是這個根域範圍內每個CPU上執行的RT任務的最高優先順序,以及
     * 各個優先順序任務在CPU上的分佈情況,通過cpupri的資料,那麼在rt enqueue/dequeue
     * 的時候,rt排程器就可以根據這個rt任務分佈情況來保證高優先順序的任務得到優先
     * 執行
     */
    init_defrootdomain();
#endif
 
#ifdef CONFIG_RT_GROUP_SCHED
    /*
     * 如果核心支援rt組排程(RT_GROUP_SCHED), 那麼對RT任務的頻寬控制將可以用cgroup
     * 的粒度來控制每個group裡rt任務的CPU頻寬使用情況
     *
     * RT_GROUP_SCHED可以讓rt任務以cpu cgroup的形式來整體控制頻寬
     * 這樣可以為RT頻寬控制帶來更大的靈活性(沒有RT_GROUP_SCHED的時候,只能控制RT的全域性
     * 頻寬使用,不能通過指定group的形式控制部分RT程式頻寬)
     */
    init_rt_bandwidth(&root_task_group.rt_bandwidth,
            global_rt_period(), global_rt_runtime());
#endif /* CONFIG_RT_GROUP_SCHED */
 
    /* 為每個CPU初始化它的執行佇列 */
    for_each_possible_cpu(i) {
        struct rq *rq;
 
        rq = cpu_rq(i);
        raw_spin_lock_init(&rq->lock);
        /*
         * 初始化rq上cfs/rt/dl的執行佇列
         * 每個排程型別在rq上都有各自的執行佇列,每個排程類都是各自管理自己的程式
         * 在pick_next_task()的時候,核心根據排程類優先順序的順序,從高到底選擇任務
         * 這樣就保證了高優先順序排程類任務會優先得到執行
         *
         * stop和idle是特殊的排程型別,是為專門的目的而設計的排程類,並不允許使用者
         * 建立相應型別的程式,所以核心也沒有在rq裡設計對應的執行佇列
         */
        init_cfs_rq(&rq->cfs);
        init_rt_rq(&rq->rt);
        init_dl_rq(&rq->dl);
#ifdef CONFIG_FAIR_GROUP_SCHED
        /*
         * CFS的組排程(group_sched),可以通過cpu cgroup來對CFS進行進行控制
         * 可以通過cpu.shares來提供group之間的CPU比例控制(讓不同的cgroup按照對應
         * 的比例來分享CPU),也可以通過cpu.cfs_quota_us來進行配額設定(與RT的
         * 頻寬控制類似)。CFS group_sched頻寬控制是容器實現的基礎底層技術之一
         *
         * root_task_group 是預設的根task_group,其他的cpu cgroup都會以它做為
         * parent或者ancestor。這裡的初始化將root_task_group與rq的cfs執行佇列
         * 關聯起來,這裡做的很有意思,直接將root_task_group->cfs_rq[cpu] = &rq->cfs
         * 這樣在cpu cgroup根下的程式或者cgroup tg的sched_entity會直接加入到rq->cfs
         * 佇列裡,可以減少一層查詢開銷。
         */
        root_task_group.shares = ROOT_TASK_GROUP_LOAD;
        INIT_LIST_HEAD(&rq->leaf_cfs_rq_list);
        rq->tmp_alone_branch = &rq->leaf_cfs_rq_list;
        init_cfs_bandwidth(&root_task_group.cfs_bandwidth);
        init_tg_cfs_entry(&root_task_group, &rq->cfs, NULL, i, NULL);
#endif /* CONFIG_FAIR_GROUP_SCHED */
 
        rq->rt.rt_runtime = def_rt_bandwidth.rt_runtime;
#ifdef CONFIG_RT_GROUP_SCHED
        /* 初始化rq上的rt執行佇列,與上面的CFS的組排程初始化類似 */
        init_tg_rt_entry(&root_task_group, &rq->rt, NULL, i, NULL);
#endif
 
#ifdef CONFIG_SMP
        /*
         * 這裡將rq與預設的def_root_domain進行關聯,如果是SMP系統,那麼後面
         * 在sched_init_smp的時候,核心會建立新的root_domain,然後替換這裡
         * def_root_domain
         */
        rq_attach_root(rq, &def_root_domain);
#endif /* CONFIG_SMP */
    }
 
    /*
     * 註冊CFS的SCHED_SOFTIRQ軟中斷服務函式
     * 這個軟中斷住要是週期性負載均衡以及nohz idle load balance而準備的
     */
    init_sched_fair_class();
 
    scheduler_running = 1;
}

多核排程初始化(sched_init_smp)

start_kernel

​ |----rest_init

​ |----kernel_init

​ |----kernel_init_freeable

​ |----smp_init

​ |----sched_init_smp

​ |---- sched_init_numa

​ |---- sched_init_domains

​ |---- build_sched_domains

多核排程初始化主要是完成排程域/排程組的初始化(當然根域也會做,但相對而言,根域的初始化會比較簡單)。

Linux 是一個可以跑在多種晶片架構,多種記憶體架構(UMA/NUMA)上執行的作業系統,所以 Linu x需要能夠適配多種物理結構,所以它的排程域設計與實現也是相對比較複雜的。

排程域實現原理

在講具體的排程域初始化程式碼之前,我們需要先了解排程域與物理拓撲結構之間的關係(因為排程域的設計是與物理拓撲結構息息相關的,如果不理解物理拓撲結構,那麼就沒有辦法真正理解排程域的實現)

CPU的物理拓撲圖

我們假設一個計算機系統(與 intel 晶片類似,但縮小 CPU 核心數,以方便表示):

雙 socket 的計算機系統,每個 socket 都是2核4執行緒組成,那麼這個計算機系統就應該是一個4核8執行緒的 NUMA 系統(上面只是 intel 的物理拓撲結構,而像 AMD ZEN 架構採用了 chiplet 的設計,它在 MC 與 NUMA 域之間會多一層 DIE 域)。

第一層(SMT 域):

如上圖的 CORE0,2個超執行緒構成了 SMT 域。對於 intel cpu 而言,超執行緒共享了 L1 與 L2(甚至連 store buffe 都在一定程度上共享),所以 SMT 域之間互相遷移是沒有任何快取熱度損失的

第二層(MC 域):

如上圖 CORE0 與 CORE1,他們位於同一個 SOCKET,屬於 MC 域。對於 intel cpu 而言,他們一般共享 LLC(一般是 L3),在這個域裡程式遷移雖然會失去 L1 與 L2 的熱度,但 L3 的快取熱度還是可以保持的

第三層(NUMA域):

如上圖的 SOCKET0 和 SOCKET1,它們之間的程式遷移會導致所有快取熱度的損失,會有較大的開銷,所以 NUMA 域的遷移需要相對的謹慎。

正是由於這樣的硬體物理特性(不同層級的快取熱度、NUMA 訪問延遲等硬體因素),所以核心抽象了 sched_domain 和 sched_group 來表示這樣的物理特性。在做負載均衡的時候,根據相應的排程域特性,做不同的排程策略(例如負載均衡的頻率、不平衡的因子以及喚醒選核邏輯等),從而在CPU 負載與快取親和性上做更好的平衡。

排程域具體實現

接下來我們可以看看核心如何在上面的物理拓撲結構上建立排程域與排程組的

核心會根據物理拓撲結構建立對應層次的排程域,然後在每層排程域上再建立相應的排程組。排程域在做負載均衡,是在對應層次的排程域裡找到負載最重的 busiest sg(sched_group),然後再判斷 buiest sg 與 local sg(但前 CPU 所在的排程組)的負載是否不均。如果存在負載不均的情況,則會從 buiest sg 裡選擇 buisest cpu,然後進行2個 CPU 間的負載平衡。

SMT 域是最底層的排程域,可以看到每個超執行緒對就是一個 smt domain。smt domain 裡有2個 sched_group,而每個 sched_group 則只會有一個CPU。所以 smt 域的負載均衡就是執行超執行緒間的程式遷移,這個負載均衡的時間最短,條件最寬鬆。

而對於不存在超執行緒的架構(或者說晶片沒有開啟超執行緒),那麼最底層域就是MC域(這個時候就只有2層域,MC 與 NUMA)。這樣 MC 域裡每個 CORE 都是一個 sched_group,核心在排程的時候也可以很好的適應這樣的場景。

MC 域則是 socket 上 CPU 所有的 CPU 組成,而其中每個 sg 則為上級 smt domain 的所有CPU構成。所以對於上圖而言,MC 的 sg 則由2個 CPU 組成。核心在 MC 域這樣設計,可以讓 CFS 排程類在喚醒負載均衡以及空閒負載均衡時,要求 MC 域的 sg 間需要均衡。

這個設計對於超執行緒來說很重要,我們在一些實際的業務裡也可以觀察到這樣的情況。例如,我們有一項編解碼的業務,發現它在某些虛擬機器裡的測試資料較好,而在某些虛擬機器裡的測試資料較差。通過分析後發現,這是由於是否往虛擬機器透傳超執行緒資訊導致的。當我們向虛擬機器透傳超執行緒資訊後,虛擬機器會形成2層排程域(SMT 與 MC域),而在喚醒負載均衡的時候,CFS 會傾向於將業務排程到空閒的 sg 上(即空閒的物理 CORE,而不是空閒的 CPU),這個時候業務在 CPU 利用率不高(沒有超過40%)的時候,可以更加充分的利用物理CORE的效能(還是老問題,一個物理CORE上的超執行緒對,它們同時執行 CPU 消耗型業務時,所獲得的效能增益只相當於單執行緒1.2倍左右。),從而獲得較好的效能增益。而如果沒有透傳超執行緒資訊,那麼虛擬機器只有一層物理拓撲結構(MC域),那麼由於業務很可能被排程通過一個物理 CORE 的超執行緒對上,這樣會導致系統無法充分利用物理CORE 的效能,從而導致業務效能偏低。

NUMA 域則是由系統裡的所有 CPU 構成,SOCKET 上的所有 CPU 構成一個 sg,上圖的 NUMA 域由2個 sg 構成。NUMA 的 sg 之間需要有較大的不平衡時(並且這裡的不平衡是 sg 級別的,即要 sg 上所有CPU負載總和與另外一個 sg 不平衡),才能進行跨 NUMA 的程式遷移(因為跨 NUMA 的遷移會導致 L1 L2 L3 的所有快取熱度損失,以及可能引發更多的跨 NUMA 記憶體訪問,所以需要小心應對)。

從上面的介紹可以看到,通過 sched_domain 與 sched_group 的配合,核心能夠適配各種物理拓撲結構(是否開啟超執行緒、是否開啟使用 NUMA),高效的使用 CPU 資源。

smp_init

/*
 * Called by boot processor to activate the rest.
 *
 * 在SMP架構裡,BSP需要將其他的非boot cp全部bring up
 */
void __init smp_init(void)
{
    int num_nodes, num_cpus;
    unsigned int cpu;
 
    /* 為每個CPU建立其idle thread */
    idle_threads_init();
    /* 向核心註冊cpuhp執行緒 */
    cpuhp_threads_init();
 
    pr_info("Bringing up secondary CPUs ...\n");
 
    /*
     * FIXME: This should be done in userspace --RR
     *
     * 如果CPU沒有online,則用cpu_up將其bring up
     */
    for_each_present_cpu(cpu) {
        if (num_online_cpus() >= setup_max_cpus)
            break;
        if (!cpu_online(cpu))
            cpu_up(cpu);
    }
     
    .............
}

在真正開始 sched_init_smp 排程域初始化之前,需要先 bring up 所有非 boot cpu,保證這些 CPU 處於 ready 狀態,然後才能開始多核排程域的初始化。

sched_init_smp

那這裡我們來看看多核排程初始化具體的程式碼實現(如果沒有配置 CONFIG_SMP,那麼則不會執行到這裡的相關實現)

sched_init_numa

sched_init_numa() 是用來檢測系統裡是否為 NUMA,如果是的則需要動態新增 NUMA 域。

/*
 * Topology list, bottom-up.
 *
 * Linux預設的物理拓撲結構
 *
 * 這裡只有三級物理拓撲結構,NUMA域是在sched_init_numa()自動檢測的
 * 如果存在NUMA域,則會新增對應的NUMA排程域
 *
 * 注:這裡預設的 default_topology 排程域可能會存在一些問題,例如
 * 有的平臺不存在DIE域(intel平臺),那麼就可能出現LLC與DIE域重疊的情況
 * 所以核心會在排程域建立好後,在cpu_attach_domain()裡掃描所有排程
 * 如果存在排程重疊的情況,則會destroy_sched_domain對應的重疊排程域
 */
static struct sched_domain_topology_level default_topology[] = {
#ifdef CONFIG_SCHED_SMT
    { cpu_smt_mask, cpu_smt_flags, SD_INIT_NAME(SMT) },
#endif
#ifdef CONFIG_SCHED_MC
    { cpu_coregroup_mask, cpu_core_flags, SD_INIT_NAME(MC) },
#endif
    { cpu_cpu_mask, SD_INIT_NAME(DIE) },
    { NULL, },
};

Linux預設的物理拓撲結構

/*
 * NUMA排程域初始化(根據硬體資訊建立新的sched_domain_topology物理拓撲結構)
 *
 * 核心在預設情況下並不會主動新增NUMA topology,需要根據配置(如果開啟了NUMA)
 * 如果開啟了NUMA,這裡就要根據硬體拓撲資訊來判斷是否需要新增
 * sched_domain_topology_level 域(只有新增了這個域之後,核心才會在後面初始化
 * sched_domain的時候建立NUMA DOMAIN)
 */
void sched_init_numa(void)
{
    ...................
    /*
     * 這裡會根據distance檢查是否存在NUMA域(甚至存在多級NUMA域),然後根據
     * 情況將其更新到物理拓撲結構裡。後面的建立排程域的時候,就會這個新的
     * 物理拓撲結構來建立新的排程域
     */
    for (j = 1; j < level; i++, j++) {
        tl[i] = (struct sched_domain_topology_level){
            .mask = sd_numa_mask,
            .sd_flags = cpu_numa_flags,
            .flags = SDTL_OVERLAP,
            .numa_level = j,
            SD_INIT_NAME(NUMA)
        };
    }
 
    sched_domain_topology = tl;
 
    sched_domains_numa_levels = level;
    sched_max_numa_distance = sched_domains_numa_distance[level - 1];
 
    init_numa_topology_type();
}

檢測系統的物理拓撲結構,如果存在 NUMA 域則需要將其加到 sched_domain_topology 裡,後面就會根據 sched_domain_topology 這個物理拓撲結構來建立相應的排程域。

sched_init_domains

下面接著分析 sched_init_domains 這個排程域建立函式

/*
 * Set up scheduler domains and groups.  For now this just excludes isolated
 * CPUs, but could be used to exclude other special cases in the future.
 */
int sched_init_domains(const struct cpumask *cpu_map)
{
    int err;
 
    zalloc_cpumask_var(&sched_domains_tmpmask, GFP_KERNEL);
    zalloc_cpumask_var(&sched_domains_tmpmask2, GFP_KERNEL);
    zalloc_cpumask_var(&fallback_doms, GFP_KERNEL);
 
    arch_update_cpu_topology();
    ndoms_cur = 1;
    doms_cur = alloc_sched_domains(ndoms_cur);
    if (!doms_cur)
        doms_cur = &fallback_doms;
    /*
     * doms_cur[0] 表示排程域需要覆蓋的cpumask
     *
     * 如果系統裡用isolcpus=對某些CPU進行了隔離,那麼這些CPU是不會加入到排程
     * 域裡面,即這些CPU不會參於到負載均衡(這裡的負載均衡包括DL/RT以及CFS)。
     * 這裡用 cpu_map & housekeeping_cpumask(HK_FLAG_DOMAIN) 的方式將isolate
     * cpu去除掉,從而在保證建立的排程域裡不包含isolate cpu
     */
    cpumask_and(doms_cur[0], cpu_map, housekeeping_cpumask(HK_FLAG_DOMAIN));
    /* 排程域建立的實現函式 */
    err = build_sched_domains(doms_cur[0], NULL);
    register_sched_domain_sysctl();
 
    return err;
}
/*
 * Build sched domains for a given set of CPUs and attach the sched domains
 * to the individual CPUs
 */
static int
build_sched_domains(const struct cpumask *cpu_map, struct sched_domain_attr *attr)
{
    enum s_alloc alloc_state = sa_none;
    struct sched_domain *sd;
    struct s_data d;
    struct rq *rq = NULL;
    int i, ret = -ENOMEM;
    struct sched_domain_topology_level *tl_asym;
    bool has_asym = false;
 
    if (WARN_ON(cpumask_empty(cpu_map)))
        goto error;
 
    /*
     * Linux裡的絕大部分程式都為CFS排程類,所以CFS裡的sched_domain將會被頻繁
     * 的訪問與修改(例如nohz_idle以及sched_domain裡的各種統計),所以sched_domain
     * 的設計需要優先考慮到效率問題,於是核心採用了percpu的方式來實現sched_domain
     * CPU間的每級sd都是獨立申請的percpu變數,這樣可以利用percpu的特性解決它們
     * 間的併發競爭問題(1、不需要鎖保護 2、沒有cachline偽共享)
     */
    alloc_state = __visit_domain_allocation_hell(&d, cpu_map);
    if (alloc_state != sa_rootdomain)
        goto error;
 
    tl_asym = asym_cpu_capacity_level(cpu_map);
 
    /*
     * Set up domains for CPUs specified by the cpu_map:
     *
     * 這裡會遍歷cpu_map裡所有CPU,為這些CPU建立與物理拓撲結構對應(
     * for_each_sd_topology)的多級排程域。
     *
     * 在排程域建立的時候,會通過tl->mask(cpu)獲得cpu在該級排程域對應
     * 的span(即cpu與其他對應的cpu組成了這個排程域),在同一個排程域裡
     * 的CPU對應的sd在剛開始的時候會被初始化成一樣的(包括sd->pan、
     * sd->imbalance_pct以及sd->flags等引數)。
     */
    for_each_cpu(i, cpu_map) {
        struct sched_domain_topology_level *tl;
 
        sd = NULL;
        for_each_sd_topology(tl) {
            int dflags = 0;
 
            if (tl == tl_asym) {
                dflags |= SD_ASYM_CPUCAPACITY;
                has_asym = true;
            }
 
            sd = build_sched_domain(tl, cpu_map, attr, sd, dflags, i);
 
            if (tl == sched_domain_topology)
                *per_cpu_ptr(d.sd, i) = sd;
            if (tl->flags & SDTL_OVERLAP)
                sd->flags |= SD_OVERLAP;
            if (cpumask_equal(cpu_map, sched_domain_span(sd)))
                break;
        }
    }
 
    /*
     * Build the groups for the domains
     *
     * 建立排程組
     *
     * 我們可以從2個排程域的實現看到sched_group的作用
     * 1、NUMA域 2、LLC域
     *
     * numa sched_domain->span會包含NUMA域上所有的CPU,當需要進行均衡的時候
     * NUMA域不應該以cpu為單位,而是應該以socket為單位,即只有socket1與socket2
     * 極度不平衡的時候才在這兩個SOCKET間遷移CPU。如果用sched_domain來實現這個
     * 抽象則會導致靈活性不夠(後面的MC域可以看到),所以核心會以sched_group來
     * 表示一個cpu集合,每個socket屬於一個sched_group。當這兩個sched_group不平衡
     * 的時候才會允許遷移
     *
     * MC域也是類似的,CPU可能是超執行緒,而超執行緒的效能與物理核不是對等的。一對
     * 超執行緒大概等於1.2倍於物理核的效能。所以在排程的時候,我們需要考慮超執行緒
     * 對之間的均衡性,即先要滿足CPU間均衡,然後才是CPU內的超執行緒均衡。這個時候
     * 用sched_group來做抽象,一個sched_group表示一個物理CPU(2個超執行緒),這個時候
     * LLC保證CPU間的均衡,從而避免一種極端情況:超執行緒間均衡,但是物理核上不均衡
     * 的情況,同時可以保證排程選核的時候,核心會優先實現物理執行緒,只有物理執行緒
     * 用完之後再考慮使用另外的超執行緒,讓系統可以更充分的利用CPU算力
     */
    for_each_cpu(i, cpu_map) {
        for (sd = *per_cpu_ptr(d.sd, i); sd; sd = sd->parent) {
            sd->span_weight = cpumask_weight(sched_domain_span(sd));
            if (sd->flags & SD_OVERLAP) {
                if (build_overlap_sched_groups(sd, i))
                    goto error;
            } else {
                if (build_sched_groups(sd, i))
                    goto error;
            }
        }
    }
 
    /*
     * Calculate CPU capacity for physical packages and nodes
     *
     * sched_group_capacity 是用來表示sg可使用的CPU算力
     *
     * sched_group_capacity 是考慮了每個CPU本身的算力不同(最高主頻設定不同、
     * ARM的大小核等等)、去除掉RT程式所使用的CPU(sg是為CFS準備的,所以需要
     * 去掉CPU上DL/RT程式等所使用的CPU算力)等因素之後,留給CFS sg的可用算力(因為
     * 在負載均衡的時候,不僅應該考慮到CPU上的負載,還應該考慮這個sg上的CFS
     * 可用算力。如果這個sg上程式較少,但是sched_group_capacity也較小,也是
     * 不應該遷移程式到這個sg上的)
     */
    for (i = nr_cpumask_bits-1; i >= 0; i--) {
        if (!cpumask_test_cpu(i, cpu_map))
            continue;
 
        for (sd = *per_cpu_ptr(d.sd, i); sd; sd = sd->parent) {
            claim_allocations(i, sd);
            init_sched_groups_capacity(i, sd);
        }
    }
 
    /* Attach the domains */
    rcu_read_lock();
    /*
     * 將每個CPU的rq與rd(root_domain)進行繫結,並且會檢查sd是否有重疊
     * 如果是的則需要用destroy_sched_domain()將其去掉(所以我們可以看到
     * intel的伺服器是隻有3層排程域,DIE域其實與LLC域重疊了,所以在這裡
     * 會被去掉)
     */
    for_each_cpu(i, cpu_map) {
        rq = cpu_rq(i);
        sd = *per_cpu_ptr(d.sd, i);
 
        /* Use READ_ONCE()/WRITE_ONCE() to avoid load/store tearing: */
        if (rq->cpu_capacity_orig > READ_ONCE(d.rd->max_cpu_capacity))
            WRITE_ONCE(d.rd->max_cpu_capacity, rq->cpu_capacity_orig);
 
        cpu_attach_domain(sd, d.rd, i);
    }
    rcu_read_unlock();
 
    if (has_asym)
        static_branch_inc_cpuslocked(&sched_asym_cpucapacity);
 
    if (rq && sched_debug_enabled) {
        pr_info("root domain span: %*pbl (max cpu_capacity = %lu)\n",
            cpumask_pr_args(cpu_map), rq->rd->max_cpu_capacity);
    }
 
    ret = 0;
error:
    __free_domain_allocs(&d, alloc_state, cpu_map);
 
    return ret;
}

到目前為止,我們已經將核心的排程域構建起來了,CFS 可以利用 sched_domain 來完成多核間的負載均衡了。

結語

本文主要介紹了核心排程器的基本概念,並通過分析5.4核心中排程器的初始化程式碼,介紹了排程域、排程組等基本概念的具體落地方式。整體上,5.4核心相比3.x核心,在排程器初始化邏輯,以及排程器相關的基本設計(概念/關鍵結構)上沒有本質的變化,也從側面印證了核心排程器設計的“穩定”和“優雅”。

預告:本系列下一篇文章將聚焦Linux核心排程器的基本原理和基礎框架構及相關原始碼,敬請期待。

【騰訊雲原生】雲說新品、雲研新術、雲遊新活、雲賞資訊,掃碼關注同名公眾號,及時獲取更多幹貨!!

相關文章