1.实验目的

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

2.实验内容

进程从创建(Linux 下调用 fork())到结束的整个过程就是进程的生命期,进程在其生命期中的运行轨迹实际上就表现为进程状态的多次切换,如进程创建以后会成为就绪态;当该进程被调度以后会切换到运行态;在运行的过程中如果启动了一个文件读写操作,操作系统会将该进程切换到阻塞态(等待态)从而让出 CPU;当文件读写完毕以后,操作系统会在将其切换成就绪态,等待进程调度算法来调度该进程执行……

image-20220330150535549

本次实验包括如下内容:

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

/var/process.log 文件的格式必须为:

1
pid    X    time

其中:

  • pid 是进程的 ID;
  • X 可以是 N、J、R、W 和 E 中的任意一个,分别表示进程新建(N)、进入就绪态(J)、进入运行态(R)、进入阻塞态(W) 和退出(E);
  • time 表示 X 发生的时间。这个时间不是物理时间,而是系统的滴答时间(tick);

三个字段之间用制表符分隔。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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
......

3.实验报告

完成实验后,在实验报告中回答如下问题:

  • 结合自己的体会,谈谈从程序设计者的角度看,单进程编程和多进程编程最大的区别是什么?
  • 你是如何修改时间片的?仅针对样本程序建立的进程,在修改时间片前后,log 文件的统计结果(不包括 Graphic)都是什么样?结合你的修改分析一下为什么会这样变化,或者为什么没变化?

4.实验提示

process.c 的编写涉及到 fork()wait() 系统调用,请自行查阅相关文献。

0.11 内核修改涉及到 init/main.ckernel/fork.ckernel/sched.c,开始实验前如果能详细阅读《注释》一书的相关部分,会大有裨益。

4.1 编写样本程序

所谓样本程序,就是一个生成各种进程的程序。我们的对 0.11 的修改把系统对它们的调度情况都记录到 log 文件中。在修改调度算法或调度参数后再运行完全一样的样本程序,可以检验调度算法的优劣。

理论上,此程序可以在任何 Unix/Linux 上运行,所以建议在 Ubuntu 上调试通过后,再拷贝到 0.11 下运行。

process.c 是样本程序的模板(在 /home/teacher/ 目录下)。

它主要实现了一个函数:

1
2
3
4
5
6
7
8
9
/*
 * 此函数按照参数占用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 个使用的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 比如一个进程如果要占用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() 系统调用可以让父进程等待子进程的退出。

小技巧:

在 Ubuntu 下,top 命令可以监视即时的进程状态。在 top 中,按 u,再输入你的用户名,可以限定只显示以你的身份运行的进程,更方便观察。按 h 可得到帮助。

在 Ubuntu 下,ps 命令可以显示当时各个进程的状态。ps aux 会显示所有进程;ps aux | grep xxxx 将只显示名为 xxxx 的进程。更详细的用法请问 man。

在 Linux 0.11 下,按 F1 可以即时显示当前所有进程的状态。

4.2 log 文件

操作系统启动后先要打开 /var/process.log,然后在每个进程发生状态切换的时候向 log 文件内写入一条记录,其过程和用户态的应用程序没什么两样。然而,因为内核状态的存在,使过程中的很多细节变得完全不一样。

打开 log 文件

为了能尽早开始记录,应当在内核启动时就打开 log 文件。内核的入口是 init/main.c 中的 main()(Windows 环境下是 start()),其中一段代码是:

1
2
3
4
5
6
//……
move_to_user_mode();
if (!fork()) {        /* we count on this going ok */
    init();
}
//……

这段代码在进程 0 中运行,先切换到用户模式,然后全系统第一次调用 fork() 建立进程 1。进程 1 调用 init()

在 init()中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// ……
//加载文件系统
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() 如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//……
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 中很重要。

4.3 写 log 文件

log 文件将被用来记录进程的状态转移轨迹。所有的状态转移都是在内核进行的。

在内核状态下,write() 功能失效,其原理等同于《系统调用》实验中不能在内核状态调用 printf(),只能调用 printk()。编写可在内核调用的 write() 的难度较大,所以这里直接给出源码。它主要参考了 printk()sys_write() 而写成的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#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;
}

