进程运行轨迹的跟踪与统计

编写多进程的样本程序

基于模板 process.c 编写多进程的样本程序,实现如下功能:

  • 所有子进程都并行运行,每个子进程的实际运行时间一般不超过 30 秒
  • 父进程向标准输出打印所有子进程的 id ,并在所有子进程都退出后才退出

基于 cpuio_bound 函数编写样本程序:

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
#define CPID_NUM 4
int main(int argc, char * argv[])
{
int cpid_list[CPID_NUM];
int i;

/* child process 1 */
cpid_list[0] = fork();
if (cpid_list[0] < 0) {
printf("Fail to fork child process 1\n");
return -1;
} else if (cpid_list[0] == 0) {
cpuio_bound(10, 1, 0); /* 10 times for CPU only */
_exit(0);
}

/* child process 2 */
cpid_list[1] = fork();
if (cpid_list[1] < 0) {
printf("Fail to fork child process 2\n");
return -1;
} else if (cpid_list[1] == 0) {
cpuio_bound(10, 0, 1); /* 10 times for I/O only */
_exit(0);
}

/* child process 3 */
cpid_list[2] = fork();
if (cpid_list[2] < 0) {
printf("Fail to fork child process 3\n");
return -1;
} else if (cpid_list[2] == 0) {
cpuio_bound(10, 1, 1); /* 1 time for CPU & I/O once */
_exit(0);
}

/* child process 4 */
cpid_list[3] = fork();
if (cpid_list[3] < 0) {
printf("Fail to fork child process 4\n");
return -1;
} else if (cpid_list[3] == 0) {
cpuio_bound(10, 1, 9); /* 9 times I/O more than CPU */
_exit(0);
}

for (i = 0; i < CPID_NUM; i++) {
waitpid(cpid_list[i], NULL, 0);
printf("Child process %d(pid = %d) finish\n", i+1, cpid_list[i]);
}
printf("Father process(pid = %d) finish\n", getpid());
return 0;
}

程序使用系统调用 fork() 创建四个子进程并将其 pid 返回至父进程创建数组 cpid_list 中对应位置处,四个子进程分别调用不同参数的 cpuio_bound 函数并使用系统调用 _exit(0) 退出,父进程依次使用系统调用 waitpid() 等待四个进程退出,最后打印相关信息并退出。相关系统调用函数定义如下:

void _exit(int status);
pid_t waitpid(pid_t pid,int * wait_stat,int options);

进程运行轨迹的跟踪

在 Linux 0.11 上实现进程运行轨迹的跟踪。基本任务是在内核中维护一个日志文件 /var/process.log ,把从操作系统启动到系统关机过程中所有进程的运行轨迹都记录在这一 log 文件中。

日志文件 /var/process.log

为记录进程 1 由系统调用 fork() 被创建开始的运行轨迹,在进程 0 中打开日志文件 /var/process.log 并将其与文件描述符 3 关联,故将原先由进程 1 执行的函数 init() 中相关代码迁移至进程 0 进入用户模式后、执行系统调用 fork() 创建进程 1 前,部分代码如下:

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();
}

系统调用函数 sys_open() 定义如下:

int sys_open(const char * filename,int flag,int mode)

以只写方式 (O_WRONLY) 创建 (O_CREAT) 所有人可读可写 (0666) 的文件 /var/process.log,若文件已存在则清空已有内容 (O_TRUNC)。其中 0666 是多种宏的组合,相关宏被定义在文件 /include/sys/stat.h 中:

1
2
3
4
5
6
#define S_IRUSR 00400       /* 用户可读 */
#define S_IWUSR 00200 /* 用户可写 */
#define S_IRGRP 00040 /* 用户组可读 */
#define S_IWGRP 00020 /* 用户组可写 */
#define S_IROTH 00004 /* 其余用户可读 */
#define S_IWOTH 00002 /* 其余用户可写 */

