首頁 > 運維 > linux運維 > 主體

Linux核心源碼分析之進程調度的邏輯(總結分享)

WBOY
發布: 2022-02-05 06:00:34
原創
2978 人瀏覽過

這篇文章為大家帶來了關於linux中核心原始碼分析進程調度邏輯的相關知識,希望對大家有幫助。

Linux核心源碼分析之進程調度的邏輯(總結分享)

0.1 本文目錄結構

1 作業系統理論層面

2 調度主函數

3 __schedule()方法核心邏輯概覽

4 pick_next_task():從就緒佇列中選取一個行程

  • 4.1 負載平衡邏輯

    ## 4.2 選取高優程序
  • 4.3 pick_next_task() 小結
  • 5 context_switch():執行上下文切換
  • 5 context_switch():執行上下文切換

##5.1 切換虛擬記憶體

5.2 切換通用暫存器

5.3 context_switch() 小結

#6 本文總結

0.2 pick_next_task():從就緒佇列中選取一個行程

核心在選擇行程進行排程的時候,會先判斷目前 CPU 上是否有行程可以調度,如果沒有,執行進程遷移邏輯,從其他 CPU 遷移進程,如果有,則選擇虛擬時間較小的進程進行調度。

核心在選擇邏輯 CPU 進行遷移進程的時候,為了提升被遷移進程的效能,即避免遷移之後 L1 L2 L3 快取失效,盡可能遷移那些和當前邏輯 CPU 共享高速緩存的目標邏輯CPU,離當前邏輯 CPU 越近越好。

核心將進程抽象化為調度實體,為的是可以將一批進程進行統一調度,在每一個調度層次上,都保證公平​​。

所謂選取高優進程,實際上選取的是虛擬時間較小的進程,進程的虛擬時間是根據進程的實際優先權和進程的運行時間等資訊動態計算出來的。

0.3 5 context_switch():執行上下文切換

進程上下文切換,核心要切換的是虛擬記憶體及一些通用暫存器。

進程切換虛擬內存,需要切換對應的 TLB 中的 ASID 及頁表,頁表也即不同進程的虛擬記憶體翻譯所需的 "map"。

進程的資料結構中,有一個間接字段 cpu_context 保存了通用寄存器的值,寄存器切換的本質就是將上一個進程的寄存器保存到 cpu_context 字段,然後再將下一個進程的 cpu_context 數據結構中的欄位載入到暫存器中,至此完成進程的切換。

1 作業系統理論層面

學過作業系統的同學應該都知道,行程調度分為以下兩個步驟:

根據某種演算法從就緒佇列中選取一個進程。

執行進程上下文切換。

其中第二個步驟又可以分為:

切換虛擬記憶體。

切換暫存器,即保存上一個程序的暫存器到程序的資料結構中,載入下一個程序的資料結構到暫存器。

關於虛擬記憶體相關的邏輯,後續文章會詳細剖析,這篇文章會簡單概括。

這篇文章,我們從核心原始碼的角度來剖析 Linux 是如何實現進程調度的核心邏輯,基本上遵從作業系統理論。

2 調度主函數

Linux 進程調度的主函數是 schedule() 和 __schedule(),從原始碼中可以看出兩者的關係:

// kernel/sched/core.c:3522
void schedule(void) {
    ...
    // 调度过程中禁止抢占
    preempt_disable(); 
    __schedule(false); //:3529
    // 调度完了,可以抢占了
    sched_preempt_enable_no_resched();
    ...
}
// kernel/sched/core.c:3395
__schedule(bool preempt) { 
    ... 
}
登入後複製

當一個進程主動讓出 CPU,例如 yield 系統調用,會執行 schedule() 方法,在執行進程調度的過程中,禁止其他進程搶佔當前進程,說白了就是讓當前進程好好完成這次進程切換,不要剝奪它的 CPU;

