プロセススケジューリングロジックのLinuxカーネルソースコード分析(概要共有)

WBOY
リリース: 2022-02-05 06:00:34
オリジナル
2979 人が閲覧しました

この記事では、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.1 仮想メモリの切り替え

  • #5.2 汎用レジスタの切り替え

  • ##5.3 context_switch() の概要

6 この記事の概要

0.2 pick_next_task(): 準備完了キューからプロセスを選択します

カーネルがスケジューリングするプロセスを選択するとき、最初に、そのプロセスが存在するかどうかを判断します。現在の CPU 上でスケジュール可能なプロセスです。存在しない場合は、プロセス移行ロジックを実行し、他の CPU からプロセスを移行します。存在する場合は、仮想時間の小さいプロセスを選択してスケジュールします。

カーネルは、移行プロセスの論理 CPU を選択するとき、移行プロセスのパフォーマンスを向上させるため、つまり移行後の L1 L2 L3 キャッシュ障害を回避するために、ターゲット ロジックを移行しようとします。可能な限り現在の論理 CPU とキャッシュを共有します。現在の論理 CPU に近いほど良いです。

カーネルは、プロセスのバッチを均一にスケジュールし、あらゆるスケジューリング レベルでの公平性を確保するために、プロセスをスケジューリング エンティティに抽象化します。

いわゆる高品質プロセスの選択では、実際には仮想時間の短いプロセスが選択されます。プロセスの仮想時間は、プロセスの実際の優先度とプロセスの実行時間に基づいて動的に計算されます。

0.3 5 context_switch(): コンテキスト切り替えを実行します。

コンテキスト切り替えを処理します。コアは仮想メモリといくつかの汎用レジスタを切り替える必要があります。

プロセスが仮想メモリを切り替える場合、対応する TLB 内の ASID とページ テーブルを切り替える必要があります。ページ テーブルは、異なるプロセスの仮想メモリ変換に必要な「マップ」でもあります。

プロセスのデータ構造には、汎用レジスタの値を保存する間接フィールド cpu_context があります。レジスタ切り替えの本質は、前のプロセスのレジスタを cpu_context フィールドに保存し、その後次のプロセスの cpu_context データ構造体を保存し、 のフィールドがレジスタにロードされ、プロセスの切り替えが完了します。

1 オペレーティング システムの理論レベル

オペレーティング システムを学習したことのある学生は、プロセスのスケジューリングが次の 2 つのステップに分かれていることを知っているはずです。特定のアルゴリズム プロセスを選択します。

プロセスコンテキストの切り替えを実行します。

2 番目のステップは次のように分割できます:

仮想メモリの切り替え。

レジスタの切り替え、つまり、前のプロセスのレジスタをプロセスのデータ構造に保存し、次のプロセスのデータ構造をレジスタにロードします。

仮想メモリに関するロジックについては、以降の記事で詳しく分析していきますが、この記事では簡単にまとめます。

この記事では、基本的にオペレーティング システムの理論に従って、カーネル ソース コードの観点から、Linux がプロセス スケジューリングのコア ロジックをどのように実装しているかを分析します。

2 スケジューリングのメイン関数

Linux プロセス スケジューリングの主な関数は、schedule() と __schedule() です。この 2 つの関係は、ソース コードから確認できます:

// 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) { 
    ... 
}
ログイン後にコピー

yield システムコールなど、schedule() メソッドを実行するプロセスが積極的に CPU を放棄する場合 プロセススケジューリングの実行中、他のプロセスが現在のプロセスをプリエンプトすることは禁止されます。これは、現在のプロセスにこのプロセスの切り替えを適切に完了させ、それを奪わないようにするためです。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() メソッドでは、プロセス切り替えのコア ステップはオペレーティング システムの理論と一致しています (2 つのコア ステップ 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 中国語 Web サイトの他の関連記事を参照してください。

関連ラベル:
ソース:juejin.im
このウェブサイトの声明
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。
人気のチュートリアル
詳細>
最新のダウンロード
詳細>
ウェブエフェクト
公式サイト
サイト素材
フロントエンドテンプレート