0
点赞
收藏
分享

微信扫一扫

多进程图像

夏侯居坤叶叔尘 2022-03-19 阅读 80

多进程图像

所谓多进程图像就是:多道程序,交替执行。本章主要介绍操作系统为支持多进程图像做了哪些工作。

1 多进程设计

CPU 作为计算机最关键的设备,使用好 CPU 自然而然成为了操作系统的重中之重。CPU 是一个“取指执行”的设备(设置好 PC 指针的初值,然后每次执行一条指令CPU会让“PC加1”)。如果CPU只需要处理单个任务(单进程),那么一条一条的“取指执行”是毫无问题的,因为就算遇到了 IO 指令,那也没办法,只能等着,不能跳过。

那如果有多个任务需要处理呢?当一个进程需要等待资源(如等待磁盘数据),可不可以切换到其他进程呢?这样才能提高CPU的使用率。操作系统支持多进程图像的设计由此开始。

2 一个大概的设计思路

让 CPU 切换到另一个进程去执行,可以通过修改 PC 指针实现。可是怎么切回来呢?为了保证切回来时是接着上次的状态继续执行,因此切换前,应该先记录好切换前进程的“样子”(包括切换前各个寄存器的值,进程执行的状态等),然后再修改PC 指针。Linux0.11 设计了一个结构体:struct task_struct{...},用于记录进程的“样子”,每个进程都有一个该结构体的对象——PCB(进程控制块)。

如何选择下一个要运行的进程呢?一个简单又实用的办法就是将利用队列,队列里面放的就是所有进程的PCB指针,然后用先进先出的方式安排下一个要运行的进程。Linux0.11 中设计了一个这样的队列 task :

struct task_struct * task[NR_TASKS] = {&(init_task.task), };     //定义任务指针数组

Linux0.11中编写了 schedule 函数用来选择下一个要运行的进程,并且在schedule中调用了 switch_to 函数实现切换到下一个进程的功能。task 队列中有各种状态的进程的PCB,所以在switch_to 函数切换前可以先判断一下,下一个要切换到的进程是不是阻塞态的,如果是的话就先跳过。

谁来执行切换的工作呢?其实也很好猜,因为进程要不断且快速的切来切去,才能让用户感觉所有任务都不卡,因此用定时器中断来切换再合适不过,此外也可以在当进程阻塞的时候就直接切出去。在 Linux0.11 中定义了一个 do_time 函数,该函数在 timer_interrupt(系统时钟中断,每10ms发生一次时钟中断)中 被调用的。do_time 函数最后调用了 schedule函数。

前面提到了阻塞态。一个进程在其生命周期内,可以存在多个状态,当进程在内核执行时需要读磁盘,此时进程会要进入阻塞态,等待资源;当进程等到资源时,就可以进入就绪态了;如果之后进程抢到了CPU,那么进程又进入了运行态。因此可以设计一个进程状态图,用来描述进程的各种状态之间的转换关系。在struct task_struct中就有一个成员变量 state,用于记录进程当前的运行状态。

最后还有一个问题,并发时如何保证各个进程不相互干扰?比如说进程1执行了mov [100], ax
,而内存地址[100]处恰好有进程2存放的重要数据,如果让进程1执行了该指令,那进程2的重要数据就被破坏了。一种办法是将各个进程的地址空间分离开来,比如进程3、进程4都调用了mov [200], ax,那就把进程3的[200]映射到物理内存的 7000H 处,而将进程4的[200]映射到物理内存的 8000H 处。利用映射表(实际上也就是MMU)将各个进程的地址空间分离。这部分属于操作系统内存管理的部分,之后再分析。

3 一个实际的进程切换案例

本节主要分析Linux0.11中进程切换的过程。Linux中使用PCB来描述一个进程,实际上PCB就是一个结构体对象,下面列出了本节会用的的该结构体的几个重要字段:

struct task_struct {
	long state;   //进程当前运行状态,有TASK_RUNNING(就绪态)、TASK_INTERRUPTIBLE等几种取值
	long counter; //任务运行时间计数,即运行时间片。采用递减方式,counter越大表明任务已经运行的时间越短
	long priority;//运行优先数,用于给counter赋初值。一个进程刚被创建时counter = priority。
...
}

3.1 进程的创建 - fork函数

调用 fork() 时会创建一个子进程,因此分析进程切换应该从 fork() 开始。 fork() 的执行过程如下:

  1. fork()内执行int 0x80指令,进入内核
  2. 执行system_call:程序(汇编程序)
  3. 执行sys_fork:程序(汇编程序)
  4. 执行copy_process()函数(C程序)

copy_process()才是真正创建子进程的地方。"sys_fork:"程序调用"copy_process()"是汇编调用C函数的过程,copy_process()中的那一大堆形参都是通过在汇编程序中压栈传递的,可以看出在copy_process()前面的汇编程序将许多寄存器进行了压栈。copy_process()的工作内容如下(程序内容进行了裁剪):

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;
//1、为子进程的PCB分配空间,注意这里分配了一页内存(4KB),
//其实这里连同内核栈的空间一起分配了
	p = (struct task_struct *) get_free_page();
	if (!p)
		return -EAGAIN;
//2、将子进程加入到总调度队列中。nr指向了task[]中的一个空位置
	task[nr] = p;