因为和 printk 的功能近似,建议将此函数放入到 kernel/printk.c 中。fprintk() 的使用方式类同与 C 标准库函数 fprintf(),唯一的区别是第一个参数是文件描述符,而不是文件指针。

例如:

1
2
3
4
5
// 向stdout打印正在运行的进程的ID
fprintk(1, "The ID of running process is %ld", current->pid);

// 向log文件输出跟踪进程运行轨迹
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'R', jiffies);

4.4 jiffies,滴答

jiffieskernel/sched.c 文件中定义为一个全局变量:

1
long volatile jiffies=0;

它记录了==从开机到当前时间的时钟中断发生次数==。在 kernel/sched.c 文件中的 sched_init() 函数中,时钟中断处理函数被设置为:

1
set_intr_gate(0x20,&timer_interrupt);

而在 kernel/system_call.s 文件中将 timer_interrupt 定义为:

1
2
3
4
5
timer_interrupt:
!    ……
! 增加jiffies计数值
    incl jiffies
!    ……

这说明 jiffies 表示从开机时到现在发生的时钟中断次数,这个数也被称为 “滴答数”。

另外,在 kernel/sched.c 中的 sched_init() 中有下面的代码:

1
2
3
4
// 设置8253模式
outb_p(0x36, 0x43);
outb_p(LATCH&0xff, 0x40);
outb_p(LATCH>>8, 0x40);

这三条语句用来设置每次时钟中断的间隔,即为 LATCH,而 LATCH 是定义在文件 kernel/sched.c 中的一个宏:

1
2
3
4
5
// 在 kernel/sched.c 中
#define LATCH  (1193180/HZ)

// 在 include/linux/sched.h 中
#define HZ 100

再加上 PC 机 8253 定时芯片的输入时钟频率为 1.193180MHz,即 1193180/每秒,LATCH=1193180/100,时钟每跳 11931.8 下产生一次时钟中断,即每 1/100 秒(10ms)产生一次时钟中断,所以 jiffies 实际上记录了从开机以来共经过了多少个 10ms。

4.5 寻找状态切换点

必须找到所有发生进程状态切换的代码点,并在这些点添加适当的代码,来输出进程状态变化的情况到 log 文件中。

此处要面对的情况比较复杂,需要对 kernel 下的 fork.csched.c 有通盘的了解,而 exit.c 也会涉及到。

我们给出两个例子描述这个工作该如何做,其他情况实验者可仿照完成。

(1)例子 1:记录一个进程生命期的开始

第一个例子是看看如何记录一个进程生命期的开始,当然这个事件就是进程的创建函数 fork(),由《系统调用》实验可知,fork() 功能在内核中实现为 sys_fork(),该“函数”在文件 kernel/system_call.s 中实现为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
sys_fork:
    call find_empty_process
!    ……
! 传递一些参数
    push %gs
    pushl %esi
    pushl %edi
    pushl %ebp
    pushl %eax
! 调用 copy_process 实现进程创建
    call copy_process
    addl $20,%esp

所以真正实现进程创建的函数是 copy_process(),它在 kernel/fork.c 中定义为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
int copy_process(int nr,……)
{
    struct task_struct *p;
//    ……
// 获得一个 task_struct 结构体空间
    p = (struct task_struct *) get_free_page();
//    ……
    p->pid = last_pid;
//    ……
// 设置 start_time 为 jiffies
    p->start_time = jiffies;
//       ……
/* 设置进程状态为就绪。所有就绪进程的状态都是
   TASK_RUNNING(0),被全局变量 current 指向的
   是正在运行的进程。*/
    p->state = TASK_RUNNING;

    return last_pid;
}

因此要完成进程运行轨迹的记录就要在 copy_process() 中添加输出语句。

这里要输出两种状态,分别是“N(新建)”和“J(就绪)”。

(2)例子 2:记录进入睡眠态的时间