###3529 行程式碼表示 schedule() 呼叫了 __schedule(false) 方法,傳遞 fasle 參數,表示這是進程的主動調度,且不可搶佔。 ######等目前的行程執行完調度邏輯之後,開啟搶佔,也就是說,其他行程可以剝奪目前行程的 CPU 了。 ######而如果某個進程像個強盜一樣一直佔著 CPU 不讓,核心會透過搶佔機制(例如上一篇文章提到的週期調度機制)進行一次進程調度,從而把當前進程從CPU 上踢出去。 ######__schedule() 方法的架構就是這篇文章分析的主要內容,由於程式碼較多,我會挑選核心部分來描述。 ######3 __schedule() 方法核心邏輯概覽######我們先來看看 Linux 核心中,在流程排程核心函數 __schedule() 的架構:###
// kernel/sched/core.c:3395
void __schedule(bool preempt) {
    struct task_struct *prev, *next;
    unsigned long *switch_count;
    struct rq *rq;
    int cpu;
    // 获取当前 CPU
    cpu = smp_processor_id();
    // 获取当前 CPU 上的进程队列
    rq = cpu_rq(cpu);
    // 获取当前队列上正在运行的进程
    prev = rq->curr;
    ...
    // nivcsw:进程非主动切换次数
    switch_count = &prev->nivcsw; // :3430
    if (!preempt ...) {
        ...
        // nvcsw:进程主动切换次数 
        switch_count = &prev->nvcsw; // :3456
    }
    ...
    // 1 根据某种算法从就绪队列中选中一个进程
    next = pick_next_task(rq, prev, &rf);
    ...
    if (prev != next) {
        rq->nr_switches++;
        rq->curr = next;
        ++*switch_count;
        // 2 执行进程上下文切换
        rq = context_switch(rq, prev, next, &rf);
    } 
    ...
}
登入後複製
###可以看到, __schedule() 方法中,進程切換的核心步驟和作業系統理論中是一致的(1 和 2 兩個核心步驟)。 ###

此外,进程切换的过程中,内核会根据是主动发起调度(preempt 为 fasle)还是被动发起调度,来统计进程上下文切换的次数,分别保存在进程的数据结构 task_struct 中:

// include/linux/sched.h:592
struct task_struct {
    ...
    // 主动切换:Number of Voluntary(自愿) Context Switches
    unsigned long nvcsw; // :811
    // 非主动切换:Number of InVoluntary(非自愿) Context Switches
    unsigned long nivcsw; // :812
    ...
};
登入後複製

在 Linux 中,我们可以通过 pidstat 命令来查看一个进程的主动和被动上下文切换的次数,我们写一个简单的 c 程序来做个测试:

// test.c
#include <stdlib.h>
#include <stdio.h>
int main() {
    while (1) {
        // 每隔一秒主动休眠一次
        // 即主动让出 CPU
        // 理论上每秒都会主动切换一次
        sleep(1)
    }
}
登入後複製

然后编译运行

gcc test.c -o test
./test
登入後複製

通过 pidstat 来查看

Linux核心源碼分析之進程調度的邏輯(總結分享)

可以看到,test 应用程序每秒主动切换一次进程上下文,和我们的预期相符,对应的上下文切换数据就是从 task_struct 中获取的。

接下来,详细分析进程调度的两个核心步骤:

通过 pick_next_task() 从就绪队列中选中一个进程。

通过 context_switch 执行上下文切换。

4 pick_next_task():从就绪队列中选中一个进程

我们回顾一下 pick_next_task() 在 __schedule() 方法中的位置

// kernel/sched/core.c:3395
void __schedule(bool preempt) {
    ...
    // rq 是当前 cpu 上的进程队列
    next = pick_next_task(rq, pre ...); // :3459
    ...
}
登入後複製

跟着调用链往下探索:

// kernel/sched/core.c:3316
/* 
 * 找出优先级最高的进程
 * Pick up the highest-prio task:
 */