使用函数 fprintk() 在进程状态改变时,向日志文件 /var/process.log 输出信息。代码放入文件 /kernel/printk.c 中,并将函数声明添加至头文件 /include/linux/kernel.h 中。

进程状态切换

进程状态切换相关函数定义在 /kernel/fork.c、/kernel/sched.c、/kernel/exit.c 中,分别对应进程的创建、调度和终止。
文件 fork.c 中状态切换:
系统调用函数 sys_fork() 通过调用函数 copy_process() 复制父进程信息至子进程中并设置子进程状态,涉及到子进程的新建 (N)、进入就绪态 (J):

1
2
3
4
5
6
7
8
9
10
int copy_process(...)
{
......
p->start_time = jiffies;
fprintk(3, "%ld\t%c\t%ld\n", p->pid, 'N', jiffies);
......
p->state = TASK_RUNNING; /* do this last, just in case */
fprintk(3, "%ld\t%c\t%ld\n", p->pid, 'J', jiffies);
return last_pid;
}

文件 sched.c 中状态切换:
调度函数 schedule() 首先将任务队列中处于可中断阻塞态 (TASK_INTERRUPTIBLE) 的进程设置为就绪态,随后遍历任务队列执行调度算法获得目标进程,最终通过伪函数 switch_to() 切换至目标进程,涉及到任务队列中进程进入就绪态 (J)、当前进程进入就绪态(J)、目标进程进入运行态 (R):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void schedule(void)
{
......
if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) &&
(*p)->state==TASK_INTERRUPTIBLE) {
(*p)->state=TASK_RUNNING;
fprintk(3, "%ld\t%c\t%ld\n", (*p)->pid, 'J', jiffies);
}
......
while (1) {
......
}
if (current->pid != task[next]->pid) {
if (current->pid != 0 && current->state == TASK_RUNNING) {
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'J', jiffies);
}
if (task[next]->pid != 0) {
fprintk(3, "%ld\t%c\t%ld\n", task[next]->pid, 'R', jiffies);
}
}
switch_to(next);
}
  • 需注意,因为当前进程可能经系统调用 sys_pause()、sys_waitpid()、函数 (interruptible_)sleep_on() 转换为阻塞态后调用函数 schedule(),故不能武断的认为当前进程状态 (current->state) 为运行态 (TASK_RUNNING),进而直接向日志文件中输出日志 current->pid, J, jiffies。同时目标进程可能依旧为当前进程,表现为当前进程状态不发生改变,故在打印日志时需提前判断。

系统调用函数 sys_pause() 将当前任务状态转换为可中断阻塞态 (TASK_INTERRUPTIBLE),随后调用函数 schedule() 执行进程切换,涉及到当前进程进入阻塞态 (W):

1
2
3
4
5
6
7
8
9
int sys_pause(void)
{
current->state = TASK_INTERRUPTIBLE;
if (current->pid != 0) {
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'W', jiffies);
}
schedule();
return 0;
}
  • 由于进程 0 涉及到 pause() 系统调用,为避免输出进程 0 相关日志信息,在调用函数 fprintk() 前需判断当前是否处于进程 0。

不可中断睡眠函数 sleep_on() 将睡眠任务队列头指针 (*P) 存储在当前进程堆栈帧的 tmp 变量中,并更新头指针使其指向当前进程,随后将当前进程状态切换为不可中断阻塞态 (TASK_UNINTERRUPTIBLE) 并调用函数 schedule() 执行进程切换。等待至某一进程对当前睡眠任务队列执行唤醒函数 wake_up() 将该队列首部进程状态切换至就绪态 (TASK_RUNNING) 并在某一时刻调度至该进程运行时,会将 tmp 变量指向进程状态切换至就绪态 (TASK_RUNNING),期待在未来某一时刻调度运行。该函数涉及到当前进程进入阻塞态 (W)、队列下一进程 tmp 进入就绪态 (J):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void sleep_on(struct task_struct **p)
{
......
tmp = *p;
*p = current;
current->state = TASK_UNINTERRUPTIBLE;
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'W', jiffies);
schedule();
---------------------------------------------------------
if (tmp) {
tmp->state=0; /* 0 is TASK_RUNNING */
fprintk(3, "%ld\t%c\t%ld\n", tmp->pid, 'J', jiffies);
}
}