第二个例子是记录进入睡眠态的时间。sleep_on() 和 interruptible_sleep_on() 让当前进程进入睡眠状态,这两个函数在 kernel/sched.c 文件中定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
void sleep_on(struct task_struct **p)
{
    struct task_struct *tmp;
//    ……
    tmp = *p;
// 仔细阅读,实际上是将 current 插入“等待队列”头部,tmp 是原来的头部
    *p = current;
// 切换到睡眠态
    current->state = TASK_UNINTERRUPTIBLE;
// 让出 CPU
    schedule();
// 唤醒队列中的上一个(tmp)睡眠进程。0 换作 TASK_RUNNING 更好
// 在记录进程被唤醒时一定要考虑到这种情况,实验者一定要注意!!!
    if (tmp)
        tmp->state=0;
}
/* TASK_UNINTERRUPTIBLE和TASK_INTERRUPTIBLE的区别在于不可中断的睡眠
 * 只能由wake_up()显式唤醒,再由上面的 schedule()语句后的
 *
 *   if (tmp) tmp->state=0;
 *
 * 依次唤醒,所以不可中断的睡眠进程一定是按严格从“队列”(一个依靠
 * 放在进程内核栈中的指针变量tmp维护的队列)的首部进行唤醒。而对于可
 * 中断的进程,除了用wake_up唤醒以外,也可以用信号(给进程发送一个信
 * 号,实际上就是将进程PCB中维护的一个向量的某一位置位,进程需要在合
 * 适的时候处理这一位。感兴趣的实验者可以阅读有关代码)来唤醒,如在
 * schedule()中:
 *
 *  for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
 *      if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) &&
 *         (*p)->state==TASK_INTERRUPTIBLE)
 *         (*p)->state=TASK_RUNNING;//唤醒
 *
 * 就是当进程是可中断睡眠时,如果遇到一些信号就将其唤醒。这样的唤醒会
 * 出现一个问题,那就是可能会唤醒等待队列中间的某个进程,此时这个链就
 * 需要进行适当调整。interruptible_sleep_on和sleep_on函数的主要区别就
 * 在这里。
 */
void interruptible_sleep_on(struct task_struct **p)
{
    struct task_struct *tmp;
       
    tmp=*p;
    *p=current;
repeat:    current->state = TASK_INTERRUPTIBLE;
    schedule();
// 如果队列头进程和刚唤醒的进程 current 不是一个,
// 说明从队列中间唤醒了一个进程,需要处理
    if (*p && *p != current) {
 // 将队列头唤醒,并通过 goto repeat 让自己再去睡眠
        (**p).state=0;
        goto repeat;
    }
    *p=NULL;
//作用和 sleep_on 函数中的一样
    if (tmp)
        tmp->state=0;
}

相信实验者已经找到合适的地方插入记录进程从运行到睡眠的语句了。

总的来说,Linux 0.11 支持四种进程状态的转移:就绪到运行、运行到就绪、运行到睡眠和睡眠到就绪,此外还有新建和退出两种情况。其中就绪与运行间的状态转移是通过 schedule()(它亦是调度算法所在)完成的;运行到睡眠依靠的是 sleep_on()interruptible_sleep_on(),还有进程主动睡觉的系统调用 sys_pause()sys_waitpid();睡眠到就绪的转移依靠的是 wake_up()。所以只要在这些函数的适当位置插入适当的处理语句就能完成进程运行轨迹的全面跟踪了。

  • 为了让生成的 log 文件更精准,以下几点请注意:
  • 进程退出的最后一步是通知父进程自己的退出,目的是唤醒正在等待此事件的父进程。从时序上来说,应该是子进程先退出,父进程才醒来。
  • schedule() 找到的 next 进程是接下来要运行的进程(注意,一定要分析清楚 next 是什么)。如果 next 恰好是当前正处于运行态的进程,swith_to(next) 也会被调用。这种情况下相当于当前进程的状态没变。
  • 系统无事可做的时候,进程 0 会不停地调用 sys_pause(),以激活调度算法。此时它的状态可以是等待态,等待有其它可运行的进程;也可以叫运行态,因为它是唯一一个在 CPU 上运行的进程,只不过运行的效果是等待。

4.6 管理 log 文件

日志文件的管理与代码编写无关,有几个要点要注意:

  • 每次关闭 bochs 前都要执行一下 sync 命令,它会刷新 cache,确保文件确实写入了磁盘。
  • 在 0.11 下,可以用 ls -l /varll /var 查看 process.log 是否建立,及它的属性和长度。
  • 一定要实践《实验环境的搭建与使用》一章中关于文件交换的部分。最终肯定要把 process.log 文件拷贝到主机环境下处理。
  • 在 0.11 下,可以用 vi /var/process.logmore /var/process.log 查看整个 log 文件。不过,还是拷贝到 Ubuntu 下看,会更舒服。
  • 在 0.11 下,可以用 tail -n NUM /var/process.log 查看 log 文件的最后 NUM 行。

