这部分主要是复习并发:死锁和饥饿
1、可重用资源 可消耗资源
可重用资源是指一次只能供一个进程安全地使用,并且不会由于使用而耗尽的资源。进程得到资源,后来又释放这些资源单元,供其他进程再次使用。可重用资源例子包括:处理器、I/O通道、内存和外存、设备以及诸如文件、数据库和信号量之类的数据结构。
可消耗资源是指可以被创建(生产)和销毁(消耗)的资源。当消费进程得到一个资源时,该资源就不再存在了。可消耗资源的例子有中断、信号、消息和I/O缓冲区中的信息。
2、死锁的必要条件
互斥:一次只有一个进程可以使用一个资源,其他进程不能访问已经分配给其他进程的资源
占有且等待:当一个进程在等待其他进程时,继续占有已经分配的资源
不可抢占:不能强行抢占进程已占有的资源
循环等待:存在封闭的进程链,使得每个进程都占有此链中下一个进程所需要的一个资源
有三种方法可以处理死锁。第一种方法是采用某种策略来消除上述四个条件中的一个条件的出现来预防死锁;第二种方法是基于资源分配的当前状态做动态的选择来避免死锁;第三种方法是试图检测死锁(满足1至4)的存在并且试图从死锁中恢复出来。
死锁预防
简单讲,死锁预防策略是试图设计一种系统来排除发生死锁的可能性。方法分为两大类:一种是间接的死锁预防方法,即防止前面列出的三个必要条件中的任何一个发生;一种是直接的死锁预防方法,即防止循环等待的发生。
死锁避免
在死锁预防中,通过约束资源请求,防止4个条件中至少一个的发生,可以通过直接或间接预防方法,但是这都会导致低效的资源使用和低效的进程执行。
死锁避免则相反,它允许前三个必要条件,但是通过明智的选择,确保永远不会到达死锁点,因此死锁避免比死锁预防允许更多的并发。在死锁避免中,是否允许当前资源分配请求是通过判断该请求是否可能导致死锁来决定的。因此,死锁避免需要知道将来的进程资源请求的情况。
死锁避免策略并不能确切的预测死锁,它仅仅是预测死锁的可能性并确保永远不会出现这种可能性。
有两种死锁避免的办法:
- 如果一个进程的请求会导致死锁,则不启动此进程
- 如果一个进程增加的资源请求会导致死锁,则不允许次分配
- 把资源分成几组不同的资源类
- 为预防在资源类之间由于循环等待产生死锁,可使用线性排序策略
- 在一个资源类中,使用该类资源最适合的算法
- 可交换空间:在进程交换中所使用的外存中的存储块。对于可交换空间,通过要求一次性分配所有请求的资源来预防死锁
- 进程资源:可分配设备,如磁带设备和文件。 对于这类资源,死锁避免策略常常很有效,因为进程可以事先声明他们将需要的这类资源,采用资源排序的预防策略也是可能的
- 内存:可以按页或按段分配给进程。对于内存,基于抢占的预防是最合适的策略。当一个进程被抢占后,它仅仅是被换到外存,释放空间以解决死锁
- 内部资源:诸如I/O通道。可以使用基于资源排序的预防策略
解决这个问题有个办法是在拿起筷子前先判断左右两个筷子是否可用,可用才能拿,而且是同时拿,这样不相邻的哲学家就可以吃上饭,不会造成死锁。
semaphore fork[5]={1};
semaphore room={4};
int i;
void philosoper(int i)
{
while(true){
think();
wait(room);
wait(fork[i]);
wait(fork[(i+1)mod 5]);
eat();
siginal(fork[(i+1)mod 5]);
siginal(fork[i]);
siginal(room);
}
}
void main()
{
parbrgin(philosper(0),philosoper(1),philosoper(2),philosoper(3),philosoper(4));
}
(2)使用管程解决
和信号量不同的是,管程不会发生死锁,因为在同一时刻只有一个进程进入管程。使用管程的方案定义了一个含有5个条件变量的向量,每把叉子对应一个条件变量。这些条件变量用来标识哲学家等待的叉子的可用情况,每一个布尔向量记录每把叉子的使用状态(true标识叉子可用)。
monitor dining_controller;
cond ForkReady[5];
boolean fork[5]={true};
void get_forks(int pid)
{
int left=pid;
int right=(++pid)%5;
if(!fork(left))
cwait(ForkReady[left]);//queue on condition variable
fork[left]=false;
if(!fork(right))
cwait(ForkReady(right));
fork[right]=false;
}
void release_forks(int pid)
{
int left=pid;
int right=(++pid)%5;
if(empty(ForkReady[left]))
fork[left]=true;
else //awaken a process waiting on this fork
csiginal(ForkReady[left]);
if(empty(ForkReady[right]))
fork[right]=true;
else
csiginal(ForkReady[right]);
}
void philosoper[k=0 to 4]
{
while(true){
<think>'
get_forks(k);
<eat>;
release_forks(k);
}
}
5、UNIX的并发机制
UNIX为进程间的通信和同步提供了各种机制。管道、消息和共享内存提供了进程间传递数据的方法,而信号量和信号则用于其他进程的触发行为。
(1)管道:管道是一个环形缓冲区,允许两个进程以生产者、消费者的模式进行通信,这是一个先进先出队列,由一个进程写,而另一个进程读。
管道在创建的时候获得一个固定大小的字节数。当一个进程试图往管道中写时,要有足够的空间写请求才能被立即执行,否则该进程会被阻塞。类似的读进程也是这样。操作系统强制实施互斥,即一次只能有一个进程访问管道。
有两大类管道:命名管道和匿名管道。只有有父子关系(血缘关系)的进程才可以共享匿名管道,而不相关的进程只能共享命名管道。
(2)消息:消息是有类型的一段文本。UNIX系统为参与消息传递的进程提供msgsnd和msgrcv系统调用。每个进程都有一个与之关联的消息队列,其功能类似于信箱。
消息发送者指定发送的每个消息的类型,类型可以被消息接收者用作选择的依据。接收者可以按先进先出的顺序接收,或者按类型接收。
(3)共享内存:共享内存是UNIX提供的进程间通信手段中速度最快的一种。这是虚存中由多个进程共享的一个公共内存块。每一个进程有一个只读或只写的权限。互斥约束不属于共享内存机制的一部分,但必须由使用共享内存的进程提供。
(4)信号量:信号量(semaphore)是一个计数器,用于多进程对共享数据的访问。
为了获得共享资源,进程需要执行下列操作:
a. 测试控制资源的信号量。
b. 若此信号量的值为正,则进程可以使用该资源。进程将信号量值减1,表示它使用了一个资源单位。
c. 若此信号量的值为0,则进程进入休眠状态,直至信号量值大于0。进程被唤醒后,它返回执行第a步。
当进程不再使用此共享资源时,该信号量值增1。(返还一个资源单位) 如果有进程正在休眠等待此信号量,则唤醒它们。
为了正确地实现信号量,信号量值的测试及减1操作应当是原子操作。为此,信号量通常是在内核中实现的。
(5)信号:信号是用于向一个进程通知发生异步事件的机制。信号类似于硬件中断,但没有优先级,即内核平等的对待所有信号。对于同时发生的信号,一次只给进程一个信号,而没有特定的次序。
6、Linux内核并发机制
Linux包含了在其他UNIX系统中出现的所有并发机制,其中包括管道、消息、共享内存和信号。除此之外,Linux还包含了特别为内核态线程准备的并发机制。换言之,他们是用在内核中的并发机制,为内核代码提供执行时的并发性。
(1)原子操作
原子操作在执行时不会被打断或者干涉。在单处理器中,一旦启动原子操作,从开始到结束的时间内,线程不能被中断。在多处理器系统中,原子操作所针对的变量是锁住的,以免被其他进程所访问,知道原子操作的结束。
(2)自旋锁
在Linux中保护临界区最常见的技术是自旋锁。在同一时刻,只有一个线程能获得自旋锁。其他企图获得自旋锁的任何线程将一直进行尝试(即自旋),直到获得了该锁。
本质上,自旋锁建立在内存区中的一个整数上,任何线程在进去临界区之前都必须检查该整数。如果该值为0,则线程设置它为1,然后进入临界区。如果该值非0,则线程继续检查该值。自旋锁很容易实现,但是缺点是在锁外面的线程以忙等待的方式继续进行。
读写自旋锁机制允许在内核中实现比基本自旋锁更高的并发度。读写自旋锁允许多个线程同时以只读的方式访问同一个数据结构,只有当一个线程想要更新数据结构时,才会互斥地访问该自旋锁。相对于写者而言,读写自旋锁对读者更有利,如果自旋锁被读者拥有,那么写者就不能抢占该锁,新来的读者比已经等待的写者会抢先获得该自旋锁。
(3)信号量
在用户层上,Linux提供了和UNIX对应的信号量接口。在内核内部,Linux提供了供自己使用的信号量具体实现,即在内核中的代码中能够调用内核信号量。内核信号量比用户可见的通过系统调用被用户程序访问的信号量更加高效。
Linux在内核中提供了三种信号量:二元信号量(也叫互斥信号量)、计数信号量、读写信号量(实际上对于读者使用的是一个计数信号量,对写者使用的是一个二元信号量)。
(4)屏障
在一些体系中,编译器或者处理器为了优化性能,可能会对源代码中的内存访问重新排序。重新排序是为了优化对处理器指令流水线的使用。但是在某些情况下,读操作和写操作以指定的顺序执行是相当重要的。为了保证指令执行的顺序,Linux提供了内存屏障设施。
比如在rmb()操作保证了代码中在rmb()以前的代码没有任何读操作会穿过屏障;相似的,wmb()操作保证了在代码wmb()以前的代码没有任何写操作会穿过屏障;mb()操作提供了装载和存储屏障。