该篇文章是哈工大操作系统实验4的完成笔记,不得不说多进程编程确实比较难,从理解实验内容到整理出这篇文章差不多用了2周时间。其中包含了详细的步骤和相关代码,并有截图说明,后面日志统计分析还做了图表。实验内容我都成功通过了,但是因为内容较多,记录中难免会有疏忽,如有发现错误,欢迎大家留言和我联系。
文章较长,可以先收藏后观看,如果对大家有帮助,麻烦动动你可爱的小手帮忙点个赞哈。
理论知识
理论结合实际,在实践前有必要阅读一下理论相关的内容,根据理论去实践,再从实践反过来看原理,很多东西就通畅了。
实验的内容和进程相关,因此有必要阅读进程相关的内容,Linux0.11的进程管理推荐查看《Linux内核完全注释》第2.5章节——Linux进程控制。
因为这章的实验内容是进程运行轨迹的跟踪与统计,那么我觉的下面这张进程的状态及切换关系图就非常重要了,可以作为主要思路。
我们只要知道了进程什么创建了、什么时候运行了、什么睡眠了、什么时候僵死了等这些状态的变换,然后在相关的状态切换点记录日志,就可以知道进程运行的轨迹了。
根据这个思路,我另外绘制了一张图,比较清晰的罗列了每个状态变化的相关代码文件。
1) 创建进程0
进程0可以说是万物之始,所有的进程都来源于这个进程,进程0的创建和运行位于 init/main.c 中 main() 函数中。
- sched_init() 用来创建进程0;
- move_to_user_mode() 将进程0移动到用户态下执行。
2)新建进程
进程0创建后,紧接着(move_to_user_mode之后)就fork出第一个进程1,fork出的进程一开始都会被设置为就绪态(TASK_RUNNING)。而后进程1又会进行一些初始化操作(init),如初始化文件句柄,shell等。
fork的相关流程:
fork()
-> kernel/system_call.s中的_system_call函数
-> kernel/system_call.s中的_sys_fork函数
-> kernel/fork.c的copy_process函数。 // 设置就绪状态就在这个copy_process函数里。
而进程0创建完进程1后,自身就一直循环执行pause方法(对应kernel/sched.c中的sys_pause系统调用),使自己一直保持在可中断睡眠状态,并调度其他进程执行,从这里可以看出进程0是状态无关的。
3)就绪态<->运行态
进程新建后,处于就绪态(TASK_RUNNING),当系统执行调度函数(对应kernel/sched.c中的schedule函数)时,如果符合条件就会进入到运行态(TASK_RUNNING),就绪态和运行态虽然值是一样的,但是含义上是不一样的。
同时运行态的进程,如果时间片用完后,就会切换到其他还有时间片的进程,自身就转为就绪态。
后续实践部分记录了两条日志,就是这个原理。
4)运行态->睡眠,睡眠->就绪态
睡眠有两种状态:可中断睡眠和不可中断睡眠,对应的切换方法分别为:kernel/sched.c中的interruptible_sleep_on()和sleep_on()。
实验中有详细说明了两种状态的区别,我自己总结一句话表达就是:两种都是链式结构,不可中断睡眠一定是从头唤醒,可中断睡眠状态可以从链中间唤醒,所以处理方法不太一样。
另外可中断睡眠还有两个地方有进行切换,分别是:kernel/sched.c中sys_pause函数和kernel/exit.c中sys_waitpid函数。
- sys_pause函数前面提到过就是进程0一直在循环调用;
- sys_waitpid函数就是父进程可以调用这个函数等待子进程结束,如果子进程没有全部结束就会把自己置为可中断睡眠状态,继续等待。后续编写的process.c文件,就会调用到这个方法。
唤醒方式:不可中断睡眠状态只能通过wake_up函数(kernel/sched.c)进行唤醒;可中断睡眠状态除了通过wake_up函数唤醒外,如果收到信号也可以被唤醒,在kernel/sched.c中的schedule函数的第一部分就是进行这个处理的。
5)运行态->僵死
进程运行完成要退出,或者主动退出可以调用exit()或者_exit()进行退出,但最终都会执行系统调用sys_exit函数(kernel/exit.c),在这个系统调用里进程会被设置为僵死(TASK_ZOMBIE)状态,然后等待父进程进行回收占用的资源,这个也正是僵死的含义,就是进程要退出了,但是占用资源还没有完全被回收的意思哈。
6)其他
暂停状态Linux0.11没有实现,可以不用管。
运行态有用户运行态和内核运行态两种,通过系统调用、中断和中断返回进行切换,这个也可以不管,因为都是同一个进程在运行,进程本身的状态没有变化。
实践操作
理论有了一些了解后,就可以进行实践了,在实践中如果碰到不懂的地方,可以再返回去看理论,这样就容易弄明白了。
编写样本程序process.c
实验课程提供的process.c文件,提供了一个函数 cpuio_bound,可以模拟进程对CPU的占用和IO的占用。我们要做的就是在这个文件基础上,fork出若干进程,每个进程调用 cpuio_bound 函数,实现不同的CPU和IO时间占用。最后通过进程的运行日志,观察进程的运行轨迹。
按实验课程的说明,我们可以先在宿主机上(Ubuntu)上调试好,再放到Linux0.11上跑一下。这可太好了,毕竟在Linux0.11上调试程序实在太不方便了。
1) 拷出 proccess.c
先把 process.c 拷贝到实验目录下,因为 /home/teacher/process.c 是无法直接编辑的。
# 当前的工作路径为 /home/shiyanlou/oslab/
# 将process.c文件复制到当前目录
$ cp /home/teacher/process.c ./
2) 编写process.c
这里创建出4个子进程:
- 第一个进程:占用10秒的CPU时间;
- 第二个进程:占用10秒的IO时间;
- 第三个进程:CPU和IO各占用一半时间;
- 第四个进程:IO时间是CPU的9倍。
创建进程通过fork函数实现,fork函数本质通过是系统调用sys_fork实现,在理论部分有说明对应的内核代码。
创建出4个子进程后,父进程会调用四次wait函数等待4个子进程结束运行,wait函数本质是通过系统调用sys_waitpid实现,在理论部分有说明对应的内核代码。
4个子进程运行结束后,父进程也就跟着运行结束了。
相关代码如下:
#define __LIBRARY__ /* 定义一个符号常量,见下行说明。unistd.h文件中会用到。 */
#include <unistd.h> /* Linux 标准头文件。定义了各种符号常数和类型,并申明了各种函数。如果定义了__LIBRARY__,则还含系统调用号和内嵌汇编 syscall0()等。 */
#include <stdio.h> /* C语言标准输入输出函数库,主要是使用其中 printf 函数 */
#include <time.h> /* 时间类型头文件。其中最主要定义了 tm 结构和一些有关时间的函数原形。*/
#include <sys/times.h> /* 定义了进程中运行时间的结构 tms 以及 times()函数原型。*/
#include <sys/wait.h> /* 等待调用头文件。定义系统调用 wait()和 waitpid()及相关常数符号。 */
#define HZ 100 /* 定义系统时钟滴答频率(1百赫兹,每个滴答10ms)。*/
/* 声明 cpuio_bound 函数的原型 */
void cpuio_bound(int last, int cpu_time, int io_time);
int main(int argc, char *argv[]) /* argc表示参数个数、argv参数数组。如执行:./process 1,那么 argc=2, argv[0]=./process、argv[1]=1。这里没有使用这两个参数,所以起始可以省略。*/
{
pid_t pid1, pid2, pid3, pid4; /* 声明4个pid_t变量,用来接收fork出的4个子进程。 */
/* 这里开始fork出第1个子进程。*/
/* 如果fork失败,会返回负数。如果fork成功,从这里开始分成父进程和子进程分别执行(fork分叉的含义),对于父进程返回的是子进程的pid(>0),对于子进程则返回0。*/
pid1 = fork();
if (pid1 < 0) /* 返回如果为负数,表示出现了错误。 */
printf("error in fork!, errno=%d\n", pid1);
else if (pid1 == 0) /* 返回如果为0,表示是子进程。 */
{
/*这里的代码都是子进程执行的。 */
printf("Child process 1 is running.\n");
cpuio_bound(10, 1, 0); /* 进程占用10秒的CPU时间 */
printf("Child process 1 is end.\n");
return 0;
}
/* 父进程继续执行后面的代码 */
/* 这里开始fork出第2个子进程。原理同第1个类似。*/
pid2 = fork();
if (pid2 < 0) /* 返回如果为负数,表示出现了错误。 */
printf("error in fork!, errno=%d\n", pid2);
else if (pid2 == 0) /* 返回如果为0,表示是子进程。 */
{
/*这里的代码都是子进程执行的。 */
printf("Child process 2 is running.\n");
cpuio_bound(10, 0, 1); /* 进程以IO为主要任务 */
printf("Child process 2 is end.\n");
return 0;
}
/* 这里开始fork出第3个子进程。原理同第1个类似。*/
pid3 = fork();
if (pid3 < 0) /* 返回如果为负数,表示出现了错误。 */
printf("error in fork!, errno=%d\n", pid3);
else if (pid3 == 0) /* 返回如果为0,表示是子进程。 */
{
/*这里的代码都是子进程执行的。 */
printf("Child process 3 is running.\n");
cpuio_bound(10, 1, 1); /* CPU和IO各1秒钟轮回 */
printf("Child process 3 is end.\n");
return 0;
}
/* 这里开始fork出第4个子进程。原理同第1个类似。*/
pid4 = fork();
if (pid4 < 0) /* 返回如果为负数,表示出现了错误。 */
printf("error in fork!, errno=%d\n", pid4);
else if (pid4 == 0) /* 返回如果为0,表示是子进程。 */
{
/*这里的代码都是子进程执行的。 */
printf("Child process 4 is running.\n");
cpuio_bound(10, 1, 9); /* IO时间是CPU的9倍 */
printf("Child process 4 is end.\n");
return 0;
}
printf("This process's pid is %d\n", getpid());
printf("Pid of child process 1 is %d\n", pid1);
printf("Pid of child process 2 is %d\n", pid2);
printf("Pid of child process 3 is %d\n", pid3);
printf("Pid of child process 4 is %d\n", pid4);
/* 4个wait调用,等待4个子进程执行结束。*/
wait(NULL);
wait(NULL);
wait(NULL);
wait(NULL);
printf("end.\n");
return 0;
}
/*
* 此函数按照参数占用CPU和I/O时间
* last: 函数实际占用CPU和I/O的总时间,不含在就绪队列中的时间,>=0是必须的
* cpu_time: 一次连续占用CPU的时间,>=0是必须的
* io_time: 一次I/O消耗的时间,>=0是必须的
* 如果last > cpu_time + io_time,则往复多次占用CPU和I/O
* 所有时间的单位为秒
*/
void cpuio_bound(int last, int cpu_time, int io_time)
{
struct tms start_time, current_time;
clock_t utime, stime;
int sleep_time;
while (last > 0)
{
/* CPU Burst */
times(&start_time);
/* 其实只有t.tms_utime才是真正的CPU时间。但我们是在模拟一个
* 只在用户状态运行的CPU大户,就像“for(;;);”。所以把t.tms_stime
* 加上很合理。*/
do
{
times(¤t_time);
utime = current_time.tms_utime - start_time.tms_utime;
stime = current_time.tms_stime - start_time.tms_stime;
} while (((utime + stime) / HZ) < cpu_time);
last -= cpu_time;
if (last <= 0)
break;
/* IO Burst */
/* 用sleep(1)模拟1秒钟的I/O操作 */
sleep_time = 0;
while (sleep_time < io_time)
{
sleep(1);
sleep_time++;
}
last -= sleep_time;
}
}
tips:如果感觉 Ubuntu 上还是不太方便,可以本地电脑上编辑好这个文件,通过蓝桥上传代码上传到 Ubuntu 中。
3) 编译process.c
# 当前的工作路径为 /home/shiyanlou/oslab/
# 编译 process.c
$ gcc -o process process.c -Wall
如果有错误,根据错误排查原因,没有任何提示的话,就编译成功了。
4) 运行
# 当前的工作路径为 /home/shiyanlou/oslab/
# 运行
$ ./process
运行效果如何和下图类似,表明这个文件就调试成功了。
5) 查看运行情况
当前命令行窗口运行 ./process 进程时,命令行会卡住,可以打开另外一个命令行窗口通过 top 或 ps 命令查看。
- top 命令可以监视即时的进程状态。在 top 中,按 u,再输入你的用户名,可以限定只显示以你的身份运行的进程;
- ps 命令可以显示当时各个进程的状态。ps -ef 会显示所有进程;ps -ef | grep xxxx 将只显示名为 xxxx 的进程。
下图是用ps命令查看进程的截图:
实验课程内容说用 ps aux 也是可以的,只是看不到进程的父进程编号,ps -ef 可以看到父进程的编号,更加容易分清。
6) Linux0.11运行这个程序
Ubuntu上调试好了就可以在Linux0.11试试看能否正常运行,虽然Ubuntu是Linux的发行版,但毕竟Linux0.11太过久远了,有些地方不兼容也说不定。
老样子,解压Linux0.11源码,挂载虚拟盘、process.c复制到./hdc/usr/root/目录、编译运行Linux0.11,然后在Linux0.11中编译process.c,运行试试看。
前面的步骤之前实验已经做过很多次了,这里一次性罗列相关命令在这里供参考。
# 进入到 oslab 所在的文件夹
$ cd /home/shiyanlou/oslab/
# 解压,并指定解压到 /home/shiyanlou/
# 这样的话,在 /home/shiyanlou/oslab/ 中就能找到解压后的所有文件
$ tar -zxvf hit-oslab-linux-20110823.tar.gz -C /home/shiyanlou/
# 启动挂载脚本
$ sudo ./mount-hdc
# process.c 复制到 ./hdc/usr/root/ 目录
$ cp ./process.c ./hdc/usr/root/
# 进入到Linux-0.11
$ cd linux-0.11/
# 编译
$ make all
# 运行
$ ../run
在Linux0.11中编译运行process.c:
# 当前的工作路径为Linux0.11中的 /usr/root/ 目录。
# 编译 process.c
$ gcc -o process process.c -Wall
# 运行
$ ./process
运行截图:
成功运行,Linux的兼容性还是很好的。
实现日志记录
process.c完成后,下面就要在相关的进程状态变更点记录日志了。
创建文件句柄
写文件要先创建文件句柄,源码有创建终端控制台文件句柄0、1、2,在进程1创建之后的init函数(init/main.c)内进行创建的。因为要记录进程1的情况,所以我们可以在进程0运行起来之后、创建进程1之前处理,即 init/main.c 中 move_to_user_mode() 之后,fork() 之前。
因为文件句柄的编号是顺序增加的,所以在创建 process.log 的文件句柄之前,需要先创建出终端控制台文件句柄0、1、2。
相关代码如下:
/* 打开process.log文件,init/main.c文件 */
void main(void)
{
...
move_to_user_mode(); // 移到用户模式下执行。(include/asm/system.h,第 1 行) // 下面过程通过在堆栈中设置的参数,利用中断返回指令启动任务 0 执行。
/***************添加开始***************/
// 下面4行是从 init() 函数中复制过来的,只是为了占用0、1、2这3个文件描述符,确保打开process.log文件描述符是4。
// 0、1、2这3个文件描述符是终端控制台用的。
setup((void *) &drive_info); // 这是一个系统调用。用于读取硬盘参数包括分区表信息并加载虚拟盘(若存在的话)和安装根文件系统设备。该函数是用 25 行上的宏定义的,对应函数是 sys_setup(),在 kernel/blk_drv/hd.c,71
(void) open("/dev/tty0",O_RDWR,0);// 以读写访问方式打开设备“/dev/tty0”,它对应终端控制台。于这是第一次打开文件操作,因此产生的文件句柄号(文件描述符)肯定是 0。该句柄是 UNIX 类操作系统默认的控制台标准输入句柄 stdin。这里把它以读和写的方式打开是为了复制产生标准输出(写)句柄 stdout 和标准出错输出句柄 stderr。
(void) dup(0);// 复制句柄,产生句柄 1 号 -- stdout 标准输出设备。
(void) dup(0);// 复制句柄,产生句柄 2 号 -- stderr 标准出错输出设备。
/*
打开/var/process.log,文件描述符为3。
O_CREAT:表示如果文件不存在就创建。
O_TRUNC:若文件已存在且是写操作,则长度截为 0,即从头开始写。
O_WRONLY:以只写方式打开文件。
0666:文件的权限是所有人可读可写。
*/
(void) open("/var/process.log", O_CREAT|O_TRUNC|O_WRONLY, 0666);
/***************添加结束***************/
if (!fork()) { /* we count on this going ok */ // 使用fork调用创建进程1, 返回0表示新进程,大于0则是父进程。
init(); // 在新建的子进程(任务 1)中执行。
}
/* // 下面代码开始以任务 0 的身份运行。
* NOTE!! For any other task 'pause()' would mean we have to get a // 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到一个信号才会返回就绪运行态,
* signal to awaken, but task0 is the sole exception (see 'schedule()') // 但任务 0(task0)是唯一的例外情况(参见'schedule()'),因为任务 0 在任何空闲时间里都会被激活(当没有其它任务在运行时),
* as task 0 gets activated at every idle moment (when no other tasks // 因此对于任务 0'pause()'仅意味着我们返回来查看是否有其它任务可以运行,如果没有的话我们就回到这里,一直循环执行'pause()'。
* can run). For task0 'pause()' just means we go check if some other
* task can run, and if not we return here.
*/
for(;;) pause(); // pause()系统调用(kernel/sched.c,144)会把任务 0 转换成可中断等待状态,再执行调度函数。但是调度函数只要发现系统中没有其它任务可以运行时就会切换到任务 0,而不依赖于任务 0 的状态。
}
从理论上来说,在进程0打开终端0、1、2三个描述符后,后续fork的进程都会继承这3个描述符。进程1的初始化init()函数内就没有必要再重复打开了,可以去掉。我自己没有尝试过,有兴趣可自行一试,有试验结果也可以分享哈。
增加fprintk函数
之所以要另外编写一个fprintk函数,是因为内核不能直接调用用户态的write函数,所以只能自己编写一个提供给内核态写文件的函数。
实验内容提供了这个函数,直接使用就可以了。函数的原理也不难理解:就是判断文件句柄(fd)如果是0、1、2则直接调用sys_write,否则就调用file_write。
/* 写log函数, 放到kernel/printk.c文件 */
#include "linux/sched.h"
#include "sys/stat.h"
static char logbuf[1024];
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;
}
在状态切换点记录日志
状态切换点记录日志根据前面的理论部分按步记录即可。
1) 创建进程0
进程0的创建比较特殊,在此之前无法打开文件句柄,所以这里可以不用记录。
2)新建进程
新建进程在理论部分有提过了,最后是落在 kernel/fork.c的copy_process函数。
当进程设置完进程id(p->id)之后,更新进程状态(p->state)为就绪态之前,可以记录一条进程新创建的日志,中间具体哪个位置关系不大,都是给进程的其他属性赋值。
进程各种属性设置完后,进程状态(p->state)就可以更新为就绪态了,之后记录一条日志表明进程已经就绪了。
/* 寻找状态点 —— 创建新进程并设置为就绪 */
int copy_process(int nr,long ebp,long edi,long esi,long gs,long none, // nr是调用find_empty_process()分配的任务数组项号。none是system_call.s中调用sys_call_table时压入堆栈的返回地址。
long ebx,long ecx,long edx,
long fs,long es,long ds,
long eip,long cs,long eflags,long esp,long ss) // 因为该函数是提供给用户程序的系统调用,特权级从3变成0,需要从用户态堆栈切换到内核态堆栈,所以需要在堆栈中压入用户态堆栈的ss、esp,还有eflags
{ // cs和eip不管有没切换栈都是要压入栈的,系统调用返回后,会根据这两个返回到中断的位置继续执行。
...
p->cutime = p->cstime = 0; // 初始化子进程用户态和核心态时间。
p->start_time = jiffies; // 当前滴答数时间,初始为0。
/***************添加开始***************/
// 往process.log写一条日志,N表示进程新创建
fprintk(3, "%ld\t%c\t%ld\n", p->pid, 'N', jiffies);
/***************添加结束***************/
p->tss.back_link = 0; // 以下设置任务状态段 TSS 所需的数据(参见列表后说明)。
p->tss.esp0 = PAGE_SIZE + (long) p; // 内核态堆栈指针,指向任务数据结构所在页的顶部。 //由于是给任务结构 p 分配了 1 页新内存,所以此时 esp0 正好指向该页顶端。ss0:esp0 用于作为程序在内核态执行时的堆栈。 // 这个是指用户任务内核态堆栈指针,用户任务运行在内核态时特权级为0,这个就是esp0、ss0中0的含义。
...
set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss)); // 在 GDT 中设置新任务的 TSS 和 LDT 描述符项,数据从 task 结构中取。
set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt)); // 任务切换时,任务寄存器 tr 由 CPU 自动加载。
p->state = TASK_RUNNING; /* do this last, just in case */ // 最后再将新任务设置成可运行状态,以防万一。
/***************添加开始***************/
// 往process.log写一条日志,J表示进程就绪。
fprintk(3, "%ld\t%c\t%ld\n", p->pid, 'J', jiffies);
/***************添加结束***************/
return last_pid; // 返回新进程号(与任务号是不同的)。
}
3) 就绪态<->运行态
就绪状态和运行状态之间切换在kernel/sched.c文件schedule函数的后半部分——while(1)开始的地方。
需要注意的地方是调度程序可能找到的进程还是自身,相当于没有切换,所以记录日志前要判断一下。然后进程从运行态到就绪态也需要记录日志,需要增加状态判断。代码注释很详细了,就不再赘述。
另外schedule函数的前半部分是判断进程是否接收到信号,如果有符合的信号,那么进程会从可睡眠状态切换到就绪态,因为是在一个函数里,所以可以在这步就直接修改了。
/* 寻找状态点 —— 就绪状态和运行状态之间切换 */
void schedule(void)
{
int i,next,c;
struct task_struct ** p; // 任务结构指针的指针。
/* check alarm, wake up any interruptible tasks that have got a signal */ // 检测 alarm(进程的报警定时值),唤醒任何已得到信号的可中断任务
// 从任务数组中最后一个任务开始检测 alarm。
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
if (*p) {
if ((*p)->alarm && (*p)->alarm < jiffies) { // 如果设置过任务的定时值 alarm,并且已经过期(alarm<jiffies),则在信号位图中置 SIGALRM 信号,
(*p)->signal |= (1<<(SIGALRM-1)); // 即向任务发送 SIGALARM 信号。然后清 alarm。该信号的默认操作是终止进程
(*p)->alarm = 0; // jiffies 是系统从开机开始算起的滴答数(10ms/滴答)。定义在 sched.h 第 139 行
}
if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) && // 如果信号位图中除被阻塞的信号外还有其它信号,并且任务处于可中断状态,则置任务为就绪状态。
(*p)->state==TASK_INTERRUPTIBLE) { // _BLOCKABLE=...10111111111011111111b、(*p)->blocked=...1111b,_BLOCKABLE&(*p)->blocked 表示屏蔽的信号,取反表示未被屏蔽的信号。
(*p)->state=TASK_RUNNING; // (*p)->signal & 上面一步 则表示未被屏蔽的信号位中有信号。
/***************添加开始***************/
// 可中断睡眠 -> 就绪
// 记录一条日志,表明当前进程切换到就绪态了。J表示就绪的意思。
fprintk(3, "%ld\t%c\t%ld\n", (*p)->pid, 'J', jiffies);
/***************添加结束***************/
}
}
/* this is the scheduler proper: */
// 这里是调度程序的主要部分
while (1) {
c = -1;
next = 0;
i = NR_TASKS;
p = &task[NR_TASKS];
while (--i) { // 这段代码也是从任务数组的最后一个任务开始循环处理,并跳过不含任务的数组槽。比较每个就绪态任务的counter(任务运行时间的递减滴答计数)值,哪一个值大,运行时间还不长,next就向哪个的任务号。
if (!*--p) // 跳过不含任务的数组槽
continue;
if ((*p)->state == TASK_RUNNING && (*p)->counter > c) // 比较每个就绪态任务的counter(任务运行时间的递减嘀嗒计数)值,哪一个值大,next就指向哪个的任务号。
c = (*p)->counter, next = i;
}
if (c) break; // 如果比较得出有 counter 值大于 0 的结果,则退出 124 行开始的循环,执行任务切换(141 行)。
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p) // 否则就根据每个任务的优先权值,更新每一个任务的 counter 值,然后回到 125 行重新比较。
if (*p)
(*p)->counter = ((*p)->counter >> 1) + // counter 值的计算方式为 counter = counter / 2 + priority。这里计算过程不考虑进程的状态。
(*p)->priority;
}
/***************添加开始***************/
// 如果当前进程和要切换的进程一样,相当于没有切换。在 switch_to 函数中一开始就进行这个验证。
// 只有当前运行进程不等于要切换的进程 才需要进行记录。
if (current->pid != task[next]->pid) {
// 如果当前运行程序的状态为TASK_RUNNING,表明当前进程的时间片用完了,所以要切换到其它进程。
// 时间中断程序检测到进程运行时间片<=0,则会调用schedule(),此时当前进程就会从运行态转变到就绪态。
// 可以参考:_timer_interrupt( kernel/system_call.s中定义)-> do_timer()(kernel/sched.c)。
// 运行态和就绪态虽然值都是TASK_RUNNING,但是含义上是不一样的。
// 另外进程调用sleep_on等其他变化状态的函数,也会调用schedule()进程,此时不应再记录就绪状态的日志,所以也需要加状态判断。
if (current->state == TASK_RUNNING) {
// 记录一条日志,表明当前进程切换到就绪态了。J表示就绪的意思。
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'J', jiffies);
}
// 要切换的进程从就绪态(TASK_RUNNING)切换到运行态(TASK_RUNNING)。
// 记录一条日志,表明要切换的进程即将运行了。R表示运行的意思。
fprintk(3, "%ld\t%c\t%ld\n", task[next]->pid, 'R', jiffies);
}
/***************添加结束***************/
switch_to(next); // 切换到任务号为next的任务运行。在126行next被初始化为0。因此若系统中没有任何其它任务可运行时,则next始终为0。
}
4) 运行态->睡眠,睡眠->就绪态
4.1) 运行态->不可中断睡眠状态:kernel/sched.c文件sleep_on函数。这里不难理解,在当前进程状态设置为不可中断睡眠状态后,记录一条日志即可。另外当进程被唤醒继续执行后,会依次唤醒前面等待的进程,此时也需要记录一条日志。
/* 寻找状态点 —— 运行状态到不可中断睡眠 */
// 把当前任务置为不可中断的等待状态,并让睡眠队列头的指针指向当前任务。只有明确地唤醒时才会返回。该函数提供了进程与中断处理程序之间的同步机制。
void sleep_on(struct task_struct **p) // 函数参数**p是放置等待任务的队列头指针
{
struct task_struct *tmp;
if (!p) // 若指针无效,则退出。(指针所指的对象可以是null,但指针本身不会为0)。
return;
if (current == &(init_task.task)) // 如果当前任务是0,则死机(impossible!)。
panic("task[0] trying to sleep");
tmp = *p; // 让tmp指向已经在等待队列上的任务(如果有的话)
*p = current; // 将睡眠队列头指针指向当前任务。
current->state = TASK_UNINTERRUPTIBLE; // 将当前任务指向不可中断的等待状态。
/***************添加开始***************/
// 记录一条日志,表明当前进程切换到不可中断睡眠状态了。W(Wait)表示等待的意思。
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'W', jiffies);
/***************添加结束***************/
schedule(); // 重新调度。重新调度后,如果去执行其他进程了,则当前进程暂停点就在这里,恢复运行时就从下一行开始。
if (tmp) { // 若在其前还存在等待的任务,则将其置为就绪状态(唤醒)。 就绪状态就是:TASK_RUNNING // 只有当这个等待任务被唤醒时,调度程序才又返回到这里,则表示进程已被明确地唤醒。
tmp->state=0; // 既然大家都在等待同样的资源,那么在资源可用时,就有必要唤醒所有等待该资源的进程。
/***************添加开始***************/
// 记录一条日志,表明当前进程切换到就绪态了。J表示就绪的意思。
fprintk(3, "%ld\t%c\t%ld\n", tmp->pid, 'J', jiffies);
/***************添加结束***************/
}
}
4.2) 运行态->可中断睡眠状态:这里有3个地方。
- kernel/sched.c中interruptible_sleep_on函数
- kernel/sched.c中sys_pause函数
- kernel/exit.c中sys_waitpid函数
4.2.1) kernel/sched.c文件interruptible_sleep_on函数:同sleep_on函数类似。
/* 寻找状态点 —— 运行状态到可中断睡眠 */
// 将当前任务置为可中断的等待状态,并放入*p指定的等待队列中。
void interruptible_sleep_on(struct task_struct **p)
{
struct task_struct *tmp;
if (!p)
return;
if (current == &(init_task.task))
panic("task[0] trying to sleep");
tmp=*p;
*p=current;
repeat: current->state = TASK_INTERRUPTIBLE;
/***************添加开始***************/
// 记录一条日志,表明当前进程切换到可中断睡眠状态了。W(Wait)表示等待的意思。
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'W', jiffies);
/***************添加结束***************/
schedule();
if (*p && *p != current) { // 如果等待队列中还有等待任务,并且队列头指针所指向的任务不是当前任务时,则将该等待任务置为运行的就绪状态,并重新执行调度程序。
(**p).state=0; // 当指针*p所指向的不是当前任务时,表示在当前任务被放入队列后,又有新的任务被插入等待队列中,因此,就应该同时也将所有其它的等待任务置为可运行状态。
/***************添加开始***************/
// 记录一条日志,表明当前进程切换到就绪态了。J表示就绪的意思。
fprintk(3, "%ld\t%c\t%ld\n", (**p).pid, 'J', jiffies);
/***************添加结束***************/
goto repeat;
}
*p=NULL;
if (tmp) {
tmp->state=0;
/***************添加开始***************/
// 记录一条日志,表明当前进程切换到就绪态了。J表示就绪的意思。
fprintk(3, "%ld\t%c\t%ld\n", tmp->pid, 'J', jiffies);
/***************添加结束***************/
}
}
4.2.2) kernel/sched.c中sys_pause函数:进程主动睡觉的系统调用。
/* 寻找状态点 —— 进程主动睡觉的系统调用 */
// pause()系统调用。转换当前任务的状态为可中断的等待状态,并重新调度。
int sys_pause(void) // 该系统调用将导致进程进入睡眠状态,直到收到一个信号。该信号用于终止进程或者使进程调用一个信号捕获函数。
{ // 只有当捕获了一个信号,并且信号捕获处理函数返回,pause()才会返回。
// 此时 pause()返回值应该是-1,并且 errno 被置为 EINTR。这里还没有完全实现(直到 0.95 版)。
/***************添加开始***************/
//
if (current->state != TASK_INTERRUPTIBLE) {
// 记录一条日志,表明当前进程切换到可中断睡眠状态了。W(Wait)表示等待的意思。
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'W', jiffies);
}
/***************添加结束***************/
current->state = TASK_INTERRUPTIBLE;
schedule();
return 0;
}
这里之所以加一个if判断,是因为在没有其它进程运行时,进程0不管状态是啥,会一直调用这个sys_pause()。如果不加if判断,就会记录大量如下日志:
一开始我没有加if判断的原因就是为了观察到进程的运行情况,通过这个日志确实可以观察到0进程一直在调用sys_pause(),但是后面进行日志统计的时候会报错误,错误信息:Error at line 33:It is a clone of previous line,为了后续日志统计,所以这里就加上if判断了。有兴趣可以自行移除if判断再观察一下。
4.2.3) kernel/exit.c中sys_waitpid函数:进程等子进程结束的系统调用。
// 系统调用 waitpid()。挂起当前进程,直到 pid 指定的子进程退出(终止)或者收到要求终止进程的信号,或者是需要调用一个信号句柄(信号处理程序)。
int sys_waitpid(pid_t pid,unsigned long * stat_addr, int options)
{
...
if (flag) { // 在上面对任务数组扫描结束后,如果 flag 被置位,说明有符合等待要求的子进程并没有处于退出或僵死状态。
if (options & WNOHANG) // 若 options = WNOHANG,则立刻返回。 // 如果此时已设置 WNOHANG 选项(表示若没有子进程处于退出或终止态就立刻返回),立刻返回 0,退出。
return 0; // 否则把当前进程置为可中断等待状态并重新执行调度。
current->state=TASK_INTERRUPTIBLE; // 置当前进程为可中断等待状态。
/***************添加开始***************/
// 记录一条日志,表明当前进程切换到可中断睡眠状态了。W(Wait)表示等待的意思。
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'W', jiffies);
/***************添加结束***************/
schedule(); // 重新调度,此时可能跳转到其他进程去执行了。
if (!(current->signal &= ~(1<<(SIGCHLD-1)))) // 当又开始执行本进程时,如果本进程没有收到除 SIGCHLD 以外的信号,则还是重复处理。否则,返回出错码并退出。
goto repeat;
else
return -EINTR; // 退出,返回出错码。
}
return -ECHILD; // 若没有找到符合要求的子进程,则返回出错码。
}
4.3) 睡眠状态到就绪态:两种睡眠状态都可以通过 kernel/sched.c中wake_up函数 唤醒。
/* 寻找状态点 —— 睡眠到就绪 */
// 唤醒指定任务*p。
void wake_up(struct task_struct **p)
{
if (p && *p) {
(**p).state=0; // 置为就绪(可运行)状态。
/***************添加开始***************/
// 记录一条日志,表明当前进程切换到就绪态了。J表示就绪的意思。
fprintk(3, "%ld\t%c\t%ld\n", (**p).pid, 'J', jiffies);
/***************添加结束***************/
*p=NULL;
}
}
可中断睡眠状态切换为就绪还有一个地方在kernel/sched.c中schedule函数前一部分,前面已经改过,这里就不用再改了。
5)运行态->僵死:kernel/exit.c文件sys_exit函数->do_exit函数。
/* 寻找状态点 —— 退出 */
// 程序退出处理程序。在下面 137 行处的系统调用 sys_exit()中被调用。
int do_exit(long code) // code 是错误码。
{
...
if (current->leader) // 如果当前进程是 leader 进程,则终止该会话的所有相关进程。
kill_session();
current->state = TASK_ZOMBIE; // 把当前进程置为僵死状态,表明当前进程已经释放了资源。并保存将由父进程读取的退出码。
/***************添加开始***************/
// 记录一条日志,表明当前进程切换到退出状态了。E(Exit)表示退出的意思。
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'E', jiffies);
/***************添加结束***************/
current->exit_code = code;
...
}
6)其他
其他情况无需记录。
编译内核
# 当前的工作路径为 /home/shiyanlou/oslab/ 目录。
# 进入到Linux-0.11
$ cd linux-0.11/
# 编译
$ make all
# 运行
$ ../run
如果上一步的修改没有啥问题,这里就能顺利编译,并成功运行,如果有啥错误,根据错误排查即可。
在Linux0.11中运行proccess.c
# 当前的工作路径为Linux0.11中的 /usr/root/ 目录。
# 编译 process.c
$ gcc -o process process.c -Wall
# 运行
$ ./process
此时运行 process.c,则内核会记录日志到 /var/process.log 文件。
日志统计分析
stat_log.py 这个文件在 /home/teacher/ 目录下,可以直接复制到 oslab 目录下,然后增加可执行权限,就可以对 process.log 进行分析了。
# 当前的工作路径为 /home/shiyanlou/oslab/ 目录。
# 将 stat_log.py 复制到 oslab 目录
$ cp /home/teacher/stat_log.py ./
# 增加可执行权限
$ chmod +x stat_log.py
# 挂载Linux0.11文件系统,这样就可以在宿主机下读取到 process.log 文件了
$ sudo ./mount-hdc
# 分析日志,因为输出很多,所以使用 less 进行查看
$ ./stat_log.py hdc/var/process.log 0 1 2 3 4 5 -g | less
查看所有进程:
$ ./stat_log.py hdc/var/process.log -g | less
修改时间片
实验给了很多提示,总结来说就是fork的时候,新进程会复制父进程的时间片(counter)和优先级(priority),后续进程运行时如果时间片用完,是根据自身的时间片(counter)和优先级(priority)重新计算的。
所以要修改进程时间片,只要修改进程0初始化时的时间片(counter)和优先级(priority)即可,后续fork的进程都会继承这两个值。
进程0的初始化在 include/linux/sched.h 文件中:
// 在include/linux/sched.h文件中
#define INIT_TASK \
{ 0,15,15,
// 上述三个值分别对应 state、counter 和 priority;
一开始我只尝试了5、10、15、25、50这5个值,后面发现样本太少,于是扩大了样本:1、5、10、15、25、50、75、100、150、250、500、750、1000、1500、2000。分别修改时间片为这些值,然后使用 stat_log.py 进行分析,得到的结果参考下面截图。
时间片为1:
时间片为5:
时间片为10:
时间片为15:
时间片为25:
时间片为50:
时间片为75:
时间片为100:
时间片为150:
时间片为250:
时间片为500:
时间片为750:
时间片为1000:
时间片为1500:
时间片为2000:
数据较多比较凌乱,我用Echarts图表工具进行分析(参考资料有分享链接),得到如下图表:
可以观察到一些有意思的东西:
- 进程7(CPU10):可以理解为CPU密集型,时间片在250(含)以下时,周转时间几乎不变,超过250后周转时间下降较多。初步分析原因时间片大了,CPU密集型任务被调度到时,执行的时间就更长,更有利于任务完成。至于为什么时间片在250(含)以下时,周转时间几乎不变这个暂时没有搞懂原因。
- 进程8(IO10):可以理解为IO密集型,几乎是随着时间片的增加而增加。分析原因是因为IO密集型经常要处于等待状态,等待完成后,如果其他进程刚分配到新的时间片运行,那么就要等到其他进程运行完,才会调度到自己。如果时间片短,其他进程运行时间就短,就很快会切换到自己。总结来说最差的情况就是自己IO已经准备就绪了,结果还要等上设置的时间片的时间才能轮到自己执行。
- 进程9(CPU5 IO5):可以理解为均衡性,它的折线基本上处于IO密集型和CPU密集型之间。折线图中50、75、100、150这4个点例外,我估计是样本程序的原因,或者是执行次数比较少,导致数据不太平均。
- 进程10(CPU1 IO9):基本是可以理解为IO密集型,但因为有1部分CPU计算,所以在时间片小的时候周转时间大于进程8(IO10),时间片大的时候周转时间要小于进程8(IO10)。
- 进程6(父):周转时间总是接近最大的进程的周转时间。这个不难理解,因为父进程总是要等待最后一个进程执行完后才退出。
- 平均周转时间:一开始随着周转时间的增加而增加,在15的时候最低,但是差别不大,后面几乎随着时间片的增加而增加。
总结一下结论:
- 时间片太小:由于时间片过短,进程很快就会被挂起,系统需要频繁地进行进程切换,这会增加系统的开销,降低CPU的利用率。频繁的进程切换不仅会增加系统的开销,还可能导致CPU缓存中的数据频繁失效,从而降低系统的整体性能。
- 时间片太大:由于时间片过长,一个进程可能会长时间占用CPU,导致其他进程等待时间过长,系统的响应速度变慢。过大的时间片会减少进程切换的次数,从而降低系统的并发性,使得系统无法高效地处理多个任务。
- 时间片设置:时间片不是越大越好,也不是越小越好。如果是CPU密集型,时间片设置大一些较为有利;如果是IO密集型,时间片设置小一些较为有利;如果是均衡性,时间片设置为中间最妥当。Linux0.11设置的时间片为15,从图上可以观察到平均周转时间最短,不愧是大佬的设定。
实验报告
1) 结合自己的体会,谈谈从程序设计者的角度看,单进程编程和多进程编程最大的区别是什么?
单进程编程与多进程编程的主要区别在于资源管理和并发处理。单进程编程简单,所有任务共享一个进程空间,但并发性受限;而多进程编程则能更好地利用多核处理器,提高并发性,但资源管理更复杂,编程难度和调试成本也相应增加。
2) 你是如何修改时间片的?仅针对样本程序建立的进程,在修改时间片前后,log 文件的统计结果(不包括 Graphic)都是什么样?结合你的修改分析一下为什么会这样变化,或者为什么没变化?
参考前面修改时间片章节。
参考资料
- 实验课本身的内容:这个翻来覆去多看几遍,结合《Linux内核完全注释》很多就理解了。
- 赵炯博士的《Linux内核完全注释》:代码的完全注释,通过阅读代码了解了很多细节问题。
- 修改时间片图表分析——百度Echarts图表生成工具。下面是对应的数据。
option = {
title: {
text: '不同时间片进程周转时间'
},
tooltip: {
trigger: 'axis'
},
legend: {
data: ['进程6(父)', '进程7(CPU10)', '进程8(IO10)', '进程9(CPU5 IO5)', '进程10(CPU1 IO9)', '平均周转时间']
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
toolbox: {
feature: {
saveAsImage: {}
}
},
xAxis: {
type: 'category',
boundaryGap: false,
data: ['1', '5', '10', '15', '25', '50', '75', '100', '150', '250', '500', '750', '1000', '1500', '2000']
},
yAxis: {
type: 'value',
},
series: [
{
name: '进程6(父)',
type: 'line',
data: [1608, 1607, 1607, 1606, 1608, 1608, 1684, 1911, 1912, 2113, 2214, 2214, 2315, 2315, 2314]
},
{
name: '进程7(CPU10)',
type: 'line',
data: [1604, 1604, 1604, 1604, 1604, 1604, 1605, 1604, 1605, 1604, 1404, 1304, 1304, 1204, 1204]
},
{
name: '进程8(IO10)',
type: 'line',
data: [1014, 1052, 1122, 1103, 1254, 1403, 1681, 1907, 1908, 2109, 2210, 2210, 2311, 2311, 2310]
},
{
name: '进程9(CPU5 IO5)',
type: 'line',
data: [1602, 1587, 1572, 1458, 1528, 1453, 1427, 1604, 1353, 1704, 1905, 1906, 2007, 2007, 2006]
},
{
name: '进程10(CPU1 IO9)',
type: 'line',
data: [1207, 1232, 1262, 1221, 1375, 1552, 1679, 1805, 1806, 2007, 2108, 2108, 2209, 2209, 2208]
},
{
name: '平均周转时间',
type: 'line',
data: [1407, 1416, 1433, 1398, 1473, 1524, 1615, 1766, 1716, 1907, 1968, 1948, 2029, 2009, 2008]
},
]
};
完。