一种可能的情况下,得到的 process.log 文件的前几行是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
1    N    48    //进程1新建(init())。此前是进程0建立和运行,但为什么没出现在log文件里?
1    J    49    //新建后进入就绪队列
0    J    49    //进程0从运行->就绪,让出CPU
1    R    49    //进程1运行
2    N    49    //进程1建立进程2。2会运行/etc/rc脚本,然后退出
2    J    49
1    W    49    //进程1开始等待(等待进程2退出)
2    R    49    //进程2运行
3    N    64    //进程2建立进程3。3是/bin/sh建立的运行脚本的子进程
3    J    64
2    E    68    //进程2不等进程3退出,就先走一步了
1    J    68    //进程1此前在等待进程2退出,被阻塞。进程2退出后,重新进入就绪队列
1    R    68
4    N    69    //进程1建立进程4,即shell
4    J    69
1    W    69    //进程1等待shell退出(除非执行exit命令,否则shell不会退出)
3    R    69    //进程3开始运行
3    W    75
4    R    75
5    N    107    //进程5是shell建立的不知道做什么的进程
5    J    108
4    W    108
5    R    108
4    J    110
5    E    111    //进程5很快退出
4    R    111
4    W    116    //shell等待用户输入命令。
0    R    116    //因为无事可做,所以进程0重出江湖
4    J    239    //用户输入命令了,唤醒了shell
4    R    239
4    W    240
0    R    240
……

4.7 数据统计

为展示实验结果,需要编写一个数据统计程序,它从 log 文件读入原始数据,然后计算平均周转时间、平均等待时间和吞吐率。

任何语言都可以编写这样的程序,实验者可自行设计。我们用 python 语言编写了一个——stat_log.py(这是 python 源程序,可以用任意文本编辑器打开)。

python 是一种跨平台的脚本语言,号称 “可执行的伪代码”,非常强大,非常好用,也非常有用,建议闲着的时候学习一下。

其解释器免费且开源,Ubuntu 下这样安装:

1
2
# 在实验楼的环境中已经安装了 python,可以不必进行此操作
$ sudo apt-get install python

然后只要给 stat_log.py 加上执行权限(使用的命令为 chmod +x stat_log.py)就可以直接运行它。

此程序必须在命令行下加参数执行,直接运行会打印使用说明。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Usage:
  ./stat_log.py /path/to/process.log [PID1] [PID2] ... [-x PID1 [PID2] ... ] [-m] [-g]
Example:
  # Include process 6, 7, 8 and 9 in statistics only. (Unit: tick)
  ./stat_log.py /path/to/process.log 6 7 8 9
  # Exclude process 0 and 1 from statistics. (Unit: tick)
  ./stat_log.py /path/to/process.log -x 0 1
  # Include process 6 and 7 only. (Unit: millisecond)
  ./stat_log.py /path/to/process.log 6 7 -m
  # Include all processes and print a COOL "graphic"! (Unit: tick)
  ./stat_log.py /path/to/process.log -g

运行 ./stat_log.py process.log 0 1 2 3 4 5 -g(只统计 PID 为 0、1、2、3、4 和 5 的进程)的输出示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
(Unit: tick)
Process   Turnaround   Waiting   CPU Burst   I/O Burst
     0           75        67           8           0
     1         2518         0           1        2517
     2           25         4          21           0
     3         3003         0           4        2999
     4         5317         6          51        5260
     5            3         0           3           0
Average:     1823.50     12.83
Throughout: 0.11/s
-----===< COOL GRAPHIC OF SCHEDULER >===-----

            [Symbol]   [Meaning]
         ~~~~~~~~~~~~~~~~~~~~~~~~~~~
             number   PID or tick
              "-"     New or Exit
              "#"       Running
              "|"        Ready
              ":"       Waiting
                    / Running with
              "+" -|     Ready
                    \and/or Waiting

