🚀 前言
所谓的进程调度,其实就是让 CPU 一会去程序 1 的位置处运行一段时间,一会去程序 2 的位置处运行一段时间。本文是在真正去进行进程调度之前所需要的一些准备。本文对应的章节是书中第23~24回。希望各位给个三连,拜托啦,这对我真的很重要!!!
目录
🏆进程调度大体流程
📃 如何让CPU一会去这里一会去那里
有种方法是在程序1里面隔几行就放弃自己的执行权跳转程序2,程序2也是如此。但是这种方法不靠谱且不公平。那就想个办法,找一个第三方来协调,这个第三方就是调度程序。
这时候就得提到定时器了,之前初始化的时候提到过的,不记得的话可以查看这篇博客:linux0.11内核源码修仙传第十章——进程调度始化简单来说就是定时器根据设定好的时间定时向CPU发送一个计时器中断。之后的进程调度函数的基础就是这个时钟。
📃上下文环境
有计算机基础的同学可能知道,虚拟内存的一大优点就是让每个程序都以为自己能用的整个内存,但是其实从物理层面上,只要这个程序执行程序,那就肯定会涉及寄存器,内存和外设端口。那总不能每个程序都配一组吧,自然只能做出避让了。
这些需要避让的寄存器就算是上下文环境,具体的寄存器如下所示:
具体会出现什么样的干扰呢?比如程序1往eax寄存器写入一个值准备用,这是被切换到了进程2,又往 eax 写入了一个值。那么回到进程1的时候不就出错了吗。所以聪明的你一定想到了,每个进程都把自己的环境保存好,等到CPU控制权回到自己手上的时候,重新将这些寄存器赋成之前的值就好了,恭喜你,你已经学会了保存上下文!
那么怎么保存呢?采用下面的结构体,下面的结构体就包含了所需要的所有寄存器:
struct task_struct {
···
struct tss_struct tss;
};
struct tss_struct {
long back_link; /* 16 high bits zero */
long esp0;
long ss0; /* 16 high bits zero */
long esp1;
long ss1; /* 16 high bits zero */
long esp2;
long ss2; /* 16 high bits zero */
long cr3;
long eip;
long eflags;
long eax,ecx,edx,ebx;
long esp;
long ebp;
long esi;
long edi;
long es; /* 16 high bits zero */
long cs; /* 16 high bits zero */
long ss; /* 16 high bits zero */
long ds; /* 16 high bits zero */
long fs; /* 16 high bits zero */
long gs; /* 16 high bits zero */
long ldt; /* 16 high bits zero */
long trace_bitmap; /* bits: trace 0, bitmap 16-31 */
struct i387_struct i387;
};
这里面注意,还有个 cr3
寄存器,还记得这个寄存器都是干嘛的嘛,可以看之前的博客:linux0.11内核源码修仙传第三章——head.s 的 📃开启方式 节。这个寄存器是指向页目录表首地址的。
那就很有意思了,如果每个进程都有一个cr3
寄存器,那不就意味着每个进程都可以有自己的页表了吗?事实就是如此,这样的话,线性地址到物理地址的映射关系就有能力做到不同。也就是说,在我们刚刚假设的理想情况下,不同程序用不同的内存地址可以做到内存互不干扰。但是很遗憾, Linux 0.11 并不是通过替换 cr3 寄存器来实现内存互不干扰的,这是后话了。
📃 进程调度时机
在上面我们已经解决了进程转换时的准备,现在来看看进程转换时机。
上面提到了定时器,那总不能每次时钟中断都切换一次吧?一来这样不灵活,二来这完全依赖时钟中断的频率,有点危险。所以一个好的办法就是,给进程一个属性,叫剩余时间片,每次时钟中断来了之后都 -1,如果减到 0 了,就触发切换进程的操作。在代码里面用counter
来代表。
struct task_struct {
···
long counter;
···
struct tss_struct tss;
};
他的用法也非常简单,就是每次中断都判断一下是否到 0 了。
void do_timer(long cpl)
{
···
// 当前还有剩余时间直接返回
if ((--current->counter)>0) return;
current->counter=0;
if (!cpl) return;
// 没有时间片,调度
schedule();
}
如果还没到 0,就直接返回,相当于这次时钟中断什么也没做,仅仅是给当前进程的时间片属性做了 -1
操作。如果已经到 0 了,就触发进程调度,选择下一个进程并使 CPU 跳转到那里运行。至于进程调度的逻辑 schedule
函数里面怎么实现,怎么调,我们先不管。
📃优先级
那么已经解决了调度时机的问题,现在还有个问题,这个counter每个进程设置多少合适呢?同时这个问题也关乎着集成调度之后counter重新设置多少。
往宏观想一下,这个值越大,那么 counter 就越大,那么每次轮到这个进程时,它在 CPU 中运行的时间就越长,也就是这个进程比其他进程得到了更多 CPU 运行的时间。那么这个变量的名字可以有一个大家都很熟悉的名字,叫优先级。可以在结构体里面再添加一个priority
变量:
struct task_struct {
···
long counter;
long priority;
···
struct tss_struct tss;
};
每次一个进程初始化时,都把 counter 赋值为这个 priority,而且当 counter 减为 0 时,下一次分配时间片,也赋值为这个。
📃进程状态
现在我们知道了进程调度时机,进程切换保存上下文,现在还需要保存一下进程状态,这是为什么呢?很简单的一个场景,一个进程中有一个读取硬盘的操作,发起读请求后,要等好久才能得到硬盘的中断信号。那这个时间其实该进程再占用着 CPU 也没用,此时就可以选择主动放弃 CPU 执行权,然后再把自己的状态标记为等待中。意思是告诉进程调度的代码,先别调度我,因为我还在等硬盘的中断,现在轮到我了也没用,把机会给别人吧。那这个状态可以记录一个属性了,叫 state
,记录了此时进程的状态。
struct task_struct {
long state;
long counter;
long priority;
···
struct tss_struct tss;
};
来看看linux进程一共有多少种:
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define TASK_ZOMBIE 3
#define TASK_STOPPED 4
现在我们有了进程状态,进程调度时机,进程优先级,进程上下文保存,已经可以完成调度任务了。
🏆从定时器的角度看进程调度
📃定时器中断
接下来我们重点看看定时器的事,我们都知道定时器每隔一段时间会向CPU发起一个中断信号,这个间隔时间被设置为10ms,也就是100Hz:
#define HZ 100
发起中断的函数是timer_interrupt
,详细可以看这篇博客:linux0.11内核源码修仙传第十章——进程调度始化。
void sched_init(void)
{
···
set_system_gate(0x80,&system_call);
}
_timer_interrupt:
···
# 增加系统滴答数
incl _jiffies
···
# 调用定时器中断函数 do_timer
call _do_timer
···
上面的函数做了简化,一件事是将系统滴答数这个变量 jiffies
加一,一个是调用了另一个函数 do_timer
,详细的_timer_interrupt
函数见下一节。接下来看看do_timer
函数:
void do_timer(long cpl)
{
···
if ((--current->counter)>0) return;
···
schedule();
}
do_timer
函数最重要的就是上面这一段,内容就是将当先进程的时间片 -1,然后判断时间片,如果时间片仍然大于零,则什么都不做直接返回;如果时间片已经为零,则调用 schedule()
,这个函数就是进程调度的主干。看到这里其实已经可以了,如果想了解这个函数全貌可以看下面一节的内容。
📃定时器中断函数剩余部分(选看)
先来看定时器中断函数,首先来看最开始的压栈:
_timer_interrupt:
push %ds # save ds,es and put kernel data space
push %es # into them. %fs is used by _system_call
push %fs
pushl %edx # we save %eax,%ecx,%edx as gcc doesn't
pushl %ecx # save those across function calls. %ebx
pushl %ebx # is saved as we use that in ret_sys_call
pushl %eax
···
要注意,CPU在发生中断后会自行压栈一些寄存器的值:SS,ESP,EFLAGS,CS,EIP
。然后这里又压入了一些值,现在整个栈的分布情况如下所示:
至此,包括CPU自动压栈的寄存器,基本所有重要的寄存器都有了,也就是进程上下文进行了保存。翻看中断函数可以发现这是所有中断处理函数的统一操作。
接下来来看下一段:
_timer_interrupt:
···
movl $0x10,%eax
mov %ax,%ds
mov %ax,%es
movl $0x17,%eax
mov %ax,%fs
incl _jiffies
···
这一段改变了DS和ES寄存器,值是0x10,这个正好对应了内核数据段选择子,也就是GDT中下标为2的位置。之后FS是用户数据段选择子,分析也是同理。最后incl _jiffies
递增系统时钟滴答计数器。jiffies
变量记录自系统启动以来的时钟中断次数。
中断里面只是对计数变量进行递增,接下来就是通知中断控制芯片,当前中断处理完毕,允许接收新中断:
_timer_interrupt:
···
movb $0x20,%al # EOI to interrupt controller #1
outb %al,$0x20
···
0x20
是EOI
命令码,0x20
是主PIC
的端口地址。
之后判断中断发生时CPU处于内核态(CPL=0)还是用户态(CPL=3)作为参数最后传给后续的do_timer
寄存器:
_timer_interrupt:
···
movl CS(%esp), %eax ; 从栈中加载原始CS值
andl $3, %eax ; 提取CPL(CS的最低两位)
pushl %eax ; 将CPL作为参数压栈
···
最后调用定时器处理函数并返回到公共出口,最后jmp的公共出口会通过统一出口恢复上下文:
_timer_interrupt:
···
call _do_timer ; 调用C函数do_timer(CPL)
addl $4, %esp ; 清理参数(弹出CPL)
jmp ret_from_sys_call ; 跳转到系统调用/中断返回路径
总结一下这个函数前后做了什么:
上下文保存于恢复:保存寄存器值,确保C代码能安全使用这些寄存器,最后进行中断返回。
特权级判断:获取CS寄存器低两位CPL,统计用户态和内核态时间。
进程调度函数:计算了jiffies
变量并调用do_timer
函数
中断结束:给PIC发送EOI信号,通知PIC接收新中断,避免中断丢失。
接下来看调用的do_timer
函数,首先如果计数的变量为零(初始值为之前设置时钟频率的1/8),则停止计数。之后依据上面_timer_interrupt
获取的CPL,更新用户态或者内核态的CPU时间:
static void sysbeep(void)
{
···
beepcount = HZ/8;
}
void sysbeepstop(void)
{
// 0x61端口的Bit0是定时器(1开始计数,0停止计数),Bit1是扬声器(1是发声,0是静音)
outb(inb_p(0x61)&0xFC, 0x61);
}
// cpl:优先级
void do_timer(long cpl)
{
extern int beepcount;
extern void sysbeepstop(void);
if (beepcount)
if (!--beepcount)
sysbeepstop();
if (cpl)
current->utime++;
else
current->stime++;
···
}
下面是实现内核定时任务:
void do_timer(long cpl)
{
···
if (next_timer) {
next_timer->jiffies--;
while (next_timer && next_timer->jiffies <= 0) {
void (*fn)(void);
fn = next_timer->fn;
next_timer->fn = NULL;
next_timer = next_timer->next;
(fn)();
}
}
···
}
将人话就是这个循环里面首先递减第一个定时器的 jiffies
;若 jiffies <= 0
,执行其回调函数 fn()
,并移除该节点;循环处理所有到期的定时器(支持多个定时器同时到期)。
最后一段了,最后是时间片管理,并调用调度函数:
void do_timer(long cpl)
{
···
if (current_DOR & 0xf0)
do_floppy_timer();
if ((--current->counter)>0) return;
current->counter=0;
if (!cpl) return;
schedule();
}
软盘已经用的不多了,因此第一个if
不重要,重要的是后面时间片管理和调度。每次中断后剩余进程片都减一,如果时间片还没用完,直接返回,如果时间片用完,则重置为0。其次,仅当中断发生在用户态时,会调用调度函数 schedule()
。如果是在内核态,则不触发调度,确保内核代码的原子性。
📃进程调度函数
上面铺垫终于结束了,终于来到了进程调度函数schedule()
,先来看整体代码一饱眼福:
void schedule(void)
{
int i,next,c;
struct task_struct ** p;
/* check alarm, wake up any interruptible tasks that have got a signal */
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
if (*p) {
if ((*p)->alarm && (*p)->alarm < jiffies) {
(*p)->signal |= (1<<(SIGALRM-1));
(*p)->alarm = 0;
}
if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) &&
(*p)->state==TASK_INTERRUPTIBLE)
(*p)->state=TASK_RUNNING;
}
/* this is the scheduler proper: */
while (1) {
c = -1;
next = 0;
i = NR_TASKS;
p = &task[NR_TASKS];
while (--i) {
if (!*--p)
continue;
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
c = (*p)->counter, next = i;
}
if (c) break;
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
if (*p)
(*p)->counter = ((*p)->counter >> 1) +
(*p)->priority;
}
switch_to(next);
}
下面进行拆解,直接抽取出主干:
void schedule(void) {
int next = get_max_counter_and_runnable_thread();
refresh_all_thread_counter();
switch_to(next);
}
所以调度函数只干了三件事:
拿到剩余时间片最大且在
runnable
状态的进程号 next。
如果所有 runnable 进程时间片都为 0,则将所有进程(不仅仅是 runnable 的进程)的 counter 重新赋值,然后再次执行步骤 1
最后拿到了一个进程号 next,调用了 switch_to(next) 这个方法,就切换到了这个进程去执行了。
上面第二步中,要将所有进程的counter
重新复制,那么有个问题,只是runnable的进程全是0了,但是其他进程还不一定为0,那么赋值多少合适呢?这里linus给出的答案是:(counter = counter/2 + priority)
现在可以来看具体的代码了:
void schedule(void)
{
···
while (1) {
c = -1;
next = 0;
i = NR_TASKS;
p = &task[NR_TASKS];
while (--i) {
if (!*--p)
continue;
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
c = (*p)->counter, next = i;
}
if (c) break;
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
if (*p)
(*p)->counter = ((*p)->counter >> 1) + (*p)->priority;
}
switch_to(next);
}
schedule
函数里面就是一直遍历所有非空任务,选择状态为TASK_RUNNING
且 counter
值最大的任务。counter
表示剩余时间片,越大优先级越高。若找到有效任务(c > 0),跳出循环!这不是和上面第一步对上了吗!最后一个for
循环就是所有就绪任务的counter为0,重新计算所有任务的counter,计算公式就是上面介绍的那个。
现在来看最后的switch
函数:
#define switch_to(n) {\
struct {long a,b;} __tmp; \
__asm__("cmpl %%ecx,_current\n\t" \
"je 1f\n\t" \
"movw %%dx,%1\n\t" \
"xchgl %%ecx,_current\n\t" \
"ljmp %0\n\t" \
"cmpl %%ecx,_last_task_used_math\n\t" \
"jne 1f\n\t" \
"clts\n" \
"1:" \
::"m" (*&__tmp.a),"m" (*&__tmp.b), \
"d" (_TSS(n)),"c" ((long) task[n])); \
}
这里就是进程切换的底层代码了。看不懂没关系,其实主要就干了一件事,就是 ljmp
到新进程的 tss
段处。就是 CPU 规定,如果 ljmp
指令后面跟的是一个 tss
段,那么,会由硬件将当前各个寄存器的值保存在当前进程的 tss
中,并将新进程的 tss
信息加载到各个寄存器。说人话就是保存当前进程上下文,恢复下一个进程的上下文,跳过去!
📃流程小节
来总结一下流程:
罪魁祸首的,就是那个每 10ms 触发一次的定时器滴答
这个滴答将会给 CPU 产生一个时钟中断信号
这个中断信号会使 CPU 查找中断向量表,找到操作系统写好的一个时钟中断处理函数 do_timer
do_timer 会首先将当前进程的 counter 变量 -1,如果 counter 此时仍然大于 0,则就此结束
如果 counter = 0 了,就开始进行进程的调度
进程调度就是找到所有处于 RUNNABLE 状态的进程,并找到一个 counter 值最大的进程,把它丢进 switch_to 函数的入参里
switch_to 这个终极函数,会保存当前进程上下文,恢复要跳转到的这个进程的上下文,同时使得 CPU 跳转到这个进程的偏移地址处
接着就是等待下一次时钟中断的来临
🎯总结
这一章讲了很多东西,来做个回顾:首先我们自己设计了一下进程调度的规则,直到怎么保存上下文,设置进程运行优先级,规定了进程的几种状态。接下来从定时器的角度看进程调度,看了定时器中断函数,与进程调度函数。
📖参考资料
[1] linux源码趣读
[2] 一个64位操作系统的设计与实现