操作系统(三)
6 程序并发
多道程序设计
多道程序设计让多个进程同时进入内存去竞争处理器以获得运行机会
进程的互斥与同步
- 进程互斥:并发进程之间因相互争夺独占性资源而产生的竞争制约关系
- 进程同步:并发进程之间为完成共同任务基于某个条件来协调执行先后关系而产生的协作制约关系
临界区
- 临界资源:互斥共享变量所代表的资源,即一次只能被一个进程使用的资源
- 临界区指并发进程中与互斥共享变量相关的程序段
- 多个并发进程访问临界资源时,存在竞争制约关系
- 如果两个进程同时停留在相关的临界区内,就会出现与时间相关的错误
- 如果两个进程的临界区具有相同的临界资源,它们就是相关的临界区,就必须互斥进入;换句话说,如果两个临界区不相关,进入就没有限制、
- 实现临界区的原子性:关中断;临界区;开中断。操作系统原语就采用这种实现思路。但用户程序不可滥用这种。
信号量
-
原因:关开中断管理临界区,不便交给用户程序使用
-
信号量:对资源的一种抽象
-
申请资源的原语:若申请不得,调用进程入队等待
-
归还资源的原语:若队列中有等待进程,需释放
-
信号量撤销:资源注销,撤销队列
-
// 定义 struct semaphore { int value; // 信号量值,正值表示资源可复用次数,0值表示无资源且无进程等待,负值表示等待队列中进程个数 struct pcb *list; // 信号量等待进程队列指针,每个信号量建立一个等待进程队列 } // P操作原语与V操作原语,分配与释放资源,有些时候是阻塞和唤醒阻塞进程 procedure P(semaphore:s) { s = s – 1; //信号量减去1 if (s < 0) W(s); //若信号量小于0,则调用进程被置成等待信号量s的状态 } procedure V(semaphore:s) { s := s + 1; //信号量加1 if (s <= 0) R(s); //若信号量小于等于0,则释放一个等待信号量s的进程 } // PV操作解决进程互斥问题框架 semaphore s; s = 1; process Pi { …… P(s); 临界区; V(s); …… }
-
管程
-
概念:
- 管程抽象相关并发进程对共享变量访问,以提供一个友善的并发程序设计开发环境
- 管程是由若干公共变量及其说明和所有访问这些变量的过程所组成
- 管程把分散在各个进程中互斥地访问公共变量的那些临界区集中起来管理,管程的局部变量只能由该管程的过程存取
- 进程只能互斥地调用管程中的过程
-
条件变量与同步原语:
-
条件变量(condition variables):当调用管程过程的进程无法运行时,用于阻塞进程的信号量
-
同步原语wait:当一个管程过程发现无法继续时(如发现没有可用资源时),它在某些条件变量上执行wait,这个动作引起调用进程阻塞
-
同步原语signal:用于释放在条件变量上阻塞的进程
-
-
哲学家就餐问题
-
需要保证不会有相邻的两位哲学家同时进餐。
-
信号量处理:
-
var chopstick: array[0,……,4] of semaphore = 1; // 5支筷子分别设置为初始值为1的互斥信号量 process_i{ while(1){ think; wait(chopstick[i]); wait(chopstick[(i+1)mod5]); eat; signal(chopstick[i]); signal(chopstick[(i+1)mod5]); } }
-
死锁:若五位哲学家同时饥饿而各自拿起了左边的筷子,这使五个信号量 chopstick 均为 0,当他们试图去拿起右边的筷子时,都将因无筷子而无限期地等待下去,即会引起死锁
-
改进:
-
// 法一:至多只允许四位哲学家同时去拿左筷子,最终能保证至少有一位哲学家能进餐,并在用完后释放两只筷子供他人使用。 semaphore chopstick[5] = {1,1,1,1,1}; semaphore r = 4; void philosopher(int i){ while(true){ think(); wait(r); wait(chopstick[i]); wait(chopstick[(i+1)mod5]); eat(); signal(chopstick[(i+1)mod5]); signal(chopstick[i]); signal(r); } } // 法二:仅当哲学家的左右手筷子都拿起时才允许进餐。原理:多个临界资源,要么全部分配,要么一个都不分配,因此不会出现死锁的情形。原理:通过互斥信号量 mutex 对 eat() 之前取左侧和右侧筷子的操作进行保护,可以防止死锁的出现。 semaphore chopstick[5] = {1,1,1,1,1}; semaphore mutex = 1; void philosopher(int i){ while(true){ think(); wait(mutex); wait(chopstick[i]); wait(chopstick[(i+1)mod5]); signal(mutex); eat(); signal(chopstick[(i+1)mod5]); signal(chopstick[i]); } } // 法三:规定奇数号哲学家先拿左筷子再拿右筷子,而偶数号哲学家相反。原理:将是 2,3 号哲学家竞争 3 号筷子,4,5 号哲学家竞争 5 号筷子。1 号哲学家不需要竞争。最后总会有一个哲学家能获得两支筷子而进餐。 semaphore chopstick[5] = {1,1,1,1,1}; void philosopher(int i){ while(true){ think(); if(i % 2 == 0){ // 偶数先右后左 wait(chopstick[(i+1)mod5]); wait(chopstick[i]); eat(); signal(chopstick[i]); signal(chopstick[(i+1)mod5]); }else{ // 奇数先左后右 wait(chopstick[i]); wait(chopstick[(i+1)mod5]); eat(); signal(chopstick[(i+1)mod5]); signal(chopstick[i]); } } }
-
-
读者写者问题
-
读者与写者问题(reader-writer problem) (Courtois, 1971)也是一个经典的并发程序设计问题。有两组并发进程:读者和写者,共享一个文件F,使用PV操作求解该问题,要求:
- (1)允许多个读者可同时对文件执行读操作
- (2)只允许一个写者往文件中写
- (3)任意写者在完成写操作之前不允许其他读者或写者工作
- (4)写者执行写操作前,应让已有的写者和读者全部退出
-
semaphore rmutex,wmutex; rmutex=1; wmutex=1; S=1; //增加互斥信号量S int readcount=0; //读进程计数 // 读者优先问题:只要又读进程在读取共享数据,写进程就要一直阻塞等待,这很可能导致写进程一直无法往共享数据中写入数据,也就是说写进程很有可能会被“饿死”。 process reader_i(){ while (true) { P(S); // 不加互斥锁S,则为读者优先;加了,则为写者优先。这里的写者优先也并不是真正的“写优先”,而是遵循相对公平的先来先服务原则。有的也称这种算法为“读写公平法”。 P(rmutex); if (readcount==0) P(wmutex); readcount++; V(rmutex); V(S); 读文件; P(rmutex); readcount--; if(readcount==0) V(wmutex); V(rmutex); } } } process writer_i( ) { while(true) { P(S); P(wmutex); 写文件; V(wmutex); V(S); } } /* 读进程A——>写进程a——>读进程B: 假设读进程A正在访问共享数据,那么读进程A肯定已经执行了P(S)、P(rmutex)、P(wmutex)、V(rmutex)、V(S)。此时,写进程a也想要访问共享数据,那么当写进程a执行P(S)时,不会被阻塞,但是执行到P(wmutex)时,由于读进程A还没有执行V(wmutex)“解锁”操作,所以,写进程a会被阻塞等待。 而如果此时有第二个读进程B也想要访问共享数据,但由于之前第一个写进程a已经执行了P(S)“上锁”操作,所以当读进程B执行到P(S)操作时,也会被堵塞等待。 直到读进程A完成了读文件操作后,执行了V(wmutex)“解锁”操作,写进程a才会被“唤醒”。然后在写进程完成了写文件操作后,执行了V(S)“解锁”操作,读进程B才能被唤醒。 注意:这里为什么会先唤醒写进程a呢? 答:因为这里是写进程比读进程B先想要访问共享数据,所以优先被唤醒。这里其实就是“先来先服务算法”。在这种算法中,连续进入的多个读进程,可以同时读文件;写进程和其他进程不能同时访问文件;写进程不会“饥饿”。但也并不是真正的“写优先”,而是遵循相对公平的先来先服务原则。有的也称这种算法为“读写公平法”。 */
生产者消费者问题
进程通信
管道、命名管道、信号、消息队列、共享内存、内存映射、信号量、socket
-
管道: 速度慢,容量有限,只有父子进程能通讯 ,且半双工,数据只能单向流动
- 管道是 UNIX 系统 IPC(进程间通信)的最古老形式,管道本质其实是内核中维护的一块内存缓冲区,Linux 系统中通过 pipe() 函数创建管道,会生成两个文件描述符,分别对应管道的读端和写端。
- 也叫无名(匿名)管道,只能用于具有亲缘关系的进程间的通信。
-
命名管道(FIFO): 任何进程间都能通讯,但速度慢 ,半双工
- 为了克服匿名管道缺点,有名管道(FIFO)不同于匿名管道之处在于它提供了一个路径名与之关联,以 FIFO 的文件形式存在于文件系统中,并且其打开方式与打开一个普通文件是一样的,这样即使与 FIFO 的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过 FIFO 相互通信。
-
消息队列: 容量受到系统限制,且要注意第一次读的时候,要考虑上一次没有读完数据的问题
- 消息队列是由消息组成的链表,存放在内核中 并由消息队列标识符标识。对消息队列有写权限的进程可以向消息队列中按照一定的规则添加新消息,对消息队列有读权限的进程则可以从消息队列中读走消息,消息队列是随内核持续的。
-
共享内存:
- 共享内存允许两个或者多个进程共享物理内存的同一块区域(通常被称为段)。由于一个共享内存段会称为一个进程用户空间的一部分,因此这种 IPC 机制无需内核介入。所有需要做的就是让一个进程将数据复制进共享内存中,并且这部分数据会对其他所有共享同一个段的进程可用。
- 与管道等要求发送进程将数据从用户空间的缓冲区复制进内核内存和接收进程将数据从内核内存复制进用户空间的缓冲区的做法相比,这种 IPC 技术的速度更快。
- 能够很容易控制容量,速度快,但要保持同步,比如一个进程在写的时候,另一个进程要注意读写的问题,相当于线程中的线程安全,当然,共享内存区同样可以用作线程间通讯,不过没这个必要,线程间本来就已经共享了同一进程内的一块内存
-
信号量: 不能传递复杂消息,只能用来同步
- 信号量主要用来解决进程和线程间并发执行时的同步问题,进程同步是并发进程为了完成共同任务采用某个条件来协调它们的活动。对信号量的操作分为 P 操作和 V 操作,P 操作是将信号量的值减 1,V 操作是将信号量的值加 1。当信号量的值小于等于 0 之后,再进行 P 操作时,当前进程或线程会被阻塞,直到另一个进程或线程执行了 V 操作将信号量的值增加到大于 0 之时。
-
Socket套接字:
- 就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。Socket 一般用于网络中不同主机上的进程之间的通信。
线程通信
线程与同属一个进程的其他的线程共享进程所拥有的全部资源,因此主要目的是用于线程同步,所以线程没有象进程通信中用于数据交换的通信机制。
锁机制、信号量、信号
1、锁机制
1.1 互斥锁:提供了以排它方式阻止数据结构被并发修改的方法。
1.2 读写锁:允许多个线程同时读共享数据,而对写操作互斥。
1.3 条件变量:可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
2、信号量机制:包括无名线程信号量与有名线程信号量
3、信号机制:类似于进程间的信号处理。信号用于通知接收进程某一事件已经发生。
死锁
-
原因:进程请求资源得不到满足而等待时,不释放已占有的资源。导致进程间形成了循环等待链,其中,每个进程都在链中等待下一个进程所持有的资源,造成这组进程永远等待
-
死锁产生条件:互斥、占有等待、不剥夺、循环等待
-
避免死锁:打破死锁产生条件
- 银行家算法
- 系统首先检查申请者对资源的最大需求量,如果现存的资源可以满足它的最大需求量时,就满足当前的申请
- 换言之,仅仅在申请者可能无条件地归还它所申请的全部资源时,才分配资源给它
- 使用定时锁,使用lock.tryLock(timeout)来代替使用内部锁机制
- 银行家算法