-----===< !!!!!!!!!!!!!!!!!!!!!!!!! >===-----

  40 -0
  41 #0
  42 #
  43 #
  44 #
  45 #
  46 #
  47 #
  48 |0  -1
  49 |   :1  -2
  50 |   :   #2
  51 |   :   #
  52 |   :   #
  53 |   :   #
  54 |   :   #
  55 |   :   #
  56 |   :   #
  57 |   :   #
  58 |   :   #
  59 |   :   #
  60 |   :   #
  61 |   :   #
  62 |   :   #
  63 |   :   #
  64 |   :   |2  -3
  65 |   :   |   #3
  66 |   :   |   #
  67 |   :   |   #
…………

小技巧:如果命令行程序输出过多,可以用 command arguments | morecommand arguments 需要替换为脚本执行的命令)的方式运行,结果会一屏一屏地显示。

“more” 在 Linux 和 Windows 下都有。Linux 下还有一个 “less”,和 “more” 类似,但功能更强,可以上下翻页、搜索。

注:log文件需要知道的一些知识

操作系统-CPU调度

CPU Burst:CPU区间

I/O Burst:I/O区间

CPU调度(CPU scheduling):多个进程同时处于内存,当一个进程必须等待时,OS从该进程拿走CPU使用权交给其他进程。

进程执行从一个**IO区间(I/O burst)开始,随后进入一个CPU区间(CPU burst)**并反复,进程循环地在CPU执行和I/O等待两个状态间切换,直到通过系统请求终止最后一个CPU burst。

image-20220330231348875

CPU burst的长度随进程和计算机的不同而变化,通常具有大量短CPU burst和少量长CPU burst。I/O约束程序通常具有很多短CPU burst,CPU约束程序可能有少量长CPU burst。

每当CPU空闲时,OS就使用短期调度程序(short-term scheduler) (或CPU scheduler)从就绪队列中选择一个能够执行的进程并为它分配CPU。

就绪队列不一定是FIFO队列。就绪队列中的记录通常为PCB。

4.8 修改时间片

时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片,即该进程允许运行的时间,使各个程序从表面上看是同时进行的。如果在时间片结束时进程还在运行,则CPU将被剥夺并分配给另一个进程。如果进程在时间片结束前阻塞或结束,则CPU当即进行切换。而不会造成CPU资源浪费。在宏观上:我们可以同时打开多个应用程序,每个程序并行不悖,同时运行。但在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行。

下面是 0.11 的调度函数 schedule,在文件 kernel/sched.c 中定义为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
while (1) {
    c = -1; next = 0; i = NR_TASKS; p = &task[NR_TASKS];

// 找到 counter 值最大的就绪态进程
    while (--i) {
        if (!*--p)    continue;
        if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
            c = (*p)->counter, next = i;
    }

// 如果有 counter 值大于 0 的就绪态进程,则退出
    if (c) break;

// 如果没有:
// 所有进程的 counter 值除以 2 衰减后再和 priority 值相加,
// 产生新的时间片
    for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
          if (*p) (*p)->counter = ((*p)->counter >> 1) + (*p)->priority;
}

// 切换到 next 进程
switch_to(next);

分析代码可知,0.11 的调度算法是选取 counter 值最大的就绪进程进行调度。

其中运行态进程(即 current)的 counter 数值会随着时钟中断而不断减 1(时钟中断 10ms 一次),所以是一种比较典型的时间片轮转调度算法。

另外,由上面的程序可以看出,当没有 counter 值大于 0 的就绪进程时,要对所有的进程做 (*p)->counter = ((*p)->counter >> 1) + (*p)->priority。其效果是对所有的进程(包括阻塞态进程)都进行 counter 的衰减,并再累加 priority 值。这样,对正被阻塞的进程来说,一个进程在阻塞队列中停留的时间越长,其优先级越大,被分配的时间片也就会越大。

所以总的来说,Linux 0.11 的进程调度是一种综合考虑进程优先级并能动态反馈调整时间片的轮转调度算法。

此处要求实验者对现有的调度算法进行时间片大小的修改,并进行实验验证。

为完成此工作,我们需要知道两件事情:

  • 进程 counter 是如何初始化的
  • 当进程的时间片用完时,被重新赋成何值?

首先回答第一个问题,显然这个值是在 fork() 中设定的。Linux 0.11 的 fork() 会调用 copy_process() 来完成从父进程信息拷贝(所以才称其为 fork),看看 copy_process() 的实现(也在 kernel/fork.c 文件中),会发现其中有下面两条语句:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 用来复制父进程的PCB数据信息,包括 priority 和 counter
*p = *current;

