进程调度的时机

硬中断和软中断

中断有很多种,但都是程序执行过程中的强制性转移,转移到操作系统内核相应的处理程序

除了主动让出CPU外,进程的调度都需要在进程外(内核)进行,这就需要从进程的指令流里切换出来
中断处理程序是与进程无关的内核指令流,起到切出进程指令流的作用

运行完内核代码后,CPU会检测是否需要进程调度
若需要则切换进程(本质上是切换内核堆栈),否则根据函数调用堆栈返回iret到原进程继续执行

ntel定义的中断类型
硬中断
CPU的两根引脚(可屏蔽和不可屏蔽)
CPU在执行每条指令后会检测这两根引脚的电平,高电平则有中断请求
一般外设以这种方式中断CPU的,如时钟、键盘、硬盘等

软中断/异常
包括除零错误、系统调用、调试断点等在CPU执行指令过程中发生的各种特殊情况统称为异常
异常会导致程序无法继续执行,而跳转到CPU预设的处理函数

异常分为三类

  • 故障fault
    出问题,但可以恢复到当前指令
    如除零错误,缺页中断等
  • 退出abort
    不可恢复的严重故障,导致程序无法继续执行
    如连续发生故障double fault
  • 陷进trap
    程序主动产生的异常,在执行当前指令后发生
    程序借助中断机制进行转移,从CPU的处理机制上,与其他中断没有区别
    如系统调用int 0x80及调试断点指令int 3

进程调度时机

schedule函数
实现进程调度,在运行队列中找到一个进程,分配CPU
调度一次即执行一次schedule函数,位于linux-3.18.6/kernel/sched/core.c#2865

调用schedule函数的两种方法

  • 进程主动调用schedule()
    如进程调用阻塞的系统调用等待外设或主动睡眠等,最终都会在内核中调用schedule函数
  • 松散调用
    内核代码可以随时调用schedule()使当前内核路径(中断处理程序或内核线程)让出CPU

上下文
一般,CPU在任意时刻都处于三种情况之一
运行于用户空间,执行用户进程上下文
运行于内核空间,处于进程(一般为内核线程)上下文
运行于内核空间,处于中断(中断处理程序ISR,包括系统调用处理过程)上下文

应用程序通过系统调用陷入内核,或外设产生中断,抬高CPU中断引脚电平,此时CPU处于中断上下文
中断上下文的get_current获取一个指向当前进程的指针,指向被中断进程或即将运行的就绪进程
为了系统的运行效率,会限制在中断上下文中调用其他内核代码
内核线程以进程上下文的形式运行在内核空间,本质上还是进程,但有调用内核代码的权限,如调用schedule函数让出CPU

进程调度时机
当内核返回用户空间时,内核会检查need_resched标志,从而决定是否调用schedule函数,即调度进程
内核线程或中断处理程序中在需要暂时中止当前执行路径的位置时,都可以直接调用schedule(),如等待某个资源就绪

进程调度时机如下:
用户进程通过特定的系统调用主动让出CPU
中断处理程序在内核返回用户态时进行调度
内核线程主动调用schedule函数让出CPU
中断处理程序主动调用schedule函数让出CPU

Linux中没有线程概念,从内核角度看,不管是进程还是内核线程,都对应一个task_struct结构体,本质上都是进程
Linux系统在用户态实现的线程库pthread是通过在内核中多个进程共享一个地址空间实现的

调度策略与算法

调度算法就是从就绪队列中选一个进程的策略

进程分类1

  • I/O消耗型进程
    需要大量文件读写操作或网络读写操作,如文件服务器的服务进程
    特点是CPU负载不高,大量时间都在等待读写数据
  • CPU消耗型进程
    如视频编码转换、加密解密算法等
    特点是CPU占用率高,没有太多硬件读写操作

进程分类2

  • 交互式进程
    有大量的人机交互,进程不断处于睡眠状态,等待用户输入,如VIM编辑器
    这就要求系统响应时间短
  • 批处理进程
    后台运行,占用大量系统资源
    对系统响应性要求不高,如编译器
  • 实时进程
    对调度延迟要求高

