实验三:进程运行轨迹的跟踪与统计

实验要求

实验要求参考实验楼课程。

实验目的

  1. 掌握Linux下的多进程编程技术;
  2. 通过对进程运行轨迹的跟踪来形象化进程的概念;
  3. 在进程运行轨迹跟踪的基础上进行相应的数据统计,从而能对进程调度算法进行实际的量化评价,更进一步加深对调度和调度算法的理解,获得能在实际操作系统上对调度算法进行实验数据对比的直接经验。

实验内容

  1. 基于模板“process.c”编写多进程的样本程序,实现如下功能:所有子进程都并行运行,每个子进程的实际运行时间一般不超过30秒;父进程向标准输出打印所有子进程的id,并在所有子进程都退出后才退出;
  2. 在Linux0.11上实现进程运行轨迹的跟踪。基本任务是在内核中维护一个日志文件/var/process.log,把从操作系统启动到系统关机过程中所有进程的运行轨迹都记录在这一log文件中。
  3. 在修改过的0.11上运行样本程序,通过分析log文件,统计该程序建立的所有进程的等待时间、完成时间(周转时间)和运行时间,然后计算平均等待时间,平均完成时间和吞吐量。可以自己编写统计程序,也可以使用python脚本程序——stat_log.py(hit-oslab/common/files)——进行统计。
  4. 修改0.11进程调度的时间片,然后再运行同样的样本程序,统计同样的时间数据,和原有的情况对比,体会不同时间片带来的差异。

实验步骤

process.c 代码

在实现的process.c中,一共fork 了5个进程。

int main(int argc, char * argv[])
{
    int pid, stat_addr;
    if (! (pid = fork())) {
        cpuio_bound(30, 1, 1);
    } else {
        printf("fork %d.\n", pid);
        if (! (pid = fork())) {
            cpuio_bound(30, 30, 0);
        } else {
            printf("fork %d.\n", pid);
            if (! (pid = fork())) {
                cpuio_bound(30, 0, 30);
            } else {
                printf("fork %d.\n", pid);
                if (! (pid = fork())) {
                    cpuio_bound(30, 9, 1);
                } else {
                    printf("fork %d.\n", pid);
                    if (! (pid = fork())) {
                        cpuio_bound(30, 1, 9);
                    } else {
                        printf("fork %d.\n", pid);
                    }
                }
            }
        }
    }
    wait(&stat_addr);
    return 0;
}

程序运行轨迹的跟踪

日志文件的格式

参考实验课程文档。

打开日志文件

同上。

写日志文件

同上。

寻找状态切换点

关于进程运行状态的综述和状态切换图,可以参考《Linux内核完全剖析:基于0.12内核》:P175。

进程的新建(N)及由新建(N)切换到就绪(N)状态

新建进程是由fork()实现的,该函数在内核中实现为sys_fork()。sys_fork()函数位于kernel/system_call.s: 208。

sys_fork:
    call find_empty_process
    testl %eax,%eax
    js 1f
    push %gs
    pushl %esi
    pushl %edi
    pushl %ebp
    pushl %eax
    call copy_process
    addl $20,%esp
1:  ret

find_empty_process函数用于获取系统中一个可用的pid,而copy_process函数负责创建一个子进程,并把父进程的所有寄存器内容直接复制给子进程。
copy_process位于kernel/fork.c: 68。

int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
        long ebx,long ecx,long edx,
        long fs,long es,long ds,
        long eip,long cs,long eflags,long esp,long ss)
{
    struct task_struct *p;
    int i;
    struct file *f;

    p = (struct task_struct *) get_free_page();
    if (!p)
        return -EAGAIN;
    task[nr] = p;
    *p = *current;    /* NOTE! this doesn't copy the supervisor stack */
    p->state = TASK_UNINTERRUPTIBLE;
/* =========== modified =========== */
    fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'N', jiffies);
/* =========== modified =========== */
    p->pid = last_pid;
    p->father = current->pid;
    /* 省略若干代码 */
    p->state = TASK_RUNNING;    /* do this last, just in case */
/* =========== modified =========== */
    fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'J', jiffies);
/* =========== modified =========== */
    return last_pid;