// 初始化 counter
p->counter = p->priority;
// 因为父进程的counter数值已发生变化,而 priority 不会,所以上面的第二句代码将p->counter 设置成 p->priority。
// 每个进程的 priority 都是继承自父亲进程的,除非它自己改变优先级。
// 查找所有的代码,只有一个地方修改过 priority,那就是 nice 系统调用。
int sys_nice(long increment)
{
    if (current->priority-increment>0)
        current->priority -= increment;
    return 0;
}

本实验假定没有人调用过 nice 系统调用,时间片的初值就是进程 0 的 priority,即宏 INIT_TASK 中定义的:

1
2
3
#define INIT_TASK \
    { 0,15,15,
// 上述三个值分别对应 state、counter 和 priority;

接下来回答第二个问题,当就绪进程的 counter 为 0 时,不会被调度(schedule 要选取 counter 最大的,大于 0 的进程),而当所有的就绪态进程的 counter 都变成 0 时,会执行下面的语句:

1
(*p)->counter = ((*p)->counter >> 1) + (*p)->priority;

显然算出的新的 counter 值也等于 priority,即初始时间片的大小。

提示就到这里。如何修改时间片,自己思考、尝试吧。

5.实验步骤

1.修改process.c文件

实验楼在teacher文件夹内提供了process.c文件的模板,另外哈工大git上也有这个文件,在对其进行修改的过程中主要是在main函数内增加一些语句,用 fork() 建立若干个同时运行的子进程,父进程等待所有子进程退出后才退出,每个子进程各自执行 cpuio_bound(),从而实现样本程序。下面贴出process.c更改后的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#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(&current_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;
	}
}

2.修改main.c文件

修改main.c的作用是使得操作系统在启动时就打开log文件,main.c文件在init目录下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
move_to_user_mode();
	/***************自定义代码块--开始***************/
	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();
	}

3.修改printk.c文件

系统在内核状态下只能使用printk函数,下面对printk增加了fprintk函数:(文件位置kernel/printk.c)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#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;
}

4.修改fork.c文件

fork.c文件在kernel目录下,下面做出两处修改:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
int copy_process(int nr,……)
{
    struct task_struct *p;
//    ……
// 获得一个 task_struct 结构体空间
    p = (struct task_struct *) get_free_page();  
//    ……
    p->pid = last_pid;
//    ……
// 设置 start_time 为 jiffies
    p->start_time = jiffies; 
      //新增修改
	fprintk(3,"%d\tN\t%d\n",p->pid,jiffies);   
//       ……
/* 设置进程状态为就绪。所有就绪进程的状态都是
   TASK_RUNNING(0),被全局变量 current 指向的
   是正在运行的进程。*/
    p->state = TASK_RUNNING;    
	//新增修改
	fprintk(3,"%d\tJ\t%d\n",p->pid,jiffies);
    return last_pid;
}

5.修改sched.c文件

文件位置:kernel/sched.c,下面做出两处修改:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void schedule(void)
{
	int i,next,c;
	struct task_struct ** p;
	//    ……
	
			if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) &&
			(*p)->state==TASK_INTERRUPTIBLE)
				{
					(*p)->state=TASK_RUNNING;
					/*新建修改--可中断睡眠 => 就绪*/
					fprintk(3,"%d\tJ\t%d\n",(*p)->pid,jiffies);
				}
	//    ……	
	/*编号为next的进程 运行*/	
	if(current->pid != task[next] ->pid)
	{
		/*新建修改--时间片到时程序 => 就绪*/
		if(current->state == TASK_RUNNING)
			fprintk(3,"%d\tJ\t%d\n",current->pid,jiffies);
		fprintk(3,"%d\tR\t%d\n",task[next]->pid,jiffies);
	}
	switch_to(next);
}

1.修改sys_pause函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int sys_pause(void)
{
	current->state = TASK_INTERRUPTIBLE;
	/*
	*修改--当前进程  运行 => 可中断睡眠
	*/
	if(current->pid != 0)
		fprintk(3,"%d\tW\t%d\n",current->pid,jiffies);
	schedule();
	return 0;
}

