Linux 内核源码分析之进程调度的逻辑

1,880 阅读21分钟

本文涉及的 Linux 内核版本是 v5.0,可在 「elixir.bootlin.com/linux/v5.0/… 在线浏览,文中每段源码均标注了文件路径及行数,建议读者对着源码读此文。

上一篇文章中,我们了解到 Linux 内核中进程的数据结构 task_struct 及进程是在何时被调度的,这篇文章我们聚焦进程调度的逻辑。

0 本文内容一分钟速览

如果读者没有耐心看完整篇文章,下面是本文的核心内容预览,一分钟内能读完。

0.1 本文目录结构

  • 1 操作系统理论层面
  • 2 调度主函数
  • 3 __schedule() 方法核心逻辑概览
  • 4 pick_next_task():从就绪队列中选中一个进程
    • 4.1 负载均衡逻辑
    • 4.2 选中高优进程
    • 4.3 pick_next_task() 小结
  • 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 操作系统理论层面

学过操作系统的同学应该都知道,进程调度分为如下两个步骤:

  1. 根据某种算法从就绪队列中选中一个进程。
  2. 执行进程上下文切换。

其中第二个步骤又可以分为:

  1. 切换虚拟内存。
  2. 切换寄存器,即保存上一个进程的寄存器到进程的数据结构中,加载下一个进程的数据结构到寄存器中。

关于虚拟内存相关的逻辑,后续文章会详细剖析,这篇文章会简要概括。

这篇文章,我们从内核源码的角度来剖析 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 两个核心步骤)。

此外,进程切换的过程中,内核会根据是主动发起调度(preemptfasle)还是被动发起调度,来统计进程上下文切换的次数,分别保存在进程的数据结构 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 来查看

主动切换

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

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

  1. 通过 pick_next_task() 从就绪队列中选中一个进程。
  2. 通过 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() 的逻辑相对还是比较复杂的,但是,其中的核心思想分为两步:

  1. 如果当前 CPU 上已无进程可调度,则执行负载逻辑,从其他 CPU 上迁移进程过来;
  2. 如果当前 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,即调度域,下面用一张图介绍一下什么是调度域。

调度域层次结构

  1. 内核根据处理器与主存的距离将处理器分为两个 NUMA 节点,每个节点有两个处理器。NUMA 指的是非一致性访问,每个 NUMA 节点中的处理器访问内存节点的速度不一致,不同 NUMA 节点之间不共享 L1 L2 L3 Cache。
  2. 每个 NUMA 节点下有两个处理器,同一个 NUMA 下的不同处理器共享 L3 Cache。
  3. 每个处理器下有两个 CPU 核,同个处理器下的不同核共享 L2 L3 Cache。
  4. 每个核下面有两个超线程,同一个核的不同超线程共享 L1 L2 L3 Cache。
  5. 我们在应用程序里面,通过系统 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 切换虚拟内存

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

  1. 进程无法直接访问到物理内存,而是通过虚拟内存到物理内存的映射机制间接访问到物理内存的。
  2. 每个进程都有自己独立的虚拟内存地址空间。如,进程 A 可以有一个虚拟地址 0x1234 映射到物理地址 0x4567,进程 B 也可以有一个虚拟地址 0x1234 映射到 0x3456,即不同进程可以有相同的虚拟地址。如果他们指向的物理内存相同,则两个进程即可通过内存共享进程通信。
  3. 进程通过多级页表机制来执行虚拟内存到物理内存的映射,如果我们简单地把这个机制当做一个 map<虚拟地址,物理地址> 数据结构的话,那么可以理解为不同的进程有维护着不同的 map;
  4. map<虚拟地址,物理地址> 的翻译是通过多级页表来实现的,访问多级页表需要多次访问内存,效率太差,因此,内核使用 TLB 缓存频繁被访问的 <虚拟地址,物理地址> 的项目,感谢局部性原理。
  5. 由于不同进程可以有相同的虚拟地址,这些虚拟地址往往指向了不同的物理地址,因此,TLB 实际上是通过 <ASID + 虚拟地址,物理地址> 的方式来唯一确定某个进程的物理地址的,ASID 叫做地址空间 ID(Address Space ID),每个进程唯一,等价于多租户概念中的租户 ID。
  6. 进程的虚拟地址空间用数据结构 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 总体上分为两块逻辑:

  1. 将下一个进程的 ASID 绑定到当前的 CPU,这样 TLB 通过虚拟地址翻译出来的物理地址,就属于下个进程的。
  2. 拿到下一个进程的 "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, x1x1 寄存器中保存了 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。
  • 进程调度的单位其实是调度实体,一个调度实体对应一个或多个进程,这样就能够在各个层次上完成公平调度,通过调度实体的虚拟时间选择最优调度实体对应的进程。
  • 进程切换,本质上就是切换虚拟内存及通用寄存器。

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