Linux系统解决方案
对于实时进程,采用FIFO或Round Robin时间片轮转的调度策略
对于其他进程,采用CFS(Completely Fair Scheduler)调度

调度策略

/*
 * scheduling policies
 */
#define SCHED_NORMAL 0 // 普通进程
#define SCHED_FIFO   1 // 实时进程
#define SCHED_RR     2 // 实时进程
#define SCHED_BATCH  3 // 保留,未实现
#define SCHED_IDLE   5 // idle进程

Linux中根据进程优先级来区分普通进程和实时进程
内核进程优先级为0~139,0为最高优先级
实时进程优先级取值为0~99
普通进程只具有nice值,映射到优先级为100~139

子进程会继承父进程的优先级,对于实时进程,Linux系统会尽量将其调度延时在一个时间期限内,但不能保证总是如此

SCHED_FIFO采用先进先出的调度策略,对于所有相同优先级的进程,最先进入就绪队列的进程总能优先获得调度,直到其主动放弃CPU
SCHED_RR采用更公平的轮转Round Robin策略,使得相同优先级的实时进程轮流获得调度,每次运行一个时间片

SCHED_NORMAL使用在Linux-2.6.23内核版本中引入的CFS调度策略,且每个进程能够分配到的CPU时间占比跟系统当前负载有关
因为交互式进程的执行时间很少,所以CFS算法对交互式进程的响应较好
CFS完全公平调度算法,是基于权重的动态优先级调度算法,位于kernel/sched/fair.c
每个进程每次占用CPU后能执行的时间ideal_runtime由进程权重决定,且保证在某个时间周期__sched_period内运行队列里的所有进程都能被调度至少一次

调度周期__sched_period
__sched_period = nr_running * sysctl_sched_min_granularity
nr_running为进程数
sysctl_sched_min_granularity默认值为0.75ms
进程越多,调度周期越长,上限默认值为8ms

理论运行时间ideal_runtime
ideal_runtime = __sched_period * 进程权重 / 运行队列总权重
每次进程获取CPU后最长可占用CPU的时间为ideal_runtime

虚拟运行时间vruntime
每个进程都有一个vruntime,调度时选择vruntime值最小的进程
vruntime维护在时钟中断里,每次时钟中断及进程就绪、阻塞等状态变化时更新
计算方法:
if se->load.weight != NICE_0_LOAD
vruntime+= delta_exec;
else
vruntime+= delta_exec* NICE_0_LOAD/se.load->weight
说明如下
se即schedule entity,存储进程调度相关属性的结构体
se->load.weight表示当前进程权重
NICE_0_LOAD表示nice值为0的进程权重
delta_exec表示当前进程本次运行时间

为避免长时间占用CPU,新进程的vruntime会设置为一定的初始值,而非0
若进程是0优先级,其虚拟时间等于实际执行的物理时间,权重越大,虚拟时间增长的越慢
每次更新完vruntime后,会进行一次检查,决定是否需要设置调度标志need_schedule
系统中断返回时会检查该标志,并按需进行进程调度

时钟中断周期
Linux传统默认时钟周期为10ms,由param.h的HZ定义
Linux-3.9内核版本中为4ms,在boot目录下的config-x.x.x文件中的CONFIG_HZ配置,时钟中断为1/CONFIG_HZ

Linux传统优先级和权重的转换关系是经验值
static const int prio_to_weight[40]={
/* -20 / 88761, 71755, 56483, 46273, 36291, 1411,
/
-15 / 29154, 23254, 18705, 14949, 11916, 1412,
/
-10 / 9548, 7620, 6100, 4904, 3906, 1413,
/
-5 */ 3121, 2501, 1991, 1586, 1277,
...
}

就绪进程排序和存储
Linux采用红黑树rb tree存储就绪进程指针
当进程进入就绪队列时,根据vruntime排序,调度时选择最左边的叶子节点即可

