实验要求与实验指导见 实验楼。
实验环境为 配置本地实验环境。
创建四个子进程,分别模拟不同的 CPU 和 I/O 时间:
#include
#include
#include
#include
#include
#include
#define HZ 100
void cpuio_bound(int last, int cpu_time, int io_time);
// 直接在样本程序 process.c 的 main() 函数添加:
int main(int argc, char * argv[])
{
pid_t p1, p2, p3, p4;
printf("father: my pid is %d\n",getpid());
p1 = fork();
if(p1 == 0) {
printf("child1: my pid is %d\n", getpid());
cpuio_bound(10, 1, 0); /* CPU */
printf("child1 end!\n");
return 0;
}
printf("father: create process child 1: pid = %d\n", p1);
p2 = fork();
if(p2 == 0) {
printf("child2: my pid is %d\n", getpid());
cpuio_bound(10, 0, 1); /* I/O */
printf("child2 end!\n\n");
return 0;
}
printf("father: create process child 2: pid = %d\n", p2);
p3 = fork();
if(p3 == 0) {
printf("child3: my pid is %d\n", getpid());
cpuio_bound(10, 1, 1); /* equal */
printf("child3 end!\n");
return 0;
}
printf("father: create process child 3: pid = %d\n", p3);
p4 = fork();
if(p4 == 0) {
printf("child4: my pid is %d\n", getpid());
cpuio_bound(10, 1, 9); /* I/O > CPU */
printf("child4 end!\n");
return 0;
}
printf("father: create process child 4: pid = %d\n", p4);
waitpid(p1, NULL, 0);
waitpid(p2, NULL, 0);
waitpid(p3, NULL, 0);
waitpid(p4, NULL, 0);
printf("father end\n");
return 0;
}
// 为了控制篇幅,此处不贴完整代码
void cpuio_bound(int last, int cpu_time, int io_time) { }
为了能够进行进程运行轨迹的跟踪,需要对 Linux -0.11 代码做出的修改如下:
fprintf()
; 为了能让 log 文件监控进程,需要在 init/main.c
中将它打开。在 main.c
中,init()
是第一个进程,在进程 0 中由 fork()
创建,开始执行时会以读写访问方式打开设备“/dev/tty0”,它对应终端控制台,并复制两次文件描述符,产生标准输入 stdin、标准输出 stdout、错误输出 stderr。它们产生的文件描述符是 0-2,那么打开 log 文件的文件描述符是 3。为了能监控第一个进程,需要把 init()
中的上述操作挪到 main()
中切换到用户模式与创建第一个进程之间。代码如下:
/* init/main.c 的第137行 */
move_to_user_mode();
// 将 init() 中的下列三行挪到这里
setup((void *) &drive_info);
(void) open("/dev/tty0",O_RDWR,0);
(void) dup(0);
(void) dup(0);
//建立文件,如果文件已存在则清空原内容。权限为所有人可读可写:
(void) open("/var/process.log", O_CREAT | O_TRUNC | O_WRONLY, 0666);
if (!fork()) { /* we count on this going ok */
init();
}
Linux-0.11 中没有 fprintf()
函数,这里直接引用实验楼实验指导的代码,将它添加到 kernel/printk.c
中:
int fprintk(int fd, const char *fmt, ...)
{
va_list args;
int count;
struct file * file;
struct m_inode * inode;
va_start(args, fmt);
count=vsprintf(logbuf, fmt, args);
va_end(args);
/* 如果输出到stdout或stderr,直接调用sys_write即可 */
if (fd < 3)
{
__asm__("push %%fs\n\t"
"push %%ds\n\t"
"pop %%fs\n\t"
"pushl %0\n\t"
/* 注意对于Windows环境来说,是_logbuf,下同 */
"pushl $logbuf\n\t"
"pushl %1\n\t"
/* 注意对于Windows环境来说,是_sys_write,下同 */
"call sys_write\n\t"
"addl $8,%%esp\n\t"
"popl %0\n\t"
"pop %%fs"
::"r" (count),"r" (fd):"ax","cx","dx");
}
else
/* 假定>=3的描述符都与文件关联。事实上,还存在很多其它情况,这里并没有考虑。*/
{
/* 从进程0的文件描述符表中得到文件句柄 */
if (!(file=task[0]->filp[fd]))
return 0;
inode=file->f_inode;
__asm__("push %%fs\n\t"
"push %%ds\n\t"
"pop %%fs\n\t"
"pushl %0\n\t"
"pushl $logbuf\n\t"
"pushl %1\n\t"
"pushl %2\n\t"
"call file_write\n\t"
"addl $12,%%esp\n\t"
"popl %0\n\t"
"pop %%fs"
::"r" (count),"r" (file),"r" (inode):"ax","cx","dx");
}
return count;
}
printk()
函数也在这个文件中,并且在 include/linux/kernel.h
中添加了它的函数定义原形,这里 fprintk()
函数也照做,从而在其他内核 C 文件中添加了这个头文件就能使用它:
//在 include/linux/kernel.h 中:
int printk(const char * fmt, ...);
//添加:
int fprintk(int fd, const char *fmt, ...);
在内核代码中使用下列语句就能以指定格式将进程状态记录在 log 文件中:
// 样例:
fprintk(3, "%ld\t%c\t%ld\n", pid, 'X', jiffies);
进程状态的变化在 /kernel/fork.c
、kernel/sched.c
、kernel/exit.c
中控制。
在 fork()
中,copy_process()
函数是系统调用 fork()
的主要处理部分,用于创建并复制进程的代码段、数据段和环境然后做出修改,并将进程设置为就绪态。所以需要在这里记录创建和就绪两个状态:
//设置进程的 start_time
p->start_time = jiffies;
//记录: 创建 N
fprintk(3, "%ld\t%c\t%ld\n", p->pid, 'N', jiffies);
//在 copy_process() 函数末尾,进程被设为就绪态
p->state = TASK_RUNNING;
//记录: 就绪 J
fprintk(3, "%ld\t%c\t%ld\n", p->pid, 'J', jiffies);
return last_pid;
在 exit.c
中主要描述了进程(任务)终止和退出的有关处理事宜。其中包含的 do_exit()
、sys_waitpid()
函数改变了进程状态,需要进行记录:
do_exit()
是程序退出处理函数,在系统调用处理函数sys_exit()中被调用。该函数将把当前进程置为 TASK_ZOMBIE 状态,然后去执行调度函数schedule(),不再返回。在此处需记录进程的退出状态:
current->state = TASK_ZOMBIE;
//记录 退出 E
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'E', jiffies);
sys_waitpid()
是系统调用 waitpid 的处理函数,它挂起当前进程,直到pid指定的子进程退出(终止)或者收到要求终止该进程的信号,或者是需要调用一个信号句柄(信号处理程序)。如果pid所指的子进程早已退出(已成所谓的僵死进程),则本调用将立刻返回。子进程使用的所有资源将释放。这里需要记录当前进程的阻塞态:
current->state=TASK_INTERRUPTIBLE;
//记录 阻塞 W
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'W', jiffies);
schedule();
sched.c
是内核主要的进程调度管理程序,其中包括了多个调度的基本函数。
schedule()
函数负责选择下一个要运行的进程:首先对所有进程进行检测,唤醒任何一个已经得到信号的任务;随后会根据进程的时间片和优先权调度机制来选择随后要执行的任务,并用 switch_to()
切换到该任务。
首先从任务数组的最后一个任务开始检测。如果任务的信号位图中除被阻塞信号外还要其他信号并且任务处于可中断状态,则设置任务为就绪状态。这里需要记录就绪状态:
if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) && (*p)->state==TASK_INTERRUPTIBLE) {
(*p)->state=TASK_RUNNING;
//记录 就绪 J
fprintk(3, "%ld\t%c\t%ld\n", (*p)->pid, 'J', jiffies);
}
然后,调度程序从任务数组的最后一个任务开始循环处理,比较每个就绪状态的任务的时间片 counter 值,找出大于零的时间片最大也就是运行时间最小的任务,next 就指向这个任务并切换到这个任务执行。如果没有 counter 值大于零,那就通过 counter = counter/2 + priority
更新所有进程的时间片,继续进行上述比较。得出 next 值后,如果它跟当前任务不是同一个任务,就将 CPU 使用权交给 next 指向的任务。故这里需要记录当前任务变为就绪态和 next 任务变为运行态。而切换过程是在 switch_to()
汇编宏函数里进行的,在这个函数里面添加记录代码比较麻烦,故在这个函数前面进行记录。
while(1) {
......
}
//next 跟 current 是同一个任务的话就没切换,无需记录
if(task[next]->pid != current->pid) {
//也有可能是被阻塞后进行切换,就不是从运行态变为就绪态了,所以得加个判断
if(current->state == TASK_RUNNING) {
//当前任务从运行态变为就绪态
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'J', jiffies);
}
//下一个任务变成运行态
fprintk(3, "%ld\t%c\t%ld\n", task[next]->pid, 'R', jiffies);
}
//切换到 next 运行
switch_to(next);
sys_pause()
函数是系统调用 pause 的处理函数,它会导致进程进入睡眠态,直到收到一个信号并且信号处理函数返回,sys_pause()
才返回。这里需要记录进程的阻塞态:
int sys_pause(void)
{
current->state = TASK_INTERRUPTIBLE;
//记录 阻塞 W
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'W', jiffies);
schedule();
return 0;
}
sleep_on()
函数会把当前任务设为不可中断的等待状态,并让睡眠队列头指针指向当前任务,只有通过 wait_up()
唤醒才会继续执行,如果它前面还有等待的进程,则也将其设为就绪态。这里需要记录阻塞态和就绪态:
current->state = TASK_UNINTERRUPTIBLE;
//记录 阻塞 W
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'W', jiffies);
schedule();
//只有当这个等待任务被唤醒时,调度程序才又返回到这里,表明本进程已被唤醒
if (tmp) {
//唤醒队列中的tmp进程(也就是前一个等待的进程),0还是改为TASK_RUNNING吧
tmp->state=TASK_RUNNING;
//记录 就绪 J
fprintk(3, "%ld\t%c\t%ld\n", tmp->pid, 'J', jiffies);
}
interruptible_sleep_on()
函数将当前任务设置为可中断的等待状态,并放入等待队列中。同样地,只有当这个任务被唤醒后才能继续执行。当指针 *p 不是指向当前任务时,表示在当前任务被放入队列后,又有新的任务被插入等待队列头部,因此需要先唤醒它们,等待后续进入队列的任务被唤醒执行时来唤醒本任务。此处需要记录阻塞态和就绪态:
repeat: current->state = TASK_INTERRUPTIBLE;
//记录 阻塞 W
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'W', jiffies);
schedule();
if (*p && *p != current) {
(**p).state=TASK_RUNNING;
//记录 就绪 J
fprintk(3, "%ld\t%c\t%ld\n", (*p)->pid, 'J', jiffies);
goto repeat;
}
*p=NULL;
if (tmp) {
tmp->state=TASK_RUNNING;
//记录 就绪 J
fprintk(3, "%ld\t%c\t%ld\n", tmp->pid, 'J', jiffies);
}
wake_up()
函数会唤醒 *p 指向的任务。*p 是等待队列头指针,新的等待任务是插入在队列头部的,所以唤醒的是最后进入等待队列的任务。这里需要记录就绪态:
void wake_up(struct task_struct **p)
{
if (p && *p) {
(**p).state=TASK_RUNNING;
//记录 就绪 J
fprintk(3, "%ld\t%c\t%ld\n", (*p)->pid, 'J', jiffies);
*p=NULL;
}
}
上述改动完成后,将 process.c
在 Linux-0.11 上运行:
获得 process.log
,用 stat_log.py
进行数据统计,结果如下:
回忆一下,在 process.c
中对四个子进程的参数分配如下,可见与统计结果基本一致。
child1: cpuio_bound(10, 1, 0); /* CPU */
child2: cpuio_bound(10, 0, 1); /* I/O */
child3: cpuio_bound(10, 1, 1); /* equal */
child4: cpuio_bound(10, 1, 9); /* I/O > CPU */
在 Linux-0.11 中,进程的优先级继承自父进程,在没有修改过优先级的情况下,所有进程的优先级都跟 include/sched.h
中定义的 INIT_TASK 一样,是15。进程被创建时,其时间片被设置为优先级的数值,也是15。这里对创建进程时的代码进行修改,让样本程序的子进程优先级和时间片依次递减,代码如下:
int copy_process(...)
{
...
//父进程的优先级和时间片仍为15,四个子进程的优先级依次为14、13、12、11
if(p->pid > 7) {
int i = p->pid - current->pid;
p->priority = current->priority - i;
}
//子进程的时间片依次为13、12、11、10
p->counter = p->priority - 1;
...
}
在修改后的 Linux-0.11 再次运行样本程序,进行数据统计,结果如下:
可见拥有在较高的优先级和时间片的父进程(pid==7)在就绪队列种的等待时间更短了,而需要占用 CPU 的子进程 8、10、11 在就绪队列种的等待时间都变高了。总的来说,在就绪队列的等待时间变长了,吞吐率略有降低。
Linux 中的进程有 5 种状态,在 include/linux/sched.h
定义,分别是:
#define TASK_RUNNING 0 //进程正在运行或已准备就绪。
#define TASK_INTERRUPTIBLE 1 //进程处于可中断等待状态。
#define TASK_UNINTERRUPTIBLE 2 //进程处于不可中断等待状态,主要用于I/0操作等待。
#define TASK_ZOMBIE 3 //进程处于僵死状态,已经停止运行,但父进程还没发信号。
#define TASK_STOPPED 4 //进程已停止。
进程的状态变化如下图所示(进程的状态切换都是在内核态完成的,当发生系统调用或中断时,CPU 进入内核态,内核的进程调度程序会处理进程的状态改变):
各个函数的作用在上述实验过程均已提到。
Linux-0.11 定义了一个全局变量 long volatile jiffies
,为从开机开始算起的滴答数时间值(10ms/滴答)。系统时钟中断每 10ms 产生一次,每次就会使 jiffies 加一。时钟中断的处理函数do_timer()
(在 kernel/sched.c
中) 会增加内核/用户代码运行时间的值和修改定时器的值,然后将当前进程的时间片减一。时钟中断结合上面的进程调度函数就能实现对进程运行和切换的控制。
当用户调用 fork() 系统调用的 api 时,处理器通过 int 0x80
中断进入 sys_fork
处理函数执行,它先获取一个空的进程号,然后调用 kernel/fork.c
中的 copy_process()
函数创建并复制子进程的代码段、数据段和环境等,将子进程的状态设为就绪态,这样新的进程就创建完毕。
在上述过程中,copy_process()
函数的返回值为子进程的 pid,于是子进程的 pid 被放入 eax 寄存器;回到 sys_fork
中,然后通过 ret
返回,此时 eax 寄存器的值仍为子进程 pid;再回到 system_call
中做一些信号处理工作后执行 iret
返回到原先的用户态也就是父进程中继续执行。返回到用户态时 eax 寄存器保存的值也就是 fork()
api 的返回值就为子进程的 pid。所以对于父进程而言,fork()
函数的返回值是子进程的 pid。
对于子进程而言,copy_process()
函数为子进程设置任务状态段时做出了修改 p->tss.eax = 0
。在切换到子进程执行时,TSS 段中的 eax 的值被赋予 eax 寄存器,而 eax 寄存器的值是函数的返回值,所以 fork()
函数在子进程中的返回值为 0。
copy_process()
函数在设置子进程的 TSS 段时 cs:eip
直接复制父进程的值,也就是父进程在进入中断处理函数前自动压栈的值,该值指向父进程返回到用户态后的下一个指令的地址。父进程在函数调用返回后继续到下一条指令执行,如果父进程的代码为 p1 = fork()
,那么父进程接下来会把 eax 寄存器的值(子进程的 pid)赋给 p1;而子进程在被创建后如果获取到了 CPU 的使用权,也接着在同样一条指令处执行,也会把 eax 寄存器的值(==0)赋给 p1。这就造成了 fork()
函数一次调用返回两次的错觉。
完成实验后,在实验报告中回答如下问题:
结合自己的体会,谈谈从程序设计者的角度看,单进程编程和多进程编程最大的区别是什么?你是如何修改时间片的?仅针对样本程序建立的进程,在修改时间片前后,log 文件的统计结果(不包括 Graphic)都是什么样?结合你的修改分析一下为什么会这样变化,或者为什么没变化?
从程序设计的角度看,如果程序业务逻辑复杂,多进程编程会使每个进程逻辑更简单,任务隔离进行也能提高稳定性,一个进程崩溃不会干扰其他进程失效。
win10 的资源管理器就是一个很好的例子,在 1903 版本之前,多个资源管理器窗口是同一个任务,一旦某个窗口卡死所有窗口都失效,甚至任务栏都消失;而 1903 版本更新后,资源管理器也支持多进程,稳定性提高。我修改时间片的方式是:创建子进程时,将子进程的优先级依次递减,且子进程的时间片设为优先级减一。这样就使父进程和各个子进程的时间片和优先级都拉开了差距。于是子进程的就绪状态持续时间更长了,因为优先级和时间片都不如其他进程了。