实验4 进程运行轨迹的跟踪与统计
实验目的
掌握 Linux 下的多进程编程技术;
通过对进程运行轨迹的跟踪来形象化进程的概念;
在进程运行轨迹跟踪的基础上进行相应的数据统计,从而能对进程调度算法进行实际的量化评价,更进一步加深对调度和调度算法的理解,获得能在实际操作系统上对调度算法进行实验数据对比的直接经验。
实验内容
进程从创建(Linux 下调用 fork())到结束的整个过程就是进程的生命期,进程在其生命期中的运行轨迹实际上就表现为进程状态的多次切换,如进程创建以后会成为就绪态;当该进程被调度以后会切换到运行态;在运行的过程中如果启动了一个文件读写操作,操作系统会将该进程切换到阻塞态(等待态)从而让出 CPU;当文件读写完毕以后,操作系统会在将其切换成就绪态,等待进程调度算法来调度该进程执行……
本次实验包括如下内容:
基于模板 process.c 编写多进程的样本程序,实现如下功能: + 所有子进程都并行运行,每个子进程的实际运行时间一般不超过 30 秒; + 父进程向标准输出打印所有子进程的 id,并在所有子进程都退出后才退出;
在 Linux0.11 上实现进程运行轨迹的跟踪。 + 基本任务是在内核中维护一个日志文件 /var/process.log,把从操作系统启动到系统关机过程中所有进程的运行轨迹都记录在这一 log 文件中。
在修改过的 0.11 上运行样本程序,通过分析 log 文件,统计该程序建立的所有进程的等待时间、完成时间(周转时间)和运行时间,然后计算平均等待时间,平均完成时间和吞吐量。可以自己编写统计程序进行统计。
修改 0.11 进程调度的时间片,然后再运行同样的样本程序,统计同样的时间数据,和原有的情况对比,体会不同时间片带来的差异。
/var/process.log 文件的格式必须为:
pid X time
其中:
pid 是进程的 ID;
X 可以是 N、J、R、W 和 E 中的任意一个,分别表示进程新建(N)、进入就绪态(J)、进入运行态®、进入阻塞态(W) 和退出(E);
time 表示 X 发生的时间。这个时间不是物理时间,而是系统的滴答时间(tick);
三个字段之间用制表符分隔。例如:
12 N 1056 12 J 1057 4 W 1057 12 R 1057 13 N 1058 13 J 1059 14 N 1059 14 J 1060 15 N 1060 15 J 1061 12 W 1061 15 R 1061 15 J 1076 14 R 1076 14 E 1076 ......
编写process.c文件
提示
在 Ubuntu 下,top 命令可以监视即时的进程状态。在 top 中,按 u,再输入你的用户名,可以限定只显示以你的身份运行的进程,更方便观察。按 h 可得到帮助。
在 Ubuntu 下,ps 命令可以显示当时各个进程的状态。ps aux 会显示所有进程;ps aux | grep xxxx 将只显示名为 xxxx 的进程。更详细的用法请问 man。
在 Linux 0.11 下,按 F1 可以即时显示当前所有进程的状态。
文件主要作用
process.c文件主要实现了一个函数:
/* * 此函数按照参数占用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,直到总运行时间超过last为止 * 所有时间的单位为秒 */ cpuio_bound(int last, int cpu_time, int io_time);
下面是 4 个使用的例子:
// 比如一个进程如果要占用10秒的CPU时间,它可以调用: cpuio_bound(10, 1, 0); // 只要cpu_time>0,io_time=0,效果相同 // 以I/O为主要任务: cpuio_bound(10, 0, 1); // 只要cpu_time=0,io_time>0,效果相同 // CPU和I/O各1秒钟轮回: cpuio_bound(10, 1, 1); // 较多的I/O,较少的CPU: // I/O时间是CPU时间的9倍 cpuio_bound(10, 1, 9);
修改此模板,用 fork() 建立若干个同时运行的子进程,父进程等待所有子进程退出后才退出,每个子进程按照你的意愿做不同或相同的 cpuio_bound(),从而完成一个个性化的样本程序。
它可以用来检验有关 log 文件的修改是否正确,同时还是数据统计工作的基础。
wait() 系统调用可以让父进程等待子进程的退出。
关键函数解释
fork()
这里摘录某位博主的详解操作系统之 fork() 函数详解 - 简书 (jianshu.com)
fork()函数通过系统调用创建一个与原来进程几乎完全相同的进程,也就是两个进程可以做完全相同的事,但如果初始参数或者传入的变量不同,两个进程也可以做不同的事。
一个进程调用fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。
#include <unistd.h> #include <stdio.h> int main () { pid_t fpid; //fpid表示fork函数返回的值 int count=0; fpid=fork(); if (fpid < 0) printf("error in fork!"); else if (fpid == 0) { printf("i am the child process, my process id is %d/n",getpid()); printf("我是爹的儿子/n");//对某些人来说中文看着更直白。 count++; } else { printf("i am the parent process, my process id is %d/n",getpid()); printf("我是孩子他爹/n"); count++; } printf("统计结果是: %d/n",count); return 0; }
运行结果:
i am the child process, my process id is 5574 我是爹的儿子 统计结果是: 1 i am the parent process, my process id is 5573 我是孩子他爹 统计结果是: 1
在语句fpid=fork()之前,只有一个进程在执行这段代码,但在这条语句之后,就变成两个进程在执行了,这两个进程的几乎完全相同,将要执行的下一条语句都是if(fpid<0)
为什么两个进程的fpid不同呢,这与fork函数的特性有关。fork调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:
1)在父进程中,fork返回新创建子进程的进程ID;
2)在子进程中,fork返回0;
3)如果出现错误,fork返回一个负值;
在fork函数执行完毕后,如果创建新进程成功,则出现两个进程,一个是子进程,一个是父进程。在子进程中,fork函数返回0,在父进程中,fork返回新创建子进程的进程ID。我们可以通过fork返回的值来判断当前进程是子进程还是父进程。
引用一位网友的话来解释fpid的值为什么在父子进程中不同。“其实就相当于链表,进程形成了链表,父进程的fpid(p 意味point)指向子进程的进程id, 因为子进程没有子进程,所以其fpid为0.
fork出错可能有两种原因:
1)当前的进程数已经达到了系统规定的上限,这时errno的值被设置为EAGAIN。
2)系统内存不足,这时errno的值被设置为ENOMEM。
创建新进程成功后,系统中出现两个基本完全相同的进程,这两个进程执行没有固定的先后顺序,哪个进程先执行要看系统的进程调度策略。
每个进程都有一个独特(互不相同)的进程标识符(process ID),可以通过getpid()函数获得,还有一个记录父进程pid的变量,可以通过getppid()函数获得变量的值。
fork执行完毕后,出现两个进程
有人说两个进程的内容完全一样啊,怎么打印的结果不一样啊,那是因为判断条件的原因,上面列举的只是进程的代码和指令,还有变量啊。
执行完fork后,进程1的变量为count=0,fpid!=0(父进程)。进程2的变量为count=0,fpid=0(子进程),这两个进程的变量都是独立的,存在不同的地址中,不是共用的,这点要注意。可以说,我们就是通过fpid来识别和操作父子进程的。
还有人可能疑惑为什么不是从#include处开始复制代码的,这是因为fork是把进程当前的情况拷贝一份,执行fork时,进程已经执行完了int count=0;fork只拷贝下一个要执行的代码到新的进程。
struct tms 结构体
struct tms 结构体定义在 <sys/times.h> 头文件里,具体定义如下:
引用 /* Structure describing CPU time used by a process and its children. */ struct tms { clock_t tms_utime ; /* User CPU time. 用户程序 CPU 时间*/ clock_t tms_stime ; /* System CPU time. 系统调用所耗费的 CPU 时间 */ clock_t tms_cutime ; /* User CPU time of dead children. 已死掉子进程的 CPU 时间*/ clock_t tms_cstime ; /* System CPU time of dead children. 已死掉子进程所耗费的系统调用 CPU 时间*/ };
用户CPU时间和系统CPU时间之和为CPU时间,即命令占用CPU执行的时间总和。实际时间要大于CPU时间,因为Linux是多任务操作系统,往往在执行一条命令时,系统还要处理其他任务。另一个需要注意的问题是即使每次执行相同的命令,所花费的时间也不一定相同,因为其花费的时间与系统运行相关。
数据类型 clock_t
关于该数据类型的定义如下:
#ifndef _CLOCK_T_DEFINED typedef long clock_t; #define _CLOCK_T_DEFINED #endif
clock_t 是一个长整型数。
在 time.h 文件中,还定义了一个常量 CLOCKS_PER_SEC ,它用来表示一秒钟会有多少个时钟计时单元,其定义如下:
#define CLOCKS_PER_SEC ((clock_t)1000)
下文就模拟cpu操作,定义 H Z = 100 HZ=100HZ=100,内核的标准时间是jiffy,一个jiffy就是一个内部时钟周期,而内部时钟周期是由100HZ的频率所产生中的,也就是一个时钟滴答,间隔时间是10毫秒(ms).计算出来的时间也并非真实时间,而是时钟滴答次数,乘以10ms可以得到真正的时间。
代码实现
下面给出代码:
#include <stdio.h> #include <unistd.h> #include <time.h> #include <sys/times.h> #define HZ 100 void cpuio_bound(int last, int cpu_time, int io_time); int main(int argc, char * argv[]) { pid_t n_proc[10]; /*10个子进程 PID*/ int i; for(i=0;i<10;i++) { n_proc[i] = fork(); /*子进程*/ if(n_proc[i] == 0) { cpuio_bound(20,2*i,20-2*i); /*每个子进程都占用20s*/ return 0; /*执行完cpuio_bound 以后,结束该子进程*/ } /*fork 失败*/ else if(n_proc[i] < 0 ) { printf("Failed to fork child process %d!\n",i+1); return -1; } /*父进程继续fork*/ } /*打印所有子进程PID*/ for(i=0;i<10;i++) printf("Child PID: %d\n",n_proc[i]); /*等待所有子进程完成*/ wait(&i); /*Linux 0.11 上 gcc要求必须有一个参数, gcc3.4+则不需要*/ 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; } }
答疑
❓ 为什么说它可以用来检验有关 log 文件的修改是否正确,同时还是数据统计工作的基础?
每个子进程都通过 cpuio_bound 函数实现了占用CPU和I/O时间的操作,并且可以精确的知道每个操作的时间。所以下面的 log 文件(日志文件)正确与否可以借此推算。
尽早打开log文件
操作系统启动后先要打开 /var/process.log,然后在每个进程发生状态切换的时候向 log 文件内写入一条记录,其过程和用户态的应用程序没什么两样。然而,因为内核状态的存在,使过程中的很多细节变得完全不一样。
为了能尽早开始记录,应当在内核启动时就打开 log 文件。内核的入口是 init/main.c 中的 main(),其中一段代码是:
//…… move_to_user_mode(); if (!fork()) { /* we count on this going ok */ init(); } //……
这段代码在进程 0 中运行,先切换到用户模式,然后全系统第一次调用 fork() 建立进程 1。进程 1 调用 init()。
在 init()中:
// …… //加载文件系统 setup((void *) &drive_info); // 打开/dev/tty0,建立文件描述符0和/dev/tty0的关联 (void) open("/dev/tty0",O_RDWR,0); // 让文件描述符1也和/dev/tty0关联 (void) dup(0); // 让文件描述符2也和/dev/tty0关联 (void) dup(0); // ……
这段代码建立了文件描述符 0、1 和 2,它们分别就是 stdin、stdout 和 stderr。这三者的值是系统标准(Windows 也是如此),不可改变。
可以把 log 文件的描述符关联到 3。文件系统初始化,描述符 0、1 和 2 关联之后,才能打开 log 文件,开始记录进程的运行轨迹。
为了能尽早访问 log 文件,我们要让上述工作在进程 0 中就完成。所以把这一段代码从 init() 移动到 main() 中,放在 move_to_user_mode() 之后(不能再靠前了),同时加上打开 log 文件的代码。
修改后的 main() 如下:
//…… move_to_user_mode(); /***************添加开始***************/ setup((void *) &drive_info); // 建立文件描述符0和/dev/tty0的关联 (void) open("/dev/tty0",O_RDWR,0); //文件描述符1也和/dev/tty0关联 (void) dup(0); // 文件描述符2也和/dev/tty0关联 (void) dup(0); (void) open("/var/process.log",O_CREAT|O_TRUNC|O_WRONLY,0666); /***************添加结束***************/ if (!fork()) { /* we count on this going ok */ init(); } //……
打开 log 文件的参数的含义是建立只写文件,如果文件已存在则清空已有内容。文件的权限是所有人可读可写。
这样,文件描述符 0、1、2 和 3 就在进程 0 中建立了。根据 fork() 的原理,进程 1 会继承这些文件描述符,所以 init() 中就不必再 open() 它们。此后所有新建的进程都是进程 1 的子孙,也会继承它们。但实际上,init() 的后续代码和 /bin/sh 都会重新初始化它们。所以只有进程 0 和进程 1 的文件描述符肯定关联着 log 文件,这一点在接下来的写 log 中很重要。
小结
其实就是为了尽早打开log日志文件开始记录,那必须满足在用户模式且可以进行文件读写,因此最前的位置只能在 move_to_user_mode() 之后(不能再靠前了),并且建立文件描述符 0、1 和 2,它们分别就是 stdin、stdout 和 stderr。