多进程图像

本文详细介绍了操作系统如何支持多进程,包括进程设计思路、进程状态转换及实际的进程切换案例。通过Linux0.11内核,阐述了fork函数创建进程、schedule函数调度进程以及进程状态图。在进程切换中,使用了时间片机制,并通过修改PC指针和保存恢复进程状态来实现代换。同时,讨论了进程的不同状态,如就绪态、阻塞态和运行态等。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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

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->tss.esp = esp;
	p->tss.ss = ss & 0xffff;
...
//6、将子进程设置为就绪态,然后父进程返回
	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.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();
}

从do_timer()可以看出,Linux0.11中采用了时间片的机制来切换进程,即:给每个进程分配一个时间片,若时间片用完了则调用schedule()函数。schedule()会重新分配各个进程的时间片,并在 task 队列中找到下一个需要运行的进程,然后调用switch_to。switch_to将当前进程的寄存器状态保存起来(保存在当前进程的 tss 中),然后将下一个进程的tss中的内容扣在CPU的寄存器中(包括PC指针)从而实现了进程的切换。关于schedule()的详细注释可以参考实验4:基于内核栈切换的进程切换

3.3 进程状态转换图

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

图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.4 如何执行我们自己的代码

子进程在被创建之后默认执行的是父进程的代码,通常子进程可以调用execve()来加载执行自己的代码。execve()是一个系统调用,真正实现其功能的是do_execve()函数,这两个函数的原型如下:

int execve(const char * filename, char ** argv, char ** envp);
int do_execve(unsigned long * eip,long tmp,char * filename,char ** argv, char ** envp)

应用程序调用 execve() 函数,可以进入内核。当一个任务进入内核态运行时,就会使用其 TSS 段给出的特权级0的堆栈指针:tss.ss0、tss.esp0,即内核栈。 也就是说进程进入内核态后,硬件自动帮进程由用户栈切换到内核栈

和其他系统调用一样,execve()也是通过压栈的方式向内核传递参数。从调用execve(),到执行do_execve()前,内核栈的被压入了如下内容:

图3.3 执行exevc函数后的压栈情况

可以看出此时的内核栈中有子进程的PC指针和用户栈指针。为了能让子进程能去执行新的程序,do_execve()会替换掉栈中的PC指针和用户栈指针,将原来的PC指针替换为新执行程序的运行地址。当中断返回,执行“iret”指令后,栈中的PC指针和用户栈指针就会被弹出,并赋值到对应的寄存器中,从而让子进程切换到新的程序去执行。

关于内核栈和用户栈之间切换的方式在《Linux内核完全剖析——基于0.12内核》的第5.8节:Linux系统中堆栈的使用方法,中有详细介绍。

参考资料

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

[1] 操作系统-哈尔滨工业大学-中国大学MOOC
[2] 哈工大操作系统实验手册
[3] Linux内核完全剖析——基于0.12内核

### 使用多进程进行图像处理 在Python中,`multiprocessing`库使得利用多核CPU资源变得简单有效。对于计算密集型任务如图像处理而言,使用多进程可以显著提升性能[^2]。 #### 图像处理的多进程实现方法 为了展示如何应用多进程技术于图像处理领域,这里给一个简单的例子——批量调整片尺寸: ```python from PIL import Image import os from multiprocessing import Pool, cpu_count def resize_image(image_path): """Resize an image to a fixed size.""" try: img = Image.open(image_path) resized_img = img.resize((100, 100)) output_file_name = f"resized_{os.path.basename(image_path)}" resized_img.save(output_file_name) print(f"{image_path} -> {output_file_name}") except Exception as e: print(f"Failed to process file {image_path}: {e}") if __name__ == "__main__": images_dir = "./images" files = [os.path.join(images_dir, f) for f in os.listdir(images_dir)] # Create a pool of worker processes. with Pool(cpu_count()) as p: p.map(resize_image, files) ``` 上述代码片段展示了怎样创建一个多进程池来并发执行图像缩放操作。通过调用`Pool.cpu_count()`自动检测机器上的可用核心数,并据此配置工作线程的数量。接着,使用`p.map()`函数将待处理的任务分配给各个子进程去完成。 #### 多进程的优势与注意事项 采用多进程方式的主要优势在于能够充分利用现代计算机系统的硬件加速能力,特别是当面对大量独立的数据集时效果尤为明显。然而,在实际项目开发过程中需要注意几点事项: - 数据共享机制:不同进程间默认情况下不共享内存空间,如果需要跨进程传递数据,则需借助队列或其他形式的消息传递接口。 - 资源竞争问题:尽管各进程拥有各自的地址空间,但在某些场景下仍可能现文件锁争抢等问题,应采取适当措施加以规避。 - 开销成本评估:启动新的操作系统进程会带来额外的时间开销,因此要权衡好任务粒度大小以及预期收益之间的关系。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值