struct task_struct *pick_next_task(rq, pre ...) {
    struct task_struct *p;
    ...
    p = fair_sched_class.pick_next_task(rq, prev ...); // :3331
    ...
    if (!p)
        p = idle_sched_class.pick_next_task(rq, prev ...); // :3337
    return p;
}
登入後複製

从 pick_next_task() 方法的注释上也能看到,这个方法的目的就是找出优先级最高的进程,由于系统中大多数进程的调度类型都是公平调度,我们拿公平调度相关的逻辑来分析。

从上述的核心框架中可以看到,3331 行先尝试从公平调度类型的队列中获取进程,3337 行,如果没有找到,就把每个 CPU 上的 IDLE 进程取出来运行:

// kernel/sched/idle.c:442
const struct sched_class idle_sched_class = {
    ...    
    .pick_next_task    = pick_next_task_idle, // :451
    ...
};
// kernel/sched/idle.c:385
struct task_struct * pick_next_task_idle(struct rq *rq ...) {
    ...
    // 每一个 CPU 运行队列中都有一个 IDLE 进程 
    return rq->idle; // :383
}
登入後複製

接下来,我们聚焦公平调度类的进程选中算法 fair_sched_class.pick_next_task()

// kernel/sched/fair.c:10506
const struct sched_class fair_sched_class = {
   ...
   .pick_next_task = pick_next_task_fair, // : 10515 
   ...
}
// kernel/sched/fair.c:6898
static struct task_struct * pick_next_task_fair(struct rq *rq ...) {
    // cfs_rq 是当前 CPU 上公平调度类队列
    struct cfs_rq *cfs_rq = &rq->cfs;
    struct sched_entity *se;
    struct task_struct *p;
    int new_tasks;
again:
    // 1 当前 CPU 进程队列没有进程可调度,则执行负载均衡逻辑
    if (!cfs_rq->nr_running) 
        goto idle;
    // 2. 当前 CPU 进程队列有进程可调度,选中一个高优进程 p
    do {
        struct sched_entity *curr = cfs_rq->curr;
        ...
        se = pick_next_entity(cfs_rq, curr); 
        cfs_rq = group_cfs_rq(se);
    } while (cfs_rq); 
    p = task_of(se);
    ...
idle:
    // 通过负载均衡迁移进程
    new_tasks = idle_balance(rq, rf); // :7017
    ...
    
    if (new_tasks > 0)
        goto again;
    return NULL;
}
登入後複製

pick_next_task_fair() 的逻辑相对还是比较复杂的,但是,其中的核心思想分为两步:

如果当前 CPU 上已无进程可调度,则执行负载逻辑,从其他 CPU 上迁移进程过来;

如果当前 CPU 上有进程可调度,从队列中选择一个高优进程,所谓高优进程,即虚拟时间最小的进程;

下面,我们分两步拆解上述步骤。

4.1 负载均衡逻辑

内核为了让各 CPU 负载能够均衡,在某些 CPU 较为空闲的时候,会从繁忙的 CPU 上迁移进程到空闲 CPU 上运行,当然,如果进程设置了 CPU 的亲和性,即进程只能在某些 CPU 上运行,则此进程无法迁移。

负载均衡的核心逻辑是 idle_balance 方法:

// kernel/sched/fair.c:9851
static int idle_balance(struct rq *this_rq ...) {
    int this_cpu = this_rq->cpu;
    struct sched_domain *sd;
    int pulled_task = 0;
    
    ...
    for_each_domain(this_cpu, sd) { // :9897
        ...
        pulled_task = load_balance(this_cpu...);
        ...
        if (pulled_task ...) // :9912
            break;
    }
    
    ...
    return pulled_task;
}
登入後複製

idle_balance() 方法的逻辑也相对比较复杂:但是大体上概括就是,遍历当前 CPU 的所有调度域,直到迁移出进程位置。

这里涉及到一个核心概念:sched_domain,即调度域,下面用一张图介绍一下什么是调度域。

Linux核心源碼分析之進程調度的邏輯(總結分享)

