正如我们知道的,在引入了进程之后,并发执行成为了可能,虽然如果只是单处理机的话,只是表现出了并发执行的特征,本质上同一时间还是只有一个进程在运行。但是即使如此,这种交替执行也是带来了很多好处,比如我们在只有一个处理机的机器上也可以同时听音乐、写文档等。更何况现在具备多处理机的计算机已经随处可见,如果单进程(单线程)反而会不能充分利用处理机的资源。
多进程的引入给我们带来好处的同时,也产生了问题。由于进程之间的交替执行以及不同进程之间执行速度的不确定性会导致一系列问题,比如程序执行的不可再现性(程序多次运行的结果可能是不一样的,这是肯定不能被允许的)。当然如果两个进程八竿子打不着,一点关系也没有,不用相互通信,需要使用的资源之间也没有交集,那就不会有问题,这个毋庸置疑。就比如我们俩只有一台电脑,上面装了LOL,CF这两款游戏,我喜欢玩LOL,你喜欢玩CF,于是我们商量后决定一人玩一个小时,这是没问题的,但是假设我们都喜欢玩LOL,还是一人玩一小时,但是你玩的时候把键位给改了,那轮到我玩的时候我可能会开局一个闪现。 这个例子也不是特别的贴切,但是意在说明假如两个进程之间并不是我们所假设的完全没有关系,也就是有着协作关系,那么他们的交替执行就可能产生问题。
进程之间的协作关系:
进程之间的协作关系有三种:
- 同步: 同步就是说两个进程之间存在时序关系,有先后顺序,就像把大象装进冰箱有三个步骤,有三个进程分别负责完成一个步骤,A进程打开冰箱门,B进程装大象,C进程关闭冰箱门。执行顺序必须是A->B->C。
- 互斥: 互斥很好理解,互相排斥。也就是说两个进程不能同时访问一个资源,比如打印机,当一个进程A占有时,其他进程比如B必须等待A进程释放了打印机这个资源才能执行。 注意:这里虽然也有一个先后问题,但是和同步的先后是有区别的,这里的先后是不确定的,谁先抢到谁就先执行,而同步的先后是确定的,必须是那个顺序。
- 通信: 通信是多个进程之间需要传递一定量的信息。
这里所说的三种协作关系对线程同样适用,线程之间肯定是也有这些关系的,同步、互斥、通信。
临界区与临界资源:
临界资源:某段时间内只允许一个进程使用的资源
临界区: 进程中访问临界资源的那段程序称为临界区
举个例子:
两个进程都需要使用打印机,那么打印机就属于临界资源,两个进程中需要使用打印机的那段程序代码就叫做该进程的临界区。
如果不加限制,也就是两个进程同时使用打印机,那么打印机打印出来的内容将是混在一起的内容,这是不对的。由此可见,对于临界资源的访问,必须加以限制。为此在每个进程进入临界区之前,要对需要访问的资源进行检查,看是否已经被占有,如果正在被使用,那就进入阻塞态,等待资源的释放;如果没有被占有那就进入临界区,并且设置资源正在被使用的标志,防止其他进程来访问这个资源。在临界区之前执行上述检查过程的代码段叫做进入区,同样的在临界区之后用于将资源的标志恢复为未访问的代码段称为退出区。
互斥的实现:
在上面我们说了进程之间的协作关系有同步、互斥和通信。我们先将通信放在一边后面再讲。同步和互斥,我发现大多数的操作系统原理教材都只讲了互斥的实现,却没有同步的实现(好吧,其实我只看过两本,两本都没讲)。为什么呢?我认为同步也算是一种互斥,可以想一下,A进程和B进程,B必须等待A执行完才能执行,这是一种同步关系;A进程和B进程,他们要访问某一临界资源,也就是必须是一个先执行,执行完释放了资源另一个才能执行。简单来说,两个都是实现了一个进程等待另一个进程执行完再执行。也就是说同步和互斥本质上应该是一回事。
互斥的实现方法有硬件方法和软件方法:
- 硬件方法:禁止中断,专用机器指令
- 软件方法:可以是软件自己实现也可以是操作系统来实现。
信号量:
信号量是Dijkstra提出的,这是一种卓有成效的解决进程互斥与同步的方法。
信号量的最初定义是包含一个整型值,和一个等待队列,队列里存放着等待当前资源的进程的PCB的指针(引用,也就是内存地址。信号量只能通过P、V原语操作来访问。
信号量也就是一个数据结构,在C里也就是相当于一个结构体,定义类似如下
struct semaphore{
int value;
struct PCB* queue;
}
P原语可用以下函数代码来描述:
void wait(semaphore s){
s.value--;
if(s.value<0){
block(s.queue);//将当前进程阻塞并放到队列里
}
}
V原语可用一下函数代码来描述:
void signal(semaphore s){
s.value++;
if(s.value<=0){
wakeUp(s.queue);//唤醒一个正在等待的进程
}
}
通过上述代码可以看出:整型值s表示这个信号代表的资源的可用数量,如果这个值为负值则代表当前正在等待这个资源的进程的数量。
P原语表示某个进程要请求某个资源,s.value--,将资源的数量减一,如果减一之后小于0,说明没有资源可以用了,并且把这个进程放入等待队列。大于等于0表示还有资源可以使用。
V原语操作表示某个进程已经使用完了这个资源,s.value++,加一之后如果小于等于0,说明还有进程在等待这个资源,唤醒一个在等待队列中的进程。为什么是小于等于0而不是小于0? s.value++之后,s.value==0,说明在自增之前s.value=-1,也就是还有一个进程在等待,所以要唤醒它。
用信号量解决互斥问题:定义一个信号量mutex,并且令s.value的初值为1,也就表示资源的可用数量为1,同一时间只能有一个进程访问这个资源,也就实现了互斥。比如下面的代码表示了P1进程和P2进程通过mutex信号量实现互斥:
//P1进程
{
P(mutex);
//Do what P1 do
V(mutex);
}
//P2进程
{
P(mutex);
//Do what P2 do
V(mutex);
}
通过上图可以发现,只要设置一个信号量并且另值为1,然后将每个进程包含在一对PV操作里面就可以实现互斥。(再次说明:PV操作都是原语操作)
用信号量解决同步问题:还是设置一个信号量syn但是另初值为0,在先运行的进程的最后加上一个V操作,在后运行的进程的开头加上一个P操作就能实现了两个进程的同步。 比如下面的代码展示了P1进程和P2进程,P2要在P1执行完后才能执行:
//P1
{
//Do what P1do
V(syn);
}
//P2
{
P(syn);
//Do what P2 do
}
可以发现实现同步的时候,PV操作还是成对出现,但是放在了不同的进程中;过程是这样的,假如P2先被调度,执行了P操作,因为syn中s.value值为0,所以P2会被阻塞,进程P1得到调度之后,执行完后执行了V(syn),P2进程就被唤醒了然后得以执行,保证了P2在P1之后;如果P1先被调度那P1肯定就会在P2之前执行了。只是要保证P2不能在P1之前执行就OK了
信号量解决经典进程同步与互斥问题:
生产者-消费者问题:
问题描述:有两组进程共享一个缓冲池,缓冲池的容量为n,每个缓冲区可以容纳一个产品,生产者进程负责生产产品放入缓冲区,消费者进程负责消费产品将产品从缓冲区取出。
问题分析: 缓冲区是临界资源,因为生产者进程和消费者进程都会对其进行访问和修改,因此生产者进程和消费者进程存在互斥关系,因此我们定义一个互斥信号量mutex,初值为1;另外当缓冲区满时,供大于求,生产者会阻塞,所以我们需要设置一个信号量empty,初值为n,代表缓冲区还有多少个空位;当缓冲区为空时,供不应求,消费者要阻塞,因此我们还需要设置一个信号量full,初值为0,代表缓冲区中还有多少个产品可以消费。下面我们就来解决这个问题:
首先定义上面说的三个信号量:
{
semaphore mutex=1;
semaphore empty=n;
semaphore full=0;
}
生产者进程:
void producer(){
while(true){
P(empty);
P(mutex); //这两个P操作不能颠倒,想想问什么
//生产一个产品放入缓冲区
V(full);
V(mutex);
}
}
消费者进程:
void consumer(){
while(true){
P(full);
P(mutex); //这两个P操作不能颠倒,想想问什么
//消费一个产品并从缓冲区中取出
V(empty);
V(mutex);
}
}
两个P操作为什么不能颠倒:P(empty)表示生产者检查缓冲区是否还有空位,如果有在P(mutex)看是否有其他进程在使用缓冲区,如果两个条件都具备,那就可以生产;但是如果还没有检查有没有空位就直接P(mutex)就会产生问题,想象这样一种情况,缓冲区已经满了,上来就P(mutex),然后再P(empty)发现没有空位于是生产者进程阻塞,此时消费者进程得到调度时执行P(mutex)时也会被阻塞,于是两个进程 就卡死了,用一句经典的话说就是--生产者占着茅坑不拉屎。。。用专业术语来说就是死锁。这就是因为资源分配顺序不当导致的死锁。在后面章节有详细说到死锁和相关解决方法。
读者写者问题:
问题描述:一个数据对象若被多个并发进程所共享,且其中一些进程只读取对象的内容,我们称之为“读者”,也就是读进程;另一些进程会修改对象的内容,我们称之为“写者”,也就是写进程。要解决的问题就是实现读者进程和写者进程之间的同步执行。
问题分析:首先读者与写者之间存在互斥关系,读者和写者不能同时运行;读者与读者之间可以同时执行,也就是同一时间可以有多个读者。写者与写者不可以同时执行,也就是同一时间只有一个写者在运行。概括来说就是:
- 读者:读者要读时只需要看有没有写进程在写,如果没有写进程在写就可以读。
- 写者:写者要写时不但要看有没有人在写,还要看有没有人在读。
读者优先:
首先说一个基本每本教材都采用的一个解决方案:这个方案的前提就是读者优先,也就是假设现在有一个读进程在读,然后现在同时又来了一个写进程要求写和一个读进程要求读,那么不管写进程在这个读进程之前到来还是之后到来,写进程都必须等待这两个读进程都读完才能写。这个前提很重要,否则可能很难理解这个方案的代码。 有了这个前提我们就只需要解决“写者与写者”和“写者与第一个读者”之间的互斥,因此设置一个信号量Wmutex,初值为1;那怎么知道某个读者是不是第一个进程呢?我们设置一个全局变量ReaderCount,在某个读者进程得到调度时如果ReaderCount==0,就认为它是第一个读者。因为每个读进程都要对ReaderCount修改(当有一个读者加入时:ReaderCount++;当一个读者退出时:ReaderCount--),因此我们要保证读者进程对ReaderCount的访问是互斥的,所以我们再设置一个信号量Rmutex,初值为1。
定义信号量和全局变量:
{
semaphore WLock=1;
semaphore RLock=1;
int ReaderCount=0;
}
读者进程的代码:
void Reader(){
while(true){
P(Rmutex);
if(ReaderCount==0) P(Wmutex);
ReaderCount++;
V(Rmutex);
...;
//Read
...;
P(Rmutex);
ReaderCount--;
if(ReaderCount==0)V(Wmutex);
V(Rmutex);
}
}
写者进程的代码:
void Writer(){
while(true){
P(Wmutex);
...;
//write;
...;
V(Wmutex);
}
}
从上面的代码我们可以分析出确实是读者优先,看读者进程的代码,当读者进程是第一个读者的时候,if(ReaderCount==0) P(Wmutex); 读者进程执行了P(Wmutex),也就是说此时再有写者来就无法写了,只有等到下一个V(Wmutex)执行后,写者才会有机会写,那什么时候读者会执行V(Wmutex)? if(ReaderCount==0) V(Wmutex); 只有没有读者在读了才会执行V(Wmutex),所以也就是我们之前说的读者优先,只要第一个读进程得到读的机会,它就会为在它之后到来的所有读者进程开后门,让所有读者都能读完才让写者写。
进程通信:
进程间的通信要解决的问题是进程之间信息的交流,信息交流的量有大有小,根据信息量的大小可以分为低级通信和高级通信。我们前面说的进程的同步和互斥实际上算是一种低级通信,信息交流的量比较小,他们仅仅通过信号量交流,通过PV操作对信号量进行修改来通信交流从而实现了互斥与同步。
进程通信的类型:
- 共享存储器系统:就是通过共享某些数据结构或存储区域,多个通过往同一个存储区域写入和读取数据来实现通信。
- 消息传递系统: 使用操作系统提供的一组消息通信原语来实现信息的传递。
- 管道通信:管道算是一种共享文件,Pipe文件,和第一种共享存储器系统有点像,都是往一个地方写和读。但是管道通信有共享存储器系统没有的功能,第一:互斥,它能保证同一时间只有一个进程对管道镀铬写;第二:同步,当管道为空时,读进程必须等待,直到写进程往管道里写了内容;当管道满时,写进程必须等待,直到读进程读了一些数据把写进程唤醒。第三: 双方存在,确保只有双方都存在时,才能通信。