在学习本章前,务必要清楚以下概念:
并发会在以下三种不同的上下文出现:
多应用程序:多道程序设计技术允许在多个活动的应用程序间动态共享处理器时间
结构化应用程序:作为模块化设计和结构化程序设计的扩展,一些应用程序可以被有效的设计成一组并发进程
操作系统结构
以上三种情况均会出现多个进程同时在一个处理机上的情况
原子操作:一个函数或动作由一个或多个指令的序列实现,对外是不可见的;也就是说,没有其他进程可以看到其中间状态或者中断此操作。要保证指令的序列要么作为一个组来执行,要么都不执行,对系统状态没有可见的影响。原子性保证了并发进程的隔离。
临界区:是一段代码,在这段代码中进程将访问共享资源,当另外一个进程已经在这段代码中运行时,这个进程就不能在这段代码中执行。
死锁:两个或两个以上的进程因其中的每个进程都在等待其他进程做完事情而不能继续执行,这种情形称为死锁。
活锁:两个或两个以上进程为了响应其他进程中的变化而持续改变自己的状态但不做有用的工作,这种情形称为活锁。
互斥:当一个进程在临界区访问共享资源时,其他进程不能进入该临界区访问任何共享资源,这种情形称为互斥。
竞争条件:多个线程或者进程在读写一个共享数据时,结果依赖于它们执行的相对时间,这种情形称为互斥条件。
饥饿:是指一个可运行的进程尽管能继续执行,但被调度程序无限期地忽视。而不能被调度执行地情形。
5.1并发的原理
在单处理器多道程序设计系统中,进程被交替执行,由于进程的相对执行速度不可预测,因此会造成共享资源的不确定性。
因此本节内容主要介绍如何实现互斥从而合理的控制进程。
5.2互斥:硬件的支持
这里主要介绍中断禁用的方法:
在单处理器机器中,并发进程不能重叠,只能交替。此外,一个进程将一直运行,直到它调用了一个系统服务或被中断。因此为了保证互斥,只需要保证一个进程不被中断就可以了,这种能力可以通过系统内核为启用和禁用中断定义的原语来实现。
while(true){
/*禁用中断*/;
/* 临界区*/;
/*启用终端*/;
/*其余部分*/;
}
由于临界区不能被中断,故可以保证互斥。但是该方法的缺点是代价非常高,由于处理器被限制于只能交替执行程序,因此执行的效率将会有明显的降低。另一个方法是不适用于多处理器结构,当一个计算机系统包括多个处理器时,就有可能有一个以上的进程同时执行,此时,禁用中断时不能保持互斥的。
还有一种方法是使用专用的机器指令实现互斥,这里不予赘述
5.3信号量
信号量:用于进程间传递信号的一个整数值。在信号量上只有三种操作可以进行:初始化、递减和增加,这三种操作都是原子操作。递减操作可以阻塞一个进程,增加操作可以用于解除阻塞一个进程。
5.3.1整形信号量
信号量的初值可以理解为系统中某种资源的数量,例如:系统中只有一台打印机,就可以设置信号量的初值为1。
如上例子所示,我们可以写如下代码:
int S=1;
void wait(int S){
while(S<=0);
S--;
}
void signal(int S){
S++;
}
如果有一个进程p0想要使用打印机这个资源,需要执行如下步骤:
...
wait(S);
使用打印机;
signal(S);
...
初始时S等于1,所以执行到wait原语时不会被陷入循环中,执行S--,此时S=0。wait原语执行完毕,进程p0使用打印机..
倘若在进程P0使用打印机时,又有一个进程P1想要使用打印机,此时进程P1进入wait原语,发现S=0,陷入循环,直到p0使用完打印机,执行Signal原语使得S++,S恢复为1,P1的wait原语跳出循环,进而使用打印机。
5.3.2记录型信号量
记录型信号量的定义如下:
struct semaphore{
int count; //表示可用资源数量
queueType queue; //阻塞队列
};
记录型信号量的wait和signal操作如下
void wait(semaphore s){
s.count--;
if(s.count<0){
block(s,queue);
/*如果剩余资源数不够,block原语就会使进程从运行态变为阻塞态加入到阻塞队列中*/
}
}
void signal(semaphore s){
s.count++;
if(s.count<=0){
wakeup(s,queue);
/*即当一个进程使用完临界资源后可用进程数小于等于零说明有进程处于阻塞队列中
则唤醒阻塞队列中优先级高的进程*/
}
}
5.4信号量实现互斥与同步
在上一节我们了解了wait操作和signal操作,在此后的篇章我们分别使用P来代替wait和V来代替signal。
5.4.1信号量实现互斥(Mutual Exclusion)
互斥:当一个进程在临界区访问共享资源时,其他进程不能进入该临界区访问任何共享资源,这种情形称为互斥。
信号量实现互斥分为如下四步:
1.划定临界区
没什么好说的,例如将访问打印机资源的代码放到临界区
2.设置互斥信号量mutex,初值为该资源可用的个数
3.在进入P(mutex)时申请资源
4.在进入V(mutex)时释放资源
代码实例如下:
semaphore mutex=1;//表示只有一台打印机
P1(){
...
P(mutex);
//临界区
V(mutex);
...
}
P2(){
...
P(mutex);
//临界区
V(mutex);
}
注意:PV操作必须成对出现,否则无法实现互斥
5.4.2信号量实现同步(Synchronization)
同步:进程之间需要在执行的某个位置上协调工作次序、传递信息所产生的一种制约关系。
举一个例子加深这种概念:
有两个进程的如下:
P1(){
/*代码1*/
/*代码2*/
/*代码3*/
}
P2(){
/*代码4*/
/*代码5*/
/*代码6*/
}
当它们在系统中并发的执行时,由于系统的环境很复杂,因此当系统在调度时,有可能是P1先上处理机运行,也有可能是P2先上处理机运行。
咱们先假设P2先上处理机运行,运行了代码四和代码五,此时时间片用完,P2切换为就绪态,处理机运行P1,P1运行代码一和代码二,接下来又切换为P2,....
由于这两个进程在系统中是并发执行的,因此它们之间的代码执行先后顺序是我们不可预知的,而我们有时必须让代码按照我们想按照的方式运行。比如我们要求代码四必须在代码一和代码二执行后才能执行。这也就是我们所说的进程同步问题。
可以使用如下的代码实现:
semaphore s=0;
P1(){
/*代码1*/
/*代码2*/
V(S);
/*代码3*/
}
P2(){
P(S);
/*代码4*/
/*代码5*/
/*代码6*/
}
注意:先V后P,即在先执行的代码后执行V操作,在后执行的代码前执行P操作。
5.5管程(monitor)
引入:为什么要有管程?因为在使用信号量是容易出错
管程是一种特殊的软件模块,主要由以下部分组成:
1.局部于管程的共享数据结构说明;
2.对该数据结构进行操作的一组函数;
3.对局部于管程的共享数据结构设置初始值的语句;
4.管程有一个名字;
管程的基本特征:
1.局部于管程的数据只能被局部于管程的过程所访问;
2.一个进程只有通过调用管程内的函数才能进入管程访问共享数据;
3.每次仅允许一个进程在管程内执行某个内部过程;(从而实现互斥)
举例:用管程解决生产者消费者问题
monitor ProduceConsumer
condition full,empty;//条件变量用来实现同步(排队)
int count=0;
void insert(Item item){
if(count==N)
wait(full);
count++;
insert_item(item);
if(count==1)
signal(empty);
}
Item remove(){
if(count==0)
wait(empty);
count--;
if(count==N-1)
signal(full);
return remove_item();
}
end monitor
//生产者进程
produce(){
while(1){
item=生产一个产品;
ProducerConsumer.insert(item);
}
}
//消费者进程
consumer(){
while(1){
item=ProcucerConsumer.remove();
消费产品item;
}
}
使用管程时无需考虑函数如何实现,只需要用就可以了。
ps:在上述描述的生产者/消费者问题中,后续作者会开一个篇章专门讲解类似的各种问题~.
本章内容到此结束~~~