内核根据处理器与主存的距离将处理器分为两个 NUMA 节点,每个节点有两个处理器。NUMA 指的是非一致性访问,每个 NUMA 节点中的处理器访问内存节点的速度不一致,不同 NUMA 节点之间不共享 L1 L2 L3 Cache。

每个 NUMA 节点下有两个处理器,同一个 NUMA 下的不同处理器共享 L3 Cache。

每个处理器下有两个 CPU 核,同个处理器下的不同核共享 L2 L3 Cache。

每个核下面有两个超线程,同一个核的不同超线程共享 L1 L2 L3 Cache。

我们在应用程序里面,通过系统 API 拿到的都是超线程,也可以叫做逻辑 CPU,下文统称逻辑 CPU。

进程在访问一个某个地址的数据的时候,会先在 L1 Cache 中找,若未找到,则在 L2 Cache 中找,再未找到,则在 L3 Cache 上找,最后都没找到,就访问主存,而访问速度方面 L1 > L2 > L3 > 主存,内核迁移进程的目标是尽可能让迁移出来的进程能够命中缓存。

内核按照上图中被虚线框起来的部分,抽象出调度域的概念,越靠近上层,调度域的范围越大,缓存失效的概率越大,因此,迁移进程的一个目标是,尽可能在低级别的调度域中获取可迁移的进程。

上述代码 idle_balance() 方法的 9897 行:for_each_domain(this_cpu, sd),this_cpu 就是逻辑 CPU(也即是最底层的超线程概念),sd 是调度域,这行代码的逻辑就是逐层往上扩大调度域;

// kernel/sched/sched.h:1268
#define for_each_domain(cpu, __sd) \
    for (__sd = cpu_rq(cpu)->sd); \
            __sd; __sd = __sd->parent)
登入後複製

而 idle_balance() 方法的 9812 行,如果在某个调度域中,成功迁移出了进程到当前逻辑 CPU,就终止循环,可见,内核为了提升应用性能真是煞费苦心。

经过负载均衡之后,当前空闲的逻辑 CPU 进程队列很有可能已经存在就绪进程了,于是,接下来从这个队列中获取最合适的进程。

4.2 选中高优进程

接下来,我们把重点放到如何选择高优进程,而在公平调度类中,会通过进程的实际优先级和运行时间,来计算一个虚拟时间,虚拟时间越少,被选中的概率越高,所以叫做公平调度。

以下是选择高优进程的核心逻辑:

// kernel/sched/fair.c:6898
static struct task_struct * pick_next_task_fair(struct rq *rq ...) {
    // cfs_rq 是当前 CPU 上公平调度类队列
    struct cfs_rq *cfs_rq = &rq->cfs;
    struct sched_entity *se;
    struct task_struct *p;
    // 2. 当前 CPU 进程队列有进程可调度,选中一个高优进程 p
    do {
        struct sched_entity *curr = cfs_rq->curr;
        ...
        se = pick_next_entity(cfs_rq, curr); 
        cfs_rq = group_cfs_rq(se);
    } while (cfs_rq); 
    // 拿到被调度实体包裹的进程
    p = task_of(se); // :6956
    ...
    return p;
}
登入後複製

内核提供一个调度实体的的概念,对应数据结构叫 sched_entity,内核实际上是根据调度实体为单位进行调度的:

// include/linux/sched.h:447
struct sched_entity {
    ...
    // 当前调度实体的父亲
    struct sched_entity    *parent;
    // 当前调度实体所在队列
    struct cfs_rq *cfs_rq;  // :468
    // 当前调度实体拥有的队列,及子调度实体队列
    // 进程是底层实体,不拥有队列
    struct cfs_rq *my_q;
    ...
};
登入後複製

每一个进程对应一个调度实体,若干调度实体绑定到一起可以形成一个更高层次的调度实体,因此有个递归的效应,上述 do while 循环的逻辑就是从当前逻辑 CPU 顶层的公平调度实体(cfs_rq->curr)开始,逐层选择虚拟时间较少的调度实体进行调度,直到最后一个调度实体是进程。