进程上下文切换

恢复执行一个进程之前,内核必须确保每个寄存器装入了挂起进程时的值
进程上下文包括:

  • 用户地址空间
    程序代码、数据、用户堆栈等
  • 控制信息
    进程描述符、内核堆栈等
  • 硬件上下文
    寄存器相关值

CR3寄存器,代表进程页目录表,即地址空间、数据等
ESP寄存器,代表进程内核堆栈
struct thread、进程控制块、内核堆栈存储于连续8KB区域中,通过ESP获取地址
EIP寄存器即其他寄存器,代表进程硬件上下文,即要执行的下条指令

安装8086体系结构的设计,进程切换使用一个专用的段类型(任务状态段task state segment,TSS)存放硬件上下文
Linux并不使用TSS进行硬件上下文切换,但依然为系统中每个不同的CPU创建一个TSS,主要保存不同运行级别(ring0~3)的堆栈信息

每个进程描述符包含一个类型为thread_struct的thread字段,只要进程被切换出去,内核就将硬件上下文保存在这个结构中
thread字段包含大部分CPU寄存器,但不包含如eax、ebx等通用寄存器,其值保存在内核堆栈中

在实际代码中,每个进程切换基本由两个步骤组成
切换页全局目录CR3以载入一个新的地址空间,这样不同进程的虚拟地址就会经过不同的页表转换为不同的物理地址
切换内核态堆栈和硬件上下文

核心代码分析
schedule函数选择一个新进程运行,并调用context_switch进行上下文切换,位于linux-3.18.6/kernel/sched/core.c#2336
调用switch_mm切换CR3
调用宏switch_to进行硬件上下文切换

地址空间切换
关键代码load_cr3,将下一进程的页表地址装入CR3,此时,所有虚拟地址转换都使用next进程的页表项
因为所有进程对内核地址空间是相同的,所以在内核态时,使用任意进程的页表转换的内核地址都是相同的

static inline void context_switch(struct rq* rq, struct task_struct* prev, struct task_struct* next){
    ...
    if(unlikely(!mm)){                // 若切换进来的进程的mm为空切换,内核线程mm为空
        next->active_mm= oldmm;       // 将共享切换出去进程的active_mm
        atomic_inc(&oldmm->mm_count); // 有一个进程共享,所有引用计数加一
        // 将per cpu保留cpu_tlbstate状态设为LAZY
        enter_lazy_tlb(oldmm, next);
    } else {                          // 普通mm不为空,则调用switch_mm切换地址空间
        switch_mm(oldmm, mm, next);
    }
    ...
    // 切换寄存器状态和栈
    switch_to(prev, next, prev);
    ...
}

static inline void switch_mm(struct mm_struct* prev, struct mm_struct* next, struct task_struct* tsk){
    ...
    if(!cpumask_test_and_set_cpu(cpu, mm_cpumask(next))){
        load_cr3(next->pgd); // 地址空间切换
        load_LDT_nolock(&next->context);
    }
#endif
}

堆栈及硬件上下文
该部分是内联汇编代码
宏switch_to,位于linux-3.18.6/arch/x86/include/asm/switch_to.h#31

