>  기사  >  운영 및 유지보수  >  프로세스 스케줄링 로직의 Linux 커널 소스 코드 분석(요약 공유)

프로세스 스케줄링 로직의 Linux 커널 소스 코드 분석(요약 공유)

WBOY
WBOY원래의
2022-02-05 06:00:342918검색

이 기사는 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 운영체제 이론 수준

운영체제를 공부한 학생들은 모두 프로세스 스케줄링이 다음 두 단계로 나누어진다는 것을 알아야 합니다.

특정 알고리즘에 따라 준비 큐에서 프로세스를 선택합니다.

프로세스 컨텍스트 전환을 수행합니다.

두 번째 단계는 다음과 같이 나눌 수 있습니다.

가상 메모리 전환.

스위치 레지스터, 즉 이전 프로세스의 레지스터를 프로세스의 데이터 구조에 저장하고, 다음 프로세스의 데이터 구조를 레지스터에 로드합니다.

가상 메모리 관련 로직에 대해서는 후속 글에서 자세히 분석할 예정이며, 이번 글에서는 간략하게 요약해 보겠습니다.

이 기사에서는 기본적으로 운영 체제 이론에 따라 Linux가 프로세스 스케줄링의 핵심 논리를 커널 소스 코드 관점에서 구현하는 방법을 분석합니다.

2 스케줄링 주요 기능

리눅스 프로세스 스케줄링의 주요 기능은 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 시스템 호출에 따라, 프로세스 스케줄링 과정에서 다른 프로세스가 현재 프로세스를 선점하는 것이 금지됩니다. 직설적으로 말하면 현재 프로세스가 이 프로세스 전환을 박탈하지 않고 완료하도록 하는 것입니다.

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 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.