内核这样做的原因是希望尽可能在每个层次上,都能够保证调度是公平的。

拿 Docker 容器的例子来说,一个 Docker 容器中运行了若干个进程,这些进程属于同一个调度实体,和宿主机上的进程的调度实体属于同一层级,所以,如果 Docker 容器中疯狂 fork 进程,内核会计算这些进程的虚拟时间总和来和宿主机其他进程进行公平抉择,这些进程休想一直霸占着 CPU!

选择虚拟时间最少的进程的逻辑是 se = pick_next_entity(cfs_rq, curr); ,相应逻辑如下:

// kernel/sched/fair.c:4102
struct sched_entity *
pick_next_entity(cfs_rq *cfs_rq, sched_entity *curr) {
    // 公平运行队列中虚拟时间最小的调度实体
    struct sched_entity *left = __pick_first_entity(cfs_rq);
    struct sched_entity *se;
    // 如果没找到或者树中的最小虚拟时间的进程
    // 还没当前调度实体小,那就选择当前实体
    if (!left || (curr && entity_before(curr, left))) 
        left = curr;
    se = left; 
    return se;
}
// kernel/sched/fair.c:489
int entity_before(struct sched_entity *a, struct sched_entity *b) {
    // 比较两者虚拟时间
    return (s64)(a->vruntime - b->vruntime) < 0;
}
登入後複製

上述代码,我们可以分析出,pick_next_entity() 方法会在当前公平调度队列 cfs_rq 中选择最靠左的调度实体,最靠左的调度实体的虚拟时间越小,即最优。

而下面通过 __pick_first_entity() 方法,我们了解到,公平调度队列 cfs_rq 中的调度实体被组织为一棵红黑树,这棵树的最左侧节点即为最小节点:

// kernel/sched/fair.c:565
struct sched_entity *__pick_first_entity(struct cfs_rq *cfs_rq) {
    struct rb_node *left = rb_first_cached(&cfs_rq->tasks_timeline);
    if (!left)
        return NULL;
    return rb_entry(left, struct sched_entity, run_node);
}
// include/linux/rbtree.h:91
// 缓存了红黑树最左侧节点
#define rb_first_cached(root) (root)->rb_leftmost
登入後複製

通过以上分析,我们依然通过一个 Docker 的例子来分析: 一个宿主机中有两个普通进程分别为 A,B,一个 Docker 容器,容器中有 c1、c2、c3 进程。

这种情况下,系统中有两个层次的调度实体,最高层为 A、B、c1+c2+c3,再往下为 c1、c2、c3,下面我们分情况来讨论进程选中的逻辑:

1)若虚拟时间分布为:A:100s,B:200s,c1:50s,c2:100s,c3:80s

选中逻辑:先比较 A、B、c1+c2+c3 的虚拟时间,发现 A 最小,由于 A 已经是进程,选中 A,如果 A 比当前运行进程虚拟时间还小,下一个运行的进程就是 A,否则保持当前进程不变。

2)若虚拟时间分布为:A:100s,B:200s,c1:50s,c2:30s,c3:10s

选中逻辑:先比较 A、B、c1+c2+c3 的虚拟时间,发现 c1+c2+c3 最小,由于选中的调度实体非进程,而是一组进程,继续往下一层调度实体进行选择,比较 c1、c2、c3 的虚拟时间,发现 c3 的虚拟时间最小,如果 c3 的虚拟时间小于当前进程的虚拟时间,下一个运行的进程就是 c3,否则保持当前进程不变。

到这里,选中高优进程进行调度的逻辑就结束了,我们来做下小结。

4.3 pick_next_task() 小结

内核在选择进程进行调度的时候,会先判断当前 CPU 上是否有进程可以调度,如果没有,执行进程迁移逻辑,从其他 CPU 迁移进程,如果有,则选择虚拟时间较小的进程进行调度。