2.修改sleep_on函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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;
	/*
	*修改--当前进程进程 => 不可中断睡眠
	*/
	fprintk(3,"%d\tW\t%d\n",current->pid,jiffies);
	schedule();
	if (tmp)
	{
		tmp->state=0;
		/*
		*修改--原等待队列 第一个进程 => 唤醒(就绪)
		*/
		fprintk(3,"%d\tJ\t%d\n",tmp->pid,jiffies);
	}
}

3.修改interruptible_sleep_on函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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;
	/*
	*修改--唤醒队列中间进程,过程中使用Wait
	*/
	fprintk(3,"%d\tW\t%d\n",current->pid,jiffies);
	schedule();
	if (*p && *p != current) {
		(**p).state=0;
		/*
		*修改--当前进程进程 => 可中断睡眠
		*/
		fprintk(3,"%d\tJ\t%d\n",(*p)->pid,jiffies);
		goto repeat;
	}
	*p=NULL;
	if (tmp)
	{
		tmp->state=0;
		/*
		*修改--原等待队列 第一个进程 => 唤醒(就绪)
		*/
		fprintk(3,"%d\tJ\t%d\n",tmp->pid,jiffies);
	}

}

4.修改wake_up函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void wake_up(struct task_struct **p)
{
	if (p && *p) {
		(**p).state=0;
		/*
		*修改--唤醒 最后进入等待序列的 进程
		*/
		fprintk(3,"%d\tJ\t%d\n",(*p)->pid,jiffies);
		*p=NULL;
	}
}

6.修改exit.c文件

此文件的位置在kernel目录下,修改了两处位置,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
int do_exit(long code)
{
	int i;
	free_page_tables(get_base(current->ldt[1]),get_limit(0x0f));
	free_page_tables(get_base(current->ldt[2]),get_limit(0x17));
	//    ……
	
	current->state = TASK_ZOMBIE;
	/*
	*修改--退出一个进程
	*/
	fprintk(3,"%d\tE\t%d\n",current->pid,jiffies);
	current->exit_code = code;
	tell_father(current->father);
	schedule();
	return (-1);	/* just to suppress warnings */
}
//    ……
int sys_waitpid(pid_t pid,unsigned long * stat_addr, int options)
{
	int flag, code;
	struct task_struct ** p;
//    ……
//    ……
	if (flag) {
		if (options & WNOHANG)
			return 0;
		current->state=TASK_INTERRUPTIBLE;
		/*
		*修改--当前进程 => 等待
		*/
		fprintk(3,"%d\tW\t%d\n",current->pid,jiffies);
		schedule();
		if (!(current->signal &= ~(1<<(SIGCHLD-1))))
			goto repeat;
		else
			return -EINTR;
	}
	return -ECHILD;
}

7.make

上述步骤中已经修改了所有的必要文件,直接执行make命令编译内核即可

8.编译运行process.c

将process.c拷贝到linux0.11系统中,这个过程需要挂载一下系统硬盘,挂载拷贝成功之后再卸载硬盘,然后启动模拟器进入系统内编译一下process.c文件,过程命令及截图如下:

1
2
3
4
5
sudo ./mount-hdc 
cp ./process.c ./hdc/usr/root/
sudo umonut hdc
./run
gcc -o process process.c

编译process.c的过程如下:

img

使用./process即可运行目标文件

image-20220330164730736

关闭 bochs 前都要执行一下 sync 命令,它会刷新 cache,确保文件确实写入了磁盘

运行后会生成log文件,生成log文件后将其拷贝到oslab根目录,命令如下:

1
2
3
sudo ./mount-hdc 
cp ./hdc/var/process.log ./
sudo umonut hdc

9.process.log自动化分析

由于默认的python脚本是使用的python2环境,我在Ubuntu上安装的是python3环境,所以对python脚本大概修改了下,直接把print命令更改下,然后有一处的异常处理将逗号更改为as即可,截图如下:

img

由于我的linux使用python的命令是python3,所以python脚本对应的第一行也要更改

https://www.cnblogs.com/fengff/p/11804465.html

image-20220330231933597

