你管这叫操作系统源码之八
如果让你来设计进程调度
本篇本应讲fork,但这个是创建新进程的过程,是一个很能体现操作系统设计的地方,所以我们先别急着看代码,就先头脑风暴下,如果让你来设计整个进程调度,你会怎么搞?别告诉我你先设计锁、设计 volatile 啥的,这都不是进程调度本身需要关心的最根本问题。
进程调度本质是什么?很简单,假如有三段代码被加载到内存中:
进程调度就是让 CPU 一会去程序 1 的位置处运行一段时间,一会去程序 2 的位置处运行一段时间。嗯,就这么简单,别反驳我,接着往下看
整体流程设计
如何做到刚刚说的,一会去这运行,一会去那运行?
第一种办法:程序 1 的代码里,每隔几行就写一段代码,主动放弃自己的执行权,跳转到程序 2 的地方运行。然后程序 2 也是如此。但这种依靠程序自己的办法肯定不靠谱。
第二种办法:由一个不受任何程序控制的,第三方的不可抗力,每隔一段时间就中断一下 CPU 的运行,然后跳转到一个特殊的程序那里,这个程序通过某种方式获取到 CPU 下一个要运行的程序的地址,然后跳转过去。这个每隔一段时间就中断 CPU 的不可抗力,就是由定时器触发的时钟中断。
不知道你是否还记得,这个定时器和时钟中断,早在系列之六中 进程调度初始化 里讲的 sched_init
函数里就搞定了。
而那个特殊的程序,就是具体的进程调度函数了。好了,整个流程就这样处理完了,那么应该设计什么样的数据结构,来支持这个流程呢?不妨假设这个结构叫 task_struct。
换句话说,你总得有一个结构来记录各个进程的信息,比如它上一次执行到哪里了,要不 CPU 就算决定好了要跳转到你这个进程上运行,具体跳到哪一行运行,总得有个地方存吧?我们一个个问题抛开来看。
上下文环境
每个程序最终的本质就是执行指令。这个过程会涉及寄存器,内存和外设端口。内存还有可能设计成相互错开的,互不干扰,比如进程 1 你就用 0~1K 的内存空间,进程 2 就用 1K~2K 的内存空间,咱谁也别影响谁。虽然有点浪费空间,而且对程序员十分不友好,但起码还是能实现的。
不过寄存器一共就那么点,肯定做不到互不干扰,可能一个进程就把寄存器全用上了,那其他进程咋整。
比如程序 1 刚刚往 eax 写入一个值,准备用,这时切换到进程 2 了,又往 eax 里写入了一个值。那么之后再切回进程 1 的时候,就出错了。所以最稳妥的做法就是,每次切换进程时,都把当前这些寄存器的值存到一个地方,以便之后切换回来的时候恢复。
Linux 0.11 就是这样做的,每个进程的结构 task_struct
里面,有一个叫 tss 的结构,存储的就是 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;
};
细节: 你发现 tss 结构里还有个 cr3 不?它表示 cr3 寄存器里存的值,还记得系列之三中进入main前的最后一跃cr3 寄存器是指向页目录表首地址的。
那么指向不同的页目录表,整个页表结构就是完全不同的一套,那么线性地址到物理地址的映射关系就有能力做到不同。也就是说,在我们刚刚假设的理想情况下,不同程序用不同的内存地址可以做到内存互不干扰。
但是有了这个 cr3 字段,就完全可以无需由各个进程自己保证不和其他进程使用的内存冲突,因为只要建立不同的映射关系即可,由操作系统来建立不同的页目录表并替换 cr3 寄存器即可。这也可以理解为,保存了内存映射的上下文信息。当然 Linux 0.11 并不是通过替换 cr3 寄存器来实现内存互不干扰的,它的实现更为简单,这是后话了。
运行时间信息
如何判断一个进程该让出 CPU 了,切换到下一个进程呢?总不能是每次时钟中断时都切换一次吧?一来这样不灵活,二来这完全依赖时钟中断的频率,有点危险。
所以一个好的办法就是,给进程一个属性,叫剩余时间片,每次时钟中断来了之后都 -1,如果减到 0 了,就触发切换进程的操作。在 Linux 0.11 里,这个属性就是 counter。
struct task_struct {
...
long counter;
...
struct tss_struct tss;
}
而他的用法也非常简单,就是每次中断都判断一下是否到 0 了。
void do_timer(long cpl) {
...
// 当前线程还有剩余时间片,直接返回
if ((--current->counter)>0) return;
// 若没有剩余时间片,调度
schedule();
}
如果还没到 0,就直接返回,相当于这次时钟中断什么也没做,仅仅是给当前进程的时间片属性做了 -1 操作。如果已经到 0 了,就触发进程调度,选择下一个进程并使 CPU 跳转到那里运行。进程调度的逻辑就是在 schedule 函数里,怎么调,我们先不管。
优先级
上面那个 counter 一开始的时候该是多少呢?而且随着 counter 不断递减,减到 0 时,下一轮回中这个 counter 应该赋予什么值呢?其实这俩问题都是一个问题,就是 counter 的初始化问题,也需要有一个属性来记录这个值。
往宏观想一下,这个值越大,那么 counter 就越大,那么每次轮到这个进程时,它在 CPU 中运行的时间就越长,也就是这个进程比其他进程得到了更多 CPU 运行的时间。那我们可以把这个值称为优先级,是不是很形象。
struct task_struct {
...
long counter;
long priority;
...
struct tss_struct tss;
}
每次一个进程初始化时,都把 counter 赋值为这个 priority,而且当 counter 减为 0 时,下一次分配时间片,也赋值为这个。其实叫啥都行,反正就是这么用的,就叫优先级吧。
进程状态
其实我们有了上面那三个信息,就已经可以完成进程的调度了。甚至如果你的操作系统让所有进程都得到同样的运行时间,连 counter 和 priority 都不用记录,就操作系统自己定一个固定值一直递减,减到 0 了就随机切一个新进程。这样就仅仅维护好寄存器的上下文信息 tss 就好了。但我们总要不断优化以适应不同场景的用户需求的,那我们再优化一个细节。
很简单的一个场景,一个进程中有一个读取硬盘的操作,发起读请求后,要等好久才能得到硬盘的中断信号。那这个时间其实该进程再占用着 CPU 也没用,此时就可以选择主动放弃 CPU 执行权,然后再把自己的状态标记为等待中。意思是告诉进程调度的代码,先别调度我,因为我还在等硬盘的中断,现在轮到我了也没用,把机会给别人吧。那这个状态可以记录一个属性了,叫 state,记录了此时进程的状态。
struct task_struct {
long state;
long counter;
long priority;
...
struct tss_struct tss;
}
而这个进程的状态在 Linux 0.11 里有这么五种。
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define TASK_ZOMBIE 3
#define TASK_STOPPED 4
好了,目前我们这几个字段,就已经可以完成简单的进程调度任务了。有表示状态的 state,表示剩余时间片的 counter,表示优先级的 priority,和表示上下文信息的 tss。其他字段我们需要用到的时候再说,今天只是头脑风暴一下进程调度设计的思路。
我们看一下 Linux 0.11 中进程结构的全部,心里先有个数,具体干嘛的先别管,就记住我们刚刚头脑风暴的那四个字段就行了。
struct task_struct {
/* these are hardcoded - don't touch */
long state; /* -1 unrunnable, 0 runnable, >0 stopped */
long counter;
long priority;
long signal;
struct sigaction sigaction[32];
long blocked; /* bitmap of masked signals */
/* various fields */
int exit_code;
unsigned long start_code,end_code,end_data,brk,start_stack;
long pid,father,pgrp,session,leader;
unsigned short uid,euid,suid;
unsigned short gid,egid,sgid;
long alarm;
long utime,stime,cutime,cstime,start_time;
unsigned short used_math;
/* file system info */
int tty; /* -1 if no tty, so it must be signed */
unsigned short umask;
struct m_inode * pwd;
struct m_inode * root;
struct m_inode * executable;
unsigned long close_on_exec;
struct file * filp[NR_OPEN];
/* ldt for this task 0 - zero 1 - cs 2 - ds&ss */
struct desc_struct ldt[3];
/* tss for this task */
struct tss_struct tss;
};
好了,我们完全由自己从零到有设计出了进程调度的大体流程,以及它需要的数据结构。我们知道了进程调度的开始,要从一次定时器滴答来触发,通过时钟中断处理函数走到进程调度函数,然后去进程的结构 task_struct 中取出所需的数据,进行策略计算,并挑选出下一个可以得到 CPU 运行的进程,跳转过去。
下一节我们从一次时钟中断出发,看看一次 Linux 0.11 的进程调度的全过程。有了这两回做铺垫,之后再看主流程中的 fork 代码,将会非常清晰!
从一次定时器滴答来看进程调度
定时器进程
还记得我们在系列之六中进程调度初始化shed_init开启了定时器吧?这个定时器每隔一段时间就会向CPU发起一个中断信号:
这个间隔时间在schedule.c
中被设置为 10 ms,也就是 100 Hz:#define HZ 100
。发起的中断叫时钟中断,其中断向量号被设置为了 0x20。还记得我们在 sched_init 里设置的时钟中断和对应的中断处理函数吧:set_intr_gate(0x20, &timer_interrupt);
。这样,当时钟中断,也就是 0x20 号中断来临时,CPU 会查找中断向量表中 0x20 处的函数地址,即中断处理函数,并跳转过去执行。这个中断处理函数就是 timer_interrupt,是system_call.s
用汇编语言写的:
_timer_interrupt:
...
// 增加系统滴答数
incl _jiffies
...
// 调用函数 do_timer
call _do_timer
...
这个函数做了两件事,一个是将系统滴答数这个变量 jiffies
加一,一个是调用了另一个在sched.c
中的函数 do_timer
:
void do_timer(long cpl) {
...
// 当前线程还有剩余时间片,直接返回
if ((--current->counter)>0) return;
// 若没有剩余时间片,调度
schedule();
}
do_timer 最重要的部分就是上面这段代码,非常简单。首先将当先进程的时间片 -1,然后判断:如果时间片仍然大于零,则什么都不做直接返回。如果时间片已经为零,则调用 schedule()
,很明显,这就是进行进程调度的主干。
void schedule(void) {
int i, next, c;
struct task_struct ** p;
...
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);
}
看到没,就剩这么点了。很简答,这个函数就做了三件事:
-
拿到剩余时间片(counter的值)最大且在 runnable 状态(state = 0)的进程号 next。
-
如果所有 runnable 进程时间片都为 0,则将所有进程(注意不仅仅是 runnable 的进程)的 counter 重新赋值(
counter = counter/2 + priority
),然后再次执行步骤 1。 -
最后拿到了一个进程号 next,调用了
switch_to(next)
这个方法,就切换到了这个进程去执行了。
看 switch_to 方法,是在sched.h
中用内联汇编语句写的:
#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 信息加载到各个寄存器。
这个图在《Linux内核完全注释V5.0》这本书里里画的非常清晰,我就不重复造轮子了。简单说就是,保存当前进程上下文,恢复下一个进程的上下文,跳过去!看,不知不觉,我们上节和本节开头提到的那些进程数据结构的字段,就都用上了。
struct task_struct {
long state;
long counter;
long priority;
...
struct tss_struct tss;
}
至此,我们梳理完了一个进程切换的整条链路。
小结
罪魁祸首的,就是那个每 10ms 触发一次的定时器滴答。而这个滴答将会给 CPU 产生一个时钟中断信号。而这个中断信号会使 CPU 查找中断向量表,找到操作系统写好的一个时钟中断处理函数 do_timer。
do_timer 会首先将当前进程的 counter 变量 -1,如果 counter 此时仍然大于 0,则就此结束。但如果 counter = 0 了,就开始进行进程的调度。
进程调度就是找到所有处于 RUNNABLE 状态的进程,并找到一个 counter 值最大的进程,把它丢进 switch_to
函数的入参里。switch_to
这个终极函数,会保存当前进程上下文,恢复要跳转到的这个进程的上下文,同时使得 CPU 跳转到这个进程的偏移地址处。接着,这个进程就舒舒服服地运行了起来,等待着下一次时钟中断的来临。