#define switch_to(prev, next, last)
do{
    /*
     * context-switching clobbers all registers, so we clobber
     * them explicitly, via unused output variables.
     * (EAX and EBP is not listed because EBP is saved/restored
     *  explicitly for wchan access and EAX is the return value of
     *  __switch_to())
     */
     unsigned long ebx, ecx, edx, esi, edi;
     asm volatile(
        "pushfl\n\t"      // 保存当前进程flags
        "pushl %%ebp\n\t" // 将当前进程堆栈基址压栈
        "movl %%esp, %[prev_sp]\n\t" // 保存ESP,将当前堆栈栈顶保存
        "movl %[next_sp], %%esp\n\t" // 更新ESP,将下一栈顶保存到ESP
            // 完成内核堆栈的切换
        "movl $1f, %[prev_ip]\n\t"   // 保存当前进程EIP
        "pushl %[next_ip]\n\t"       // 将next进程起点压栈,即next进程的栈顶
            __switch_canary
            // next_ip一般是$1f,对于新创建的子进程是ret_from_fork
        "jmp __switch_to\n"          // prev进程中,设置next进程堆栈
            // jmp不同于call,是通过寄存器传递参数,而不是通过堆栈传递
            // 所以ret时,弹出的是之前压入栈顶的next进程起点
            // 完成EIP的切换
        "1:\t"                       // next进程开始执行
        "popl %%ebp\n\t"
        "popfl\n"

        // 输出量定义
        : [prev_sp] "=m" (prev->thread.sp), // 保存prev进程的esp
          [prev_ip] "=m" (prev->thread.ip), // 保存prev进程的eip
          "=a" (last),

          // 要破坏的寄存器
          "=b" (ebx), "=c" (ecx), "=d" (edx),
          "=S" (esi), "=D" (edi)

          __switch_canary_oparam

        // 输入变量
        : [next_sp] "m" (next->thread.sp), // next进程内核堆栈栈顶地址,即esp
          [next_ip] "m" (next->thread.ip), // next进程的原eip
          // [next_ip]下一个进程执行起点,一般是$1f,对于新创建的子进程是ret_from_fork
          // regparm parameters for __switch_to():
          [prev] "a" (prev),
          [next] "d" (next)

          __switch_canary_iparam

          : // 重新加载段寄存器
          "memory"
     );
}

上述代码的伪代码如下

pushfl
pushl %ebp            // s0
prev->thread.sp= %esp // s1
%esp= next->thread.sp // s2
prev->thread.ip= $1f  // s3

push next->thread.ip  // s4
jmp _switch_to        // s5

1f:
popl %%ebp            // s6,与s0对齐
popfl

伪代码中可以看出,s0两句在prev的堆栈中将eflag和ebp寄存器压栈
s1将当前esp寄存器保存到进程PCB的prev->thread.ip中
s2载入next->thread.sp到ESP寄存器,执行该指令后,进程从prev变为next,说明如下
每个进程的进程控制块与内核堆栈在内核中占连续8KB内存
内核中get_current用来获取当前进程,利用ESP寄存器低14位置0来实现8KB对齐
所以ESP寄存器切换后,再调用get_current得到的进程指针就是next进程
s3保存$1f位置的内存地址到prev->thread.ip
s4将$1f压栈,此时是next进程的堆栈
s5跳转到c函数,通常call与return搭配,call会自动压栈返回地址,return会自动弹出返回地址
jmp不会压栈,所以此时return弹出的是$1f位置,即s4+s5模拟了一个call,且自由控制__switch_to的返回地址,如$1f地址
s6此时下一进程运行,对称地将s0压栈的数据弹出

接下来的代码是函数调用堆栈
堆栈存储了进程所有的函数调用历史
先返回到next进程上次切换让出CPU时的schedule()中,然后返回到调用schedule()的系统调用处理过程中
而系统调用又是int 0x80触发的,所以通过中断上下文返回到系统调用被触发的地方,接着执行用户空间代码
此时返回路径是根据next进程堆栈中保存的返回地址来返回的,所以是返回到next进程中

进程上下文切换时需要保存进程切换相关信息,如thread.sp和thread.ip,这与中断上下文切换是不同的
中断是在一个进程中从进程的用户态到进程的内核态,或从进程的内核态返回到进程的用户态

一般进程上下文切换是嵌套在中断上下文切换中的
比如系统调用作为一种中断,先陷入内核,即发生中断保存现场
在系统调用处理过程中,调用了schedule()发生进程上下文切换
当系统调用返回到用户态时会恢复现场
至此完成了保存现场和恢复现场,即完成了中断上下文切换

posted @ 2024-12-05 13:06  sgqmax  阅读(109)  评论(0)    收藏  举报