把py脚本传输到linux,给 stat_log.py 加上执行权限(使用的命令为 chmod +x stat_log.py

修改了python脚本并确定可以执行之后,使用如下命令执行自动化分析: (需要筛选的进程为执行process显示的Child PID)不输入-g就只会输出开头的表格

1
./stat_log.py process.log 0 1 2 3 4 5 -g

遇到的问题

我执行的时候出不来结果

image-20220330172829778

解决:

image-20220330173417227

我在main中重复执行了两次,==要把init中的代码移出去==

image-20220330173506158

但是还是不行,从打印的文件上来看,就绪状态打印了两次,为什么?

image-20220330225643338

查看之前修改的文件,发现改的代码和示例给的不一样,改的代码忘了用括号把print包起来了 ,这就导致不管状态有没有改变他都会打印,所以上面那个日志就会打印两个一样的,修改之后问题解决

image-20220330230138670

分析结果如下:

img

10.修改时间片

通过分析实验楼给出的schedule调度函数可以知道0.11 的调度算法是选取 counter 值最大的就绪进程进行调度。函数代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
while (1) {
    c = -1; next = 0; i = NR_TASKS; p = &task[NR_TASKS];

// 找到 counter 值最大的就绪态进程
    while (--i) {
        if (!*--p)    continue;
        if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
            c = (*p)->counter, next = i;
    }

// 如果有 counter 值大于 0 的就绪态进程,则退出
    if (c) break;

// 如果没有:
// 所有进程的 counter 值除以 2 衰减后再和 priority 值相加,
// 产生新的时间片
    for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
          if (*p) (*p)->counter = ((*p)->counter >> 1) + (*p)->priority;
}

// 切换到 next 进程
switch_to(next);

找到时间片的定义:

1
2
3
#define INIT_TASK \
    { 0,15,15, 
// 上述三个值分别对应 state、counter 和 priority;

据此注释可以修改时间片

include/linux/sched.h中修改#define INIT_TASK宏定义中counterpriority数值,原始时间片为15,据此可以修改时间片。

1.时间片为10

重复process.log自动化分析步骤得出如下结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Process   Turnaround   Waiting   CPU Burst   I/O Burst
      7         2298        97           0        2200
      8         2319      1687         200         432
      9         2368      2098         270           0
     10         2358      2087         270           0
     11         2347      2076         270           0
     12         2336      2066         270           0
     13         2326      2055         270           0
     14         2315      2044         270           0
     15         2304      2034         270           0
     16         2292      2021         270           0
Average:     2326.30   1826.50
Throughout: 0.41/s

image-20220331102852887

2.时间片为15

重复process.log自动化分析步骤得出如下结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Process   Turnaround   Waiting   CPU Burst   I/O Burst
      7         2247       142           0        2105
      8         2202      1686         200         315
      9         2246      1991         255           0
     10         2230      1975         255           0
     11         2215      1959         255           0
     12         2199      1944         255           0
     13         2183      1928         255           0
     14         2168      1912         255           0
     15         2152      1897         255           0
     16         2137      1881         255           0
Average:     2197.90   1731.50
Throughout: 0.44/s

image-20220331103004507

3.时间片为20

重复process.log自动化分析步骤得出如下结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Process   Turnaround   Waiting   CPU Burst   I/O Burst
      7         2587       187           0        2400
      8         2567      1766         200         600
      9         2608      2308         300           0
     10         2585      2285         300           0
     11         2565      2264         300           0
     12         2544      2244         300           0
     13         2523      2223         300           0
     14         2503      2202         300           0
     15         2482      2182         300           0
     16         2461      2161         300           0
Average:     2542.50   1982.20
Throughout: 0.38/s

image-20220331103108883

问题回答

问题一

单进程编程较于多进程编程要更简单,利用率低,因为单进程是顺序执行的,而多进程编程是同步执行的,需要复杂且灵活的调度算法,充分利用CPU资源,所以情况要复杂得多。在设计多进程编程时,要考虑资源的分配,时间片的分配等达到系统调度的平衡。要综合考虑所有进程的情况以达到最优的并行执行效果。且多进程编程的功能更为强大,且应用范围较于单进程编程更加广泛。

问题二

  • 将时间片变小,进程调度次数变多,系统会使得该进程等待时间变长。
  • 将时间片增大,进程因中断/睡眠而产生的调度次数也增多,等待时间也会变长。
  • 总结:时间片要设置合理,不能过大或者过小。