copy_process函数实现的功能包括:

  1. 为新任务的task_struct分配内存,并将当前当前任务的task_struct复制给新任务。
  2. 修改新任务的task_struct。此时。需要将新任务的状态设置为不可中断状态,防止其被内核调度执行。笔者认为,此时新任务已经完成了新建(N)状态。
  3. 修改任务状态段(TSS)数据。此时,需要设置TSS中的内核栈信息(ss0:dsp0),将传入函数的参数赋给TSS中对应的寄存器,保存本任务的LDT描述符和状态位图。值得注意的是,在设置寄存器时,需要将新任务的eax的值置为0,而对于父任务来说,eax的值是find_empty_process函数的返回值。这也就是父进程调用fork函数返回子进程的ID,而子进程调用fork函数返回0的原因。
  4. 复制进程页表;处理文件引用数;在GDT表中设置新任务是TSS段和LDT段描述符;设置进程之间的关系列表指针。
  5. 将任务置为可运行状态。此时,新任务处于就绪(J)状态。

进程的就绪(J)状态和运行(R)状态之间的切换

在linux-0.11的内核代码中是不区分就绪状态和运行状态的,二者都是TASK_RUNNING。所以代码中并没有显示地对这两种状态切换的代码。
实现进程切换的代码是switch_to(int)(kernel/sched.c: 141)函数。在切换进程之前的代码实现了进程调度的算法,这些将在下文中讨论。进程调度算法按照一定的策略找到下个执行的进程的ID(next),使用switch_to函数将cpu的使用权交给next号进程。需要注意的是,next号进程可能就是当前正在执行的进程,所以首先要判断next进程是否是当前进程。只有当二者不相等时,才真正地发生了进程的调度。next号进程就由就绪状态切换到运行状态。而如果当前进程是运行状态,则其状态需要切换到就绪态。
这里展开分析一下,在linux-0.11的内核中,何时会将正在运行的程序转换成就绪状态。在实验说明文档中有过说明,对于linux-0.11系统来说,有一个长度为10ms的tick。这个tick不仅仅是作为统计进程的单位,而且定时芯片(8253/8254)会每隔10ms向系统发出一次时间终端请求(int 0x20,时间中断的代码在kernel/sys_call.s: 176)。而系统在响应时间中断时,会调用do_timer(long gpl)函数(kernel/sched.c: 305)。如果当前正在执行的用户进程的时间片已经用完(即GPL == 3,且current->count == 0),该函数会调用schedule()函数进行进程调度操作,则当前程序很有可能被切换到就绪状态。

/* =========== modified =========== */
    if (task[next] != current) {
        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);
    }
/* =========== modified =========== */
    switch_to(next);

进程由运行(R)状态切换到阻塞(W)状态

当一个进程需要等待一个外部事件时,就需要将自身的状态由运行态切换到阻塞态。常见的外部事件包括等待I/O和等待其他进程的消息等。在内核中,等待同一个资源的进程隐式地构成一个等待队列,采用后进先出(FILO)的策略。等待队列的数据结构可以参考《Linux内核完全剖析:基于0.12内核》:P299。在linux-0.11的内核代码中将进程切换至阻塞态的函数是sleep_on(kernel/sched.c: 151)和interruptible_sleep_on(kernel/sched.c: 167)。
这2个函数的作用就是将暂时无法运行的进程切换到阻塞态,并将该进程的task_struct添加到等待队列中。之后,会调用schedule()函数将CPU分配给其他的进程。当进程被唤醒而重新执行时就会执行schedule()之后的语句,并把比它早进入等待队列的一个进程唤醒,即从阻塞态切换到就绪态。
interruptible_sleep_on与sleep_on的功能基本类似,只是在进行调度之前,把当前任务设置成可中断等待状态,并在本任务被唤醒后还需要判断自己是否为等待队列中的第一个任务。如果不是,则需要将自己重新设置成可中断等待状态,并执行schedule()函数,让出CPU的执行权。
下面展示的是打印process.log的语句。
在sleep_on中:

void 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;
    current->state = TASK_UNINTERRUPTIBLE;
/* =========== modified =========== */
    fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'W', jiffies);
/* =========== modified =========== */
    schedule();
    if (tmp) {
        tmp->state=0;
/* =========== modified =========== */
        fprintk(3, "%ld\t%c\t%ld\n", tmp->pid, 'J', jiffies);
/* =========== modified =========== */
    }
}

在interruptible_sleep_on中

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;
/* =========== modified =========== */
    fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'W', jiffies);