可中断睡眠函数 interruptible_sleep_on() 将当前进程状态切换为可中断阻塞态 (TASK_INTERRUPTIBLE),这导致该任务可通过信号、任务超时等手段唤醒。然而调度函数 schedule() 在唤醒时遍历所有进程,故可能出现不在睡眠队列首部的进程被提前唤醒,因此在执行该进程时需再次将其阻塞并切换至其他进程,同时将该队列首部进程状态转换为就绪态 (TASK_RUNNING) 唤醒 (repeat 部分)。该函数涉及到当前进程进入阻塞态 (W)、乱序唤醒时队列首部进程进入就绪态 (J)、队列下一进程 tmp 进入就绪态 (J):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void interruptible_sleep_on(struct task_struct **p)
{
......
tmp=*p;
*p=current;
---------------------------------------------------------
repeat: current->state = TASK_INTERRUPTIBLE;
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'W', jiffies);
schedule();
if (*p && *p != current) {
(**p).state=0; /* 0 is TASK_RUNNING */
fprintk(3, "%ld\t%c\t%ld\n", (*p)->pid, 'J', jiffies);
goto repeat;
}
---------------------------------------------------------
*p=NULL;
if (tmp) {
tmp->state=0; /* 0 is TASK_RUNNING */
fprintk(3, "%ld\t%c\t%ld\n", tmp->pid, 'J', jiffies);
}
}

唤醒函数 wake_up() 将睡眠队列首部进程状态转换为就绪态 (TASK_RUNNING) 唤醒,涉及到队列首部进程进入就绪态 (J):

1
2
3
4
5
6
7
8
void wake_up(struct task_struct **p)
{
if (p && *p) {
(**p).state=0;
fprintk(3, "%ld\t%c\t%ld\n", (*p)->pid, 'J', jiffies);
*p=NULL;
}
}

如图 1,多个进程等待同一资源时会形成隐式任务等待队列,通过堆栈中的变量 tmp 连接前后进程。该队列在每次调用函数 (interruptible_)sleep_on() 时不断更新头指针,在调用函数 wake_up() (也在函数 interruptible_sleep_on() 中) 唤醒时由头到尾依次更新下一进程状态为就绪态 (TASK_RUNNING)。该数据结构与其称为队列,更确切的说法应该是满足后进先出性质的任务栈,唤醒时类似多米诺骨牌依次又必然唤醒每个进程。

图1

文件 exit.c 中状态切换:
系统调用函数 sys_waitpid() 将当前进程设置可中断阻塞态 (TASK_INTERRUPTIBLE) 并调用函数 schedule() 执行进程切换,涉及到当前进程进入阻塞态 (W):

1
2
3
4
5
6
7
8
int sys_waitpid(pid_t pid,unsigned long * stat_addr, int options)
{
......
current->state=TASK_INTERRUPTIBLE;
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'W', jiffies);
schedule();
......
}

系统调用函数 sys_exit() 调用函数 do_exit() 释放当前进程资源并退出,涉及到当前进程退出 (E):

1
2
3
4
5
6
7
8
9
10
int do_exit(long code)
{
......
current->state = TASK_ZOMBIE;
fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'E', jiffies);
......
tell_father(current->father);
schedule();
......
}

运行样本程序并分析日志文件

在修改过的 0.11 上运行样本程序,通过分析 log 文件,统计该程序建立的所有进程的等待时间、完成时间(周转时间)和运行时间,然后计算平均等待时间,平均完成时间和吞吐量。可以自己编写统计程序,也可以使用 python 脚本程序 stat_log.py 进行统计。

