大学的时候学习操作系统一直对于“进程”这个概念很模糊,没有形成一个系统性的认识,这篇文章主要是结合Linux0.11源码分析进程究竟是什么?以及我们从进程的设计中可以体会出什么编程的思考方式。 主要参考:《深入理解Unix内核》、《Linux内核设计的艺术》、《Unix编程艺术》
进程概念的起源:
解决批处理操作系统的顺序执行问题:
在批处理操作系统中,CPU按照加载程序进入内存的顺序依次取指执行,比如现在有一段程序任务(Job), 可以分为分为A、B、C三个连续的代码块部分:
其中A:重CPU逻辑运算,轻IO;B:重IO,轻CPU逻辑运算;C:重CPU逻辑运算,轻IO;按照原有的批处理程序运行的原则,依次加载A、B、C后,运行到B程序时,因为IO导致CPU长时间处于阻塞态等待外设返回结果,而这造成了CPU资源的极大浪费,并且使得C程序一直处于等待运行状态,当前Job都处于等待外设返回状态。 因此我们需要对Job这个概念进行更细粒度的拆分(进程拆分线程亦是这个思路),我们把当前Job分为三个进程,并实现当CPU因为外设中断而陷入阻塞状态时,CPU可以被切换到执行C程序(进程的调度);
所以进程概念的提出本质上是解决CPU速度与外设IO速度不匹配的问题,用于提高任务执行的效率,并基于进程这个概念Unix实现了多任务处理系统。
接着我们可以按照上述进程出现的原因,它需要解决的问题,由此直观的想象一下进程需要什么:
首先我们解决上述问题是通过进程的切换从而实现CPU运行指令流的变换,因此需要在CPU运行到该进程时提供给CPU该进程对应的程序在内存中的执行地址等CPU执行所需的环境等资源信息,并且进程还需要保存用于进程切换的相关信息,如时间片、优先级等,所以进程 = 程序 + 资源信息,需要注意区分程序与进程的区别。
因此进程的资源信息不应该由程序代码本身来关心,因为这会导致程序的代码结构与进程切换的方式耦合在一起,造成代码的臃肿;所以应该由操作系统负责为每个进程维护其上下文语境并按照统一的进程调度的算法进行切换;
所谓的上下文语境对应操作系统中的数据结构即为PCB(Process Control Block),进程的切换方式在Linux0.11中为基于优先级的时间片轮转(在平均周转时间与响应时间之间的折中得到的算法)。
Linux0.11如何实现进程:
进程的切换算法
何时需要进行进程切换?
当占用CPU的当前进程因为诸如进行了需要CPU进行等待的操作时我们需要进行进程的切换,诸如外设block读入(如bread())等需要CPU等待外设写缓冲区的操作,或者当前进程的程序运行到需要获取锁的代码而未获取到锁而需要CPU等待获取锁的操作...因为进程切换的本质一直是提高CPU的利用率。
切换到哪一个进程上?(相应进程切换算法)
讨论算法前,我们需要先了解下进程切换的算法需要关注的两个点:平均周转时间与进程响应时间。 平均周转时间即为完成每个任务所需的平均时间,响应时间即为进程得到运行并产生反馈的时间。
1、FCFS (FIRST COME FIRST SERVICE)
先来先服务,通过维护进程的等待链表,依次进行进程的切换(即进程come的时间越早,优先级越高);是最公平的切换算法,但是会存在前方都是运行时间比较长的进程时,短作业需要等待长作业运行完成后才可以执行,因此会影响进程的平均周转时间,所以基于此提出了SJF。
2、SJF (SHORT JOB FIRST)
短作业优先即为按照进程所需的运行时间进行排序,从而保证短的作业可以优先完成(即运行时间越短,优先级越高),从而降低了平均周转时间,但是会导致长作业的饥饿效应,因为如果一直有短任务进来,会导致长任务一直无法执行。因此我们需要不仅要从按照时间维度进行进程优先级的划分,还需要折中考虑当前优先级导致的饥饿问题,所以最直观解决饥饿问题的方法是"雨露均沾",每一个进程都会被分配一个时间片相应执行一段时间,即RR。
3、RR (Round-Robin)
为每个进程分配一个相同长度的时间片,从而解决 SJF算法导致的饥饿问题,不过虽然解决了上述问题,但是RR会造成CPU的频繁切换,并且缺乏优先级带来的进程区别性。
因此我们需要将2、3两种算法进行整合,从而进行折中,引申出一个考虑比较完善但又简单的算法(KISS原则在计算机领域非常重要,keep it simple, stupid)。
当我们思考一个问题的解决方案的时候,往往并不能很容易直接找到很完美的解决方法,因此应该是从最直观的角度去审视这个问题,提出最简单的解决方式再寻找当前方式的问题进而一步步的优化,最后再在相互矛盾的问题之间寻找折中之法,因为矛盾是具有普遍性的。
4、优先级动态变化
算法2、3的问题在于一个会导致长任务饥饿(等待时间非常久),另一个会导致缺乏进程之间缺乏优先级,而折中之法即为我们通过进程的等待的时间和初始优先级动态修改进程的优先级,并且优先级越高,则时间片长度越长,反之亦然;而这也是LINUX 0.11采取的进程切换方法(下面会分析具体代码,在此之前需要将如何创建进程)。
进程的创建
按照一、中对于进程需要什么的直观想法,我们需要一个数据结构用于存储CPU执行的相关上下文资源信息以及进程调度算法需要的相关信息来匹配操作系统进行进程的调度;在Linux0.11中,通过task_struct这个结构体充当PCB:
struct task_struct {
/* 进程调度算法需要的相关信息,上面进程的切换时讲到 */
long state; /* 进程状态:-1 unrunnable, 0 runnable, >0 stopped */
long counter;// linux0.11使用的是CPU轮转调度,这个变量用于标识时间片的长度
long priority;// 进程优先级
long signal;
struct sigaction sigaction[32];
long blocked; /* bitmap of masked signals */
....
/* 与当前进程相关的文件资源信息 */
....
struct file * filp[NR_OPEN]; /* 进程打开的文件指针 */
/* ldt for this task 0 - zero 1 - cs 2 - ds&ss */
struct desc_struct ldt[3];
/* tss for this task */
struct tss_struct tss; // tss中存储了CPU执行时对应的所有寄存器的信息,如eip、esp等等
};
因此我们需要通过mem_map(内核内存空间地址映射结构)先申请内存创建PCB数据结构并设置初值以及CPU运行时需要的栈(栈中保存了当前进程运行过程中的局部变量)
union task_union {
struct task_struct task; // 进程对应的PCB
char stack[PAGE_SIZE]; // 进程对应的内核栈(内核态的内存资源不可以通过malloc进行内存的申请,需要使用mem_map内存映射)
};
然后再将PCB结构的指针放入Linux管理进程PCB的task_struct *task[NR_TASKS] 指针数组中,其中NR_TASK即为0.11内核支持的最大进程数64,至此进程便在内核空间中创建成功了;
Linux0.11进程切换实例
接着我们便可以学习Linux0.11中核心的进程切换源码,主要分为两步:1. 找到需要切换到的进程; 2. CPU运行环境及程序序列的切换。
找到需要切换到的进程:
其核心在于Counter变量不仅作为时间片的长度标识,并且以Counter的大小衡量进程优先级的高低,并且动态变化Counter实现进程切换算法4:
void schedule(void)
{
int i,next,c;
struct task_struct ** p; // PCB指针
while (1) {
c = -1;
next = 0; // 用于存储下一个需要运行的进程的标识
i = NR_TASKS; // NR_TASKS为LINUX0.11定义的最大进程数 #define NR_TASKS 64
p = &task[NR_TASKS]; // 存储系统中所有进程的PCB结构的指针数组
/*----------------------------------- 1. 找到下一步要切换到的进程----------------------------------------*/
// 从任务数组的最后一个任务开始循环处理。
while (--i) {
if (!*--p)
// 跳过不含任务的数组槽(NULL)
continue;
// 当前任务为就绪状态并且时间片长度大于当前最大值,则将next指向该任务,最终next会指向时间片长度最大的任务
// 即以counter值的大小作为衡量优先级的手段
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
c = (*p)->counter, next = i;
}
// 如果最终得到的counter的最大值大于0(存在时间片不为零的任务),或者系统中没有一个可运行的任务存在(此时c仍然为-1,next=0,0即为Liunx的初始进程idle),则退出while(1)的循环,执行switch任务切换操作。
if (c) break;
// 如果当前所有就绪进程分配的时间片都已经使用完,则将使用完时间片的进程的counter值置为其优先权值
// 注:此处照顾了因为I/O而一直处在非就绪态的进程,因为其原本的counter并没有被使用完,所以它的counter值会变大,优先级更高
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
if (*p)
(*p)->counter = ((*p)->counter >> 1) +
(*p)->priority;
}
/*----------------------------------- 2. 切换到next任务并执行----------------------------------------*/
switch_to(next);
}
上面的代码主要是为了找到需要切换到的下个进程的PCB指针next,并在最后调用了宏switch_to用来真正切换任务指针current指向任务号为next的任务,并切换到该任务中运行。
CPU运行环境及程序序列的切换(switch_to)
因为进程=程序序列+资源,是故我们要切换程序序列和下一个进程所用到的资源(运行环境)
运行环境主要为与CPU工作直接相关的寄存器信息,如IP寄存器,栈寄存器等等,而Linux0.11 通过一个tss数据结构进行保存,并且将每个进程的tss与该进程的PCB关联:
struct tss_struct {
long back_link; /* 16 high bits zero */
long esp0;
long ss0; /* 16 high bits zero */
long esp1;
long ss1; /* 16 high bits zero */
long esp2;
long ss2; /* 16 high bits zero */
long cr3;
long eip;
long eflags;
long eax,ecx,edx,ebx; // 通用寄存器
long esp; // 栈底位置寄存器
long ebp;
long esi;
long edi;
long es; /* 16 high bits zero */
long cs; // 16bits GDT表选择子,用于找到对应的程序地址
long ss; /* 16 high bits zero */
long ds; /* 16 high bits zero */
long fs; /* 16 high bits zero */
long gs; /* 16 high bits zero */
long ldt; /* 16 high bits zero */
long trace_bitmap; /* bits: trace 0, bitmap 16-31 */
struct i387_struct i387;
};
因此进程运行环境的切换即为PCB中tss的切换,切换时我们首先要保存CPU当前寄存器中的值的快照放入当前进程的tss数据结构中,然后找到下一个切换到的进程的PCB来获取到该进程对应的tss并写入寄存器中;这样便完成了环境的切换,并且通过eip与cs寄存器即可更换CPU即将执行的指令序列;这样进程的切换在宏观上便完成了。
进程的协作(通信)
当我们将一个较为复杂的单体程序以多个进程的角度进行拆分后,所以进程之间的通信与协作就变得尤为重要;进程之间进行通信可以通过多种方式,如 1:公共资源的访问,这个资源可以是队列、文件等; 2:通过网络套接字、外设IO等进行跨进程通信;
公共内存资源的访问
内存分为用户态与内核态,区别在于其需要的访问权限不同,用户态的DPL为3,内核态的DPL为1,数字越小,访问所需的权限越高,内核态的内存只能由操作系统本身访问,DPL主要是用于对操作系统使用的内存空间进行保护。
但是系统调用比较例外,虽然system_call()的代码在内核态中,但是其DPL为3,因此我们可以在用户态内存中通过系统调用来使用操作系统提供的中断服务。
-
用户态内存的访问:多个进程共同访问内存的时候,如果对于多个进程对于内存的访问不加限制,如果程序编写不严谨,会导致多个进程同时访问相同的变量造成冲突;解决这个问题有两种方向:1)对共享变量的访问加以限制 2)对于每个进程分配独立的内存空间;操作系统通过MMU(
Memory Management Unit)实现了多个进程间的资源隔离,类似于Java虚拟机为每个线程分配的TLAB(Thread Local Alloction Buffer),使得各个进程在用户态互不干扰。 -
内核态内存的访问:与用户态相同,通过在创建进程时为每个进程的PCB关联对应的内核栈,实现资源的隔离。
其实本质上还是计算机一直依赖的主要矛盾,时间与空间的转换,要么如上述用空间换时间(处理进程同步的时间),要么用时间换空间(使用信号量的方法对共享空间进行加锁)。
必须要在进程间进行共享的公共资源的访问
诸如外设缓冲等公共资源,我们需要通过信号量进行访问控制。
什么是信号量?
信号量和信号的区别是什么?我们知道进程之间协同工作时需要进行信号的传递,如典型的生产者与消费者问题,当生产者进程生产资源后需要发送信号通知等待的消费者进程进行资源的消费,一般操作系统中的信号传递是通过生产者与消费者共同感知一个共享资源的当前数量值(value)的变化,如当资源数量从无到有时,生产者会调用wake_up(struct task_struct **p)函数唤起等待进程。
但是操作系统中往往会存在多个进程等待一种资源的情况,这种时候简单的剩余资源数量值表达的信息量已经不够,比如说当资源数量已经为0后,生产继续生产了1个资源,但这个时候有多个进程在等待,但是生产者在调用wake_up函数的时候无法通过数量值来获取其需要唤醒的进程的PCB。
因此现在的核心问题在于应该由谁去负责去创建、维护、管理这个信息。我们不经想到了GOF中的观察者模式,由多个消费者作为监听者,生产者作为事件发起者,事件为:生产者从无到有开始生产(value由0变为1),并且由生产者负责维护消费者链表及共享资源的数量值。在Java中这样是可行的,但是在Linux多进程环境下这样做是不可以的,因为C语言每个进程的用户态空间都是使用MMU相互隔离开的,无法像面向对象语言那样可以通过改变对象状态实现消费者的动态变化(注册为监听者,取消监听);所以多个进程想要实现协作必须通过系统调用访问共享的内核空间,因此我们需要在内核中为每组生产者消费者维护共享资源的数量值及进程链表或队列,而这两者合起来即为信号量。
typedef struct {
char name[20]; // 信号量名称
int value; // 信号量对应的共享资源的数量值
task_struct * queue; // 挂载在这个共享资源上的等待进程
} semtable[20];
通过信号量进行控制资源的访问
- 当value值>0时,则当前需要使用的信号量的资源无需进行竞争,直接获取资源后进行value值减1即可。
- 当value值<=0时,则需要将当前进程放入当前信号量的
等待Queue中,并将当前进程状态置为不可被中断的阻塞态;当有新的资源产生时,依次唤醒等待队列中的所有进程(将进程置为就绪态),再通过Schedule()的调度方法进行进程的切换,使得Counter值最大的进程优先获取到资源。
谁来控制对于信号量的访问?
但是信号量本身也是存储在内核态内存中的需要被进程共享的变量,所以我们应该如何对它进行保护?难道还是再嵌套一层信号量吗?所以信号量的访问的安全性不能够再通过程序代码(软件)进行控制,最终是在硬件层面实现了改变信号值相关机器指令的原子性,保证了同时只会有一个进程在修改信号量的value值。
在Linux0.11中则是通过开关中断的方式进行信号量访问的控制,因为信号量存储在内核态内存中,因此用户态代码只可以通过系统调用(依赖中断)进行内核态内存的访问,因此关闭中断后亦可以保证同时只有一个进程访问信号量。但是这并不适合目前的多核CPU架构,因为关中断是通过置标志寄存器中的中断标志位,但是在多核架构中每个CPU都有独立的寄存器,因此当我们在一个CPU上运行了关中断后,另一个CPU上仍然可以进行中断。
PS : 这个问题类似于RSA中颁发CA根证书一样,我们最终得信任一个无法被证实是否是伪造的证书,而为了避免网络的伪造,最基础的CA认证写入了计算机硬件中。