内核在选择逻辑 CPU 进行迁移进程的时候,为了提升被迁移进程的性能,即避免迁移之后 L1 L2 L3 高速缓存失效,尽可能迁移那些和当前逻辑 CPU 共享高速缓存的目标逻辑 CPU,离当前逻辑 CPU 越近越好。

内核将进程抽象为调度实体,为的是可以将一批进程进行统一调度,在每一个调度层次上,都保证公平。

所谓选中高优进程,实际上选中的是虚拟时间较小的进程,进程的虚拟时间是根据进程的实际优先级和进程的运行时间等信息动态计算出来的。

5 context_switch():执行上下文切换

选中一个合适的进程之后,接下来就要执行实际的进程切换了,我们把目光重新聚焦到 __schedule() 方法

// kernel/sched/core.c:3395
void __schedule(bool preempt) {
    struct task_struct *prev, *next;
    ...
    // 1 根据某种算法从就绪队列中选中一个进程
    next = pick_next_task(rq, prev,...); // :3459
    ...
    if (prev != next) {
        rq->curr = next;
        // 2 执行进程上下文切换
        rq = context_switch(rq, prev, next ...); // ::3485
    } 
    ...
}
登入後複製

其中,进程上下文切换的核心逻辑就是 context_switch,对应逻辑如下:

// kernel/sched/core.c:2804
struct rq *context_switch(... task_struct *prev, task_struct *next ...) {
    struct mm_struct *mm, *oldmm;
    ...
    mm = next->mm;
    oldmm = prev->active_mm;
    ...
    // 1 切换虚拟内存
    switch_mm_irqs_off(oldmm, mm, next);
    ...
    // 2 切换寄存器状态
    switch_to(prev, next, prev);
    ...
}
登入後複製

上述代码,我略去了一些细节,保留我们关心的核心逻辑。context_switch() 核心逻辑分为两个步骤,切换虚拟内存和寄存器状态,下面,我们展开这两段逻辑。

5.1 切换虚拟内存

首先,简要介绍一下虚拟内存的几个知识点:

进程无法直接访问到物理内存,而是通过虚拟内存到物理内存的映射机制间接访问到物理内存的。

每个进程都有自己独立的虚拟内存地址空间。如,进程 A 可以有一个虚拟地址 0x1234 映射到物理地址 0x4567,进程 B 也可以有一个虚拟地址 0x1234 映射到 0x3456,即不同进程可以有相同的虚拟地址。如果他们指向的物理内存相同,则两个进程即可通过内存共享进程通信。

进程通过多级页表机制来执行虚拟内存到物理内存的映射,如果我们简单地把这个机制当做一个 map<虚拟地址,物理地址> 数据结构的话,那么可以理解为不同的进程有维护着不同的 map;

map<虚拟地址,物理地址> 的翻译是通过多级页表来实现的,访问多级页表需要多次访问内存,效率太差,因此,内核使用 TLB 缓存频繁被访问的 <虚拟地址,物理地址> 的项目,感谢局部性原理。

由于不同进程可以有相同的虚拟地址,这些虚拟地址往往指向了不同的物理地址,因此,TLB 实际上是通过 的方式来唯一确定某个进程的物理地址的,ASID 叫做地址空间 ID(Address Space ID),每个进程唯一,等价于多租户概念中的租户 ID。

进程的虚拟地址空间用数据结构 mm_struct 来描述,进程数据结构 task_struct 中的 mm 字段就指向此数据结构,而上述所说的进程的 "map" 的信息就藏在 mm_struct 中。

关于虚拟内存的介绍,后续的文章会继续分析,这里,我们只需要了解上述几个知识点即可,我们进入到切换虚拟内存核心逻辑:

// include/linux/mmu_context.h:14
# define switch_mm_irqs_off switch_mm
// arch/arm64/include/asm/mmu_context.h:241
void switch_mm(mm_struct *prev, mm_struct *next) {
    // 如果两个进程不是同一个进程
    if (prev != next)
        __switch_mm(next);
    ...
}
// arch/arm64/include/asm/mmu_context.h:224
void __switch_mm(struct mm_struct *next) {
    unsigned int cpu = smp_processor_id();
    check_and_switch_context(next, cpu);
}
登入後複製

接下来,调用 check_and_switch_context 做实际的虚拟内存切换操作:

// arch/arm64/mm/context.c:194
void check_and_switch_context(struct mm_struct *mm, unsigned int cpu) {
    ...
    u64 asid;
    // 拿到要将下一个进程的 ASID
    asid = atomic64_read(&mm->context.id); // :218
    ...
    // 将下一个进程的 ASID 绑定到当前 CPU
    atomic64_set(&per_cpu(active_asids, cpu), asid);  // :236
    // 切换页表,及切换我们上述中的 "map",
    // 将 ASID 和 "map" 设置到对应的寄存器
    cpu_switch_mm(mm->pgd, mm); // :248
}
登入後複製

check_and_switch_context 总体上分为两块逻辑:

  • 将下一个进程的 ASID 绑定到当前的 CPU,这样 TLB 通过虚拟地址翻译出来的物理地址,就属于下个进程的。

  • 拿到下一个进程的 "map",也就是页表,对应的字段是 "mm->pgd",然后执行页表切换逻辑,这样后续如果 TLB 没命中,当前 CPU 就能够知道通过哪个 "map" 来翻译虚拟地址。

cpu_switch_mm 涉及的汇编代码较多,这里就不贴了,本质上就是将 ASID 和页表("map")的信息绑定到对应的寄存器。

5.2 切换通用寄存器

虚拟内存切换完毕之后,接下来切换进程执行相关的通用寄存器,对应逻辑为 switch_to(prev, next ...); 方法,这个方法也是切换进程的分水岭,调用完之后的那一刻,当前 CPU 上执行就是 next 的代码了。

拿 arm64 为例:

// arch/arm64/kernel/process.c:422
struct task_struct *__switch_to(task_struct *prev, task_struct *next) {
    ...
    // 实际切换方法
    cpu_switch_to(prev, next); // :444
    ...
}
登入後複製

cpu_switch_to 对应的是一段经典的汇编逻辑,看着很多,其实并不难理解。

// arch/arm64/kernel/entry.S:1040
// x0 -> pre
// x1 -> next
ENTRY(cpu_switch_to)
    // x10 存放 task_struct->thread.cpu_context 字段偏移量
    mov    x10, #THREAD_CPU_CONTEXT // :1041
    
    // ===保存 pre 上下文===
    // x8 存放 prev->thread.cpu_context
    add    x8, x0, x10 
    // 保存 prev 内核栈指针到 x9
    mov    x9, sp
    // 将 x19 ~ x28 保存在 cpu_context 字段中
    // stp 是 store pair 的意思
    stp    x19, x20, [x8], #16
    stp    x21, x22, [x8], #16
    stp    x23, x24, [x8], #16
    stp    x25, x26, [x8], #16
    stp    x27, x28, [x8], #16
    // 将 x29 存在 fp 字段,x9 存在 sp 字段
    stp    x29, x9, [x8], #16 
    // 将 pc 寄存器 lr 保存到 cpu_context 的 pc 字段
    str    lr, [x8] 
    
    
    // ===加载 next 上下文===
    // x8 存放 next->thread.cpu_context
    add    x8, x1, x10
    // 将 cpu_context 中字段载入到 x19 ~ x28
    // ldp 是 load pair 的意思
    ldp    x19, x20, [x8], #16
    ldp    x21, x22, [x8], #16
    ldp    x23, x24, [x8], #16
    ldp    x25, x26, [x8], #16
    ldp    x27, x28, [x8], #16
    ldp    x29, x9, [x8], #16
    // 设置 pc 寄存器
    ldr    lr, [x8]
    // 切换到 next 的内核栈
    mov    sp, x9
    
    // 将 next 指针保存到 sp_el0 寄存器
    msr    sp_el0, x1 
    ret