运行样本程序

样本程序 process 的运行结果如图 2 所示,父进程向标准输出打印所有子进程的 id ,依次等待四个子进程退出后才退出:

图2

分析日志文件

日志文件 /var/process.log 的内容如图 3 所示,其与实验网站中预期进程运行轨迹基本一致:

图3

使用脚本进行统计

使用 python 脚本程序 stat_log.py 统计平均等待时间、平均完成时间和吞吐量,结果如图 4 所示:

图4

修改时间片

修改 0.11 进程调度的时间片,然后再运行同样的样本程序,统计同样的时间数据,和原有的情况对比,体会不同时间片带来的差异。
设置时间片为 5 时结果如图 5、6 所示:

图5
图6

设置时间片为 25 时结果如图 7、8 所示:

图7
图8

不同时间片对应着不同的进程运行轨迹,包括从进程 1 开始到进程 4 (shell) 等待过程中细微的差异,可以通过日志反向模拟进程的调度轨迹;同样的,测试程序涉及进程 (6~10) 的运行轨迹、等待时间、完成时间等都与时间片的设置息息相关。*这里引用一篇笔记 https://zhuanlan.zhihu.com/p/9525740855 在多轮调试下得到的结论,碰巧测试程序 process.c 的编写高度相似:*

1
2
3
4
5
6
7
8
9
10
11
12
13
可以观察到一些有意思的东西:

进程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,从图上可以观察到平均周转时间最短,不愧是大佬的设定。

在时间片被设置为 15 的条件下,我们来玩弄一下系统调用 nice():
独占 cpu 的子进程 1 十分气愤,自己不需要 I/O 完全可以一直运行,但是为了顾全大局,被迫定期切换至其他进程。压抑过久的他某天决定要疯一把:“让我吃满 cpu,包 Carry 的好吧!”,于是有了发癫版的 process_pro.c:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(int argc, char * argv[])
{
......
/* child process 1 */
cpid_list[0] = fork();
if (cpid_list[0] < 0) {
printf("Fail to fork child process 1\n");
return -1;
} else if (cpid_list[0] == 0) {
nice(-150);
cpuio_bound(10, 1, 0); /* 10 times for CPU only */
_exit(0);
}
......
}

然而:

图9
可以看到,狂吃三路的进程 13 (process_pro 中子进程 1) 接管了 cpu 并迅速完成了自己的任务,然而其他子进程 (14~16) 每次调度回自己执行前,都被进程 13 刷光了时间资源,最终反而不如原 process 程序。

时间片的设置是敏感的,很难把握其最佳数值:独占 cpu 的进程自然希望拥有更多的时间片;经常使用 I/O 的进程希望每次 I/O 响应时都能快速的调度回当前进程执行,如何平衡不同性质进程对时间资源的利用对操作系统调度有着重要的影响,同时恶意进程也可能通过设置其时间片为无穷大使系统崩溃,这是一门高深的学问。

相关问题

结合自己的体会,谈谈从程序设计者的角度看,单进程编程和多进程编程最大的区别是什么?
多进程程序的编写要考虑各个子进程对应的任务,对于拥有相关联任务的进程,不仅有互斥与同步方面的要求,更有对共享资源的利用 – 特别是时间资源。并发进程的临界区不仅存在于高级语言角度,更在汇编级命令中有所体现,而由于调度函数的不确定性,使得临界区的调试和问题复现都有着很大的难度,并发编程在人类视角下是巨大的挑战。
你是如何修改时间片的?仅针对样本程序建立的进程,在修改时间片前后, log 文件的统计结果(不包括Graphic)都是什么样?结合你的修改分析一下为什么会这样变化,或者为什么没变化?
时间片的修改只需改变在 /include/kernel/sched.h 文件中所定义宏 INIT_TASK:
#define INIT_TASK
{ 0,15,15, //分别对应 state counter 和 priority;
……
}
不同时间片的 log 文件分析已在上文中体现。