//3、将父进程(current:当前进程)的PCB复制给子进程,然后修改子进程PCB的部分字段
	*p = *current;	/* NOTE! this doesn't copy the supervisor stack */
	                //这样做不会复制堆栈部分,只复制结构体
	p->state = TASK_UNINTERRUPTIBLE;
	p->pid = last_pid;/* last_pid是最新进程号,也就是子进程的pid */
	p->father = current->pid;
	p->counter = p->priority;
...
//4、子进程内核栈的栈顶指针指向"p"这一页内存的最高处(地址最大处)
	p->tss.esp0 = PAGE_SIZE + (long) p;
	p->tss.ss0 = 0x10;  //0x10为内核数据段的选择符
	p->tss.eip = eip;/* 这里的eip是在执行 int 0x80 压入的eip,也就是说
                        子进程在下次被调度执行的的时候(也就是第一次被调度的时候),是从 int 0x80 
                        后面一句指令开始执行的,而不是从copy_process()开始执行*/
	p->tss.eflags = eflags;
	p->tss.eax = 0;/* 子进程fork()完后返回0的原因所在*/
...
//5、将子进程设置为就绪态,然后父进程返回
	p->state = TASK_RUNNING;	/* do this last, just in case */
	return last_pid;/* return会让返回值(last_pid)保存在eax中。这里是父进程在fork()完后要返回的子进程的pid。
                       那么子进程fork()完后要返回的0在哪里返回的呢?在 _syscall0(int,fork) 函数的那个return返回。*/
}

在执行完copy_process()后,子进程的内核栈就被创建成了如下模样:

图3.1 内核栈

图3.1 内核栈

3.2 进程的切换 - schedule函数

本节主要分析定时器中的进程切换,即do_time()中的进程切换。其实schedule()除了在do_time()中被调用外,在其他地方也有被调用。
Linux0.11中有一个定时器中断,每10ms进入一次,在这个中断中调用了do_time()。这个定时器中断主要做了如下工作:

timer_interrupt:
...
#1、让jiffies加1。jiffies为全局变量,表示从开机时到现在发生的时钟中断次数,这个数也被称为“滴答数”。
	incl jiffies
...
#2、调用do_timer(), 其中eax为do_timer()的传入参数,当前特权级。
	movl CS(%esp),%eax
	andl $3,%eax		# %eax is CPL (0 or 3, 0=supervisor)
	pushl %eax
	call do_timer		# 'do_timer(long CPL)' does everything from
...

do_timer()主要工作如下:

void do_timer(long cpl)
{
...
#1、当前任务运行计数值减1,若计数值不为0(即时间片还未用完),则继续运行当前线程。
	if ((--current->counter)>0) return;
	current->counter=0;
	if (!cpl) return;#若是内核代码则不进行调度,因为内核代码不参与调度。
#2、若时间片用完了,则进行调度,切换到下一个任务
	schedule();
}

最后schedule()会重新分配各个进程的时间片,并在 task 队列中找到下一个需要运行的进程,然后调用switch_to。switch_to将当前进程的寄存器状态保存起来(保存在当前进程的 tss 中),然后将下一个进程的tss中的内容扣在CPU的寄存器中(包括PC指针)从而实现了进程的切换。

3.3 进程状态转换图

本节主要介绍几个与状态切换相关的函数。进程状态图如下:

图3.2 进程状态转换图

图3.2 进程状态转换图

顺便贴上一个进程状态表:

内核表示含义
TASK_RUNNING可运行(就绪态或运行态)
TASK_INTERRUPTIBLE可中断的等待状态,是阻塞态的一种
TASK_UNINTERRUPTIBLE不可中断的等待状态,是阻塞态的一种
TASK_ZOMBIE僵尸态(图中的终止态)
TASK_STOPPED暂停

下面列出几个有改变进程状态功能的函数,帮助理解进程状态转换图:

  1. do_exit():由sys_exit()函数调用。会将当前进程置为僵尸态,然后调用schedule()切换到下一个进程;
  2. sys_waitpid():回收子进程,若子进程还未变为僵尸态,则该函数会将当前进程变为阻塞态(TASK_INTERRUPTIBLE),然后调用schedule()切换到下一个进程;
  3. copy_process():创建子进程,创建前子进程为新建态,创建结束后会将子进程设置为就绪态;
  4. schedule():调度函数。首先进行信号处理,可能会将一些阻塞态的进程变为就绪态。然后找到下一个需要运行的进程,并执行它(此时该进程就变为运行态了);
  5. sys_pause():将当前进程变为阻塞态(TASK_INTERRUPTIBLE),然后调用schedule()切换到下一个进程;
  6. wake_up():将进程置为就绪态;

参考资料

图3.2 进程状态图截取自哈工大操作系统课程的课件。

[1] 操作系统-哈尔滨工业大学-中国大学MOOC (https://www.icourse163.org/course/HIT-1002531008)
[2] 哈工大操作系统实验手册 (https://hoverwinter.gitbooks.io/hit-oslab-manual/content/index.html)
[3] Linux内核完全剖析——基于0.12内核

举报

相关推荐

Python 多进程

python多进程

python 多进程

多进程开发

多进程-进程-开发板

0 条评论