ENDPROC(cpu_switch_to)
登入後複製

上述汇编的逻辑可以和操作系统理论课里的内容一一对应,即先将通用寄存器的内容保存到进程的数据结构中对应的字段,然后再从下一个进程的数据结构中对应的字段加载到通用寄存器中。

1041 行代码是拿到 task_struct 结构中的 thread_struct thread 字段的 cpu_contxt 字段:

// arch/arm64/kernel/asm-offsets.c:53
DEFINE(THREAD_CPU_CONTEXT,    offsetof(struct task_struct, thread.cpu_context));
登入後複製

我们来分析一下对应的数据结构:

// include/linux/sched.h:592
struct task_struct {
    ...
    struct thread_struct thread; // :1212
    ...
};
// arch/arm64/include/asm/processor.h:129
struct thread_struct {
    struct cpu_context  cpu_context;
    ...
}
登入後複製

而 cpu_context 数据结构的设计就是为了保存与进程有关的一些通用寄存器的值:

// arch/arm64/include/asm/processor.h:113
struct cpu_context {
    unsigned long x19;
    unsigned long x20;
    unsigned long x21;
    unsigned long x22;
    unsigned long x23;
    unsigned long x24;
    unsigned long x25;
    unsigned long x26;
    unsigned long x27;
    unsigned long x28;
    // 对应 x29 寄存器
    unsigned long fp;
    unsigned long sp;
    // 对应 lr 寄存器
    unsigned long pc;
};
登入後複製

这些值刚好与上述汇编片段的代码一一对应上,读者应该不需要太多汇编基础就可以分析出来。

上述汇编中,最后一行 msr sp_el0, x1,x1 寄存器中保存了 next 的指针,这样后续再调用 current 宏的时候,就指向了下一个指针:

// arch/arm64/include/asm/current.h:15
static struct task_struct *get_current(void) {
    unsigned long sp_el0;
    asm ("mrs %0, sp_el0" : "=r" (sp_el0));
    return (struct task_struct *)sp_el0;
}
// current 宏,很多地方会使用到
#define current get_current()
登入後複製

进程上下文切换的核心逻辑到这里就结束了,最后我们做下小结。

5.3 context_switch() 小结

进程上下文切换,核心要切换的是虚拟内存及一些通用寄存器。

进程切换虚拟内存,需要切换对应的 TLB 中的 ASID 及页表,页表也即不同进程的虚拟内存翻译需要的 "map"。

进程的数据结构中,有一个间接字段 cpu_context 保存了通用寄存器的值,寄存器切换的本质就是将上一个进程的寄存器保存到 cpu_context 字段,然后再将下一个进程的 cpu_context 数据结构中的字段加载到寄存器中,至此完成进程的切换。

6 本文总结

最后,我们全文做下总结:

进程调度分为主动(非抢占)和被动调度(抢占),调度的核心逻辑在 __schedule() 方法中。

进程调度的核心逻辑分为两个大步骤,其一是选择一个合适的进程,其二是进行进程切换。

在选择合适的进程中,如果当前逻辑 CPU 没有可调度的进程,就从其他 CPU 来调度,迁移的进程尽可能靠近当前 CPU。

进程调度的单位其实是调度实体,一个调度实体对应一个或多个进程,这样就能够在各个层次上完成公平调度,通过调度实体的虚拟时间选择最优调度实体对应的进程。

进程切换,本质上就是切换虚拟内存及通用寄存器。

进程调度的逻辑几乎都完全遵循操作系统理论来设计的,学完操作系统之后,希望大家能够理论联系实际,对照着内核去翻一翻源码。

相关推荐:《Linux视频教程

以上是Linux核心源碼分析之進程調度的邏輯(總結分享)的詳細內容。更多資訊請關注PHP中文網其他相關文章!

相關標籤:
來源:juejin.im
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板