/* =========== modified =========== */
    schedule();
    if (*p && *p != current) {
        (**p).state=0;
/* =========== modified =========== */
        fprintk(3, "%ld\t%c\t%ld\n", (*p)->pid, 'J', jiffies);
/* =========== modified =========== */
        goto repeat;
    }
    *p=NULL;
    if (tmp) {
        tmp->state=0;
/* =========== modified =========== */
        fprintk(3, "%ld\t%c\t%ld\n", tmp->pid, 'J', jiffies);
/* =========== modified =========== */
    }
}

除了上述的2个函数可以实现将进程从执行状态切换到阻塞状态,sys_pause()(kernel/sched.c: 144)和sys_waitpid(kernel/exit.c: 142)也可以做到。
当schedule函数发现系统中没有可执行的任务时,就会切换到0号任务,而0号任务就会循环执行sys_pause,将自己设置为可中断的阻塞态。理论上,该系统调用将导致进程进入睡眠状态,直到收到一个用于终止进程或者促使进程调用的信号量。然而这些功能在0.11版本内核中没有没实现。

int sys_pause(void)
{
    current->state = TASK_INTERRUPTIBLE;
/* =========== modified =========== */
    if (current->pid != 0) {
        fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'W', jiffies);
    }
/* =========== modified =========== */
    schedule();
    return 0;
}

而sys_waitpid函数的功能则是对任务数组进行扫描,如果其中有子进程不处于退出状态或者僵死状态,则将父进程切换到可中断的阻塞状态。

进程由阻塞(W)状态切换到就绪(J)状态

将进程由阻塞态切换到就绪态的函数,除了之前小节中提到的sleep_on和interruptible_sleep_on函数,wake_up和schedule函数也能完成该切换。
wake_up函数直接将等待队列中的第一个任务唤醒:

void wake_up(struct task_struct **p)
{
    if (p && *p) {
        (**p).state=0;
/* =========== modified =========== */
        fprintk(3, "%ld\t%c\t%ld\n", (*p)->pid, 'J', jiffies);
/* ========== modified =========== */
        *p=NULL;
    }
}

而在schedule函数中,首先会检测进程的报警定时值(alarm),并将处于可中断状态且接收到信号的进程唤醒。

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;
/* =========== modified =========== */
            fprintk(3, "%ld\t%c\t%ld\n", (*p)->pid, 'J', jiffies);
/* =========== modified =========== */
        }
    }
}

进程的退出

进程退出状态的设置是在do_exit(kernel/exit.c: 102)中实现的。
该函数释放当前进程的代码段和数据段所占的内存页,执行与文件系统相关的清理工作,最后将进程状态设置为僵死状态,并给退出码赋值。

进程时间统计

由于process.c在运行时会在终端上打印fork的子进程编号,在进行时间统计时,最好只统计这些进程。

chen@chen:oslab$ ./stat_log.py process.log 13 14 15 16 17
(Unit: tick)
Process   Turnaround   Waiting   CPU Burst   I/O Burst
     13         3158      1631         800         727
     14         3190      2125        1065           0
     15         3172        32           0        3139
     16         3171      2036        1019         115
     17         3144       610         300        2234
Average:     3167.00   1286.80
Throughout: 0.16/s

由上面的统计可以看出,处理器繁忙的进程的等待时间也很长,而I/O繁忙的进程的等待时间会相对短一些。

调度算法修改

分析代码可知,0.11的调度算法是选取counter值最大的就绪进程进行调度。其中运行态进程(即current)的counter数值会随着时钟中断而不断减1(时钟中断10ms一次),所以是一种比较典型的时间片轮转调度算法。另外,由上面的程序可以看出,当没有counter值大于0的就绪进程时,要对所有的进程做(*p)->counter = ((*p)->counter >> 1) + (*p)->priority。其效果是对所有的进程(包括阻塞态进程)都进行counter的衰减,并再累加priority值。这样,对正被阻塞的进程来说,一个进程在阻塞队列中停留的时间越长,其优先级越大,被分配的时间片也就会越大。所以总的来说,Linux 0.11的进程调度是一种综合考虑进程优先级并能动态反馈调整时间片的轮转调度算法。
时间片counter和优先级priority的初始化在include/sched.h: 113。

#define INIT_TASK \ 
 { 0,15,15, //分别对应state;counter;和priority;

只需要修改这个宏里的数字,就可以修改counter和priority的初始值。

你可能感兴趣的:(实验三:进程运行轨迹的跟踪与统计)