1、进程间的通信(InterProcess Communication,IPC)
进程之间的通信常分为两种:低级通信、高级通信
- 低级通信:通过信号量实现进程之间的互斥和同步关系。
- 高级通信:管道、共享内存、消息队列等。
1.1 进程间的互斥
- 共享资源:①慢速的硬设备,如打印机;②软件资源,如共享变量、共享文件和各种队列等。
- 临界资源:就是一次仅允许一个进程使用的系统中共享资源。
- 临界区(critical section):就是并发进程访问临界资源的那段必须互斥执行的程序。
避免竞争条件只需要阻止多个进程同时读写共享的数据就可以了,也就是保证同时只有一个进程处于临界区内。
我们可以用《现代操作系统》一书的配图来理解。
1.2 进程间的同步
同步的原因:一组进程要合作完成一项任务。
例如:
两个用户进程共享缓冲区。计算进程将计算结果送入共享缓冲区,打印进程从缓冲区取数据打印。缓冲区空时不取数据,满时不送数据。
由于计算进程与打印进程访问缓冲区的速度不匹配,需要进行同步处理。为了使进程同步,需要引入信号量机制。
1965年,荷兰学者Dijkstra提出的一种同步机制。
基本原理:两个或多个进程通过简单的信号进行合作,一个进程被迫在某一位置停止,直到它接收到一个特定的信号。为了发信号,需要使用一个称作信号量的特殊变量。
typedef struct{ //信号量的类型描述
int value; //表示该类资源的可用数量
struct process *list; //等待使用该类资源的进程排成队列的队列头指针。
}semaphore, sem;
对信号量S的操作只允许执行P、V原语操作
P操作原语: //wait(s) ;
void P (sem &s)
{ s.value = s.value-1; //表示申请一个资源(或通过信号量s接收消息)
if (s.value < 0)
{ add this process to s.list;
block( );
} //资源用完,调用阻塞原语。“让权等待”
}
V操作原语:// signal(s);
Void V (sem &s) {
s.value = s.value+1;
//释放一个资源(或通过信号量s发消息)
if (s.value <= 0) {
remove a process P from s.list;
wakeup( );
}//表示在信号链表中,仍有等待该资源的进程被阻塞。 调用唤醒原语。
}
操作系统正是利用信号量的状态来对进程和资源进行管理的
1.3 利用信号量实现进程之间的互斥
设置一个互斥信号量mutex,初值为1,表示该临界资源空闲。
调用P(mutex)申请临界资源——mutex变为0。
调用V(mutex)释放临界资源——mutex变为1。
只需把临界区代码置于P(mutex)和 V(mutex)之间,就可实现临界资源的互斥使用了。
1.4 生产者、消费者问题
生产者和消费者是相互合作进程关系的一种抽象
生产者:当进程释放一个资源时,可把它看成是该资源的生产者,
消费者:当进程申请使用一个资源时,可把它看成该资源的消费者。
举个简单的例子:
假定有一组生产者和消费者进程,通过一个有界环形缓冲区(有k个缓冲区)发生联系。生产者向缓冲区放产品,消费者从中取产品。
当缓冲区满时,生产者要等消费者取走产品后才能向缓冲区放下一个产品;当缓冲区空时,消费者要等生产者放一个产品入缓冲区后才能从缓冲区取一个产品。
这个环形缓冲区是一个临界资源。互斥使用
//empty:表示空缓冲区的个数,初值为k
//full:有数据的缓冲区个数,初值为0
//mutex:互斥访问临界区的信号量,初值为1
int mutex=1, empty=k, full=0, i=0, j=0;
DataType array[k];
Producer:
…
produce a product x;
P(empty); //申请一个空缓冲
P(mutex); //申请进入缓冲区
array[i] = x; //放入产品
i = (i+1)mod k;
V(full); // 有数据的缓冲区个数加1
V(mutex); //退出缓冲区
…
Consumer:
…
P(full); //申请一个产品
P(mutex); //申请进入缓冲区
y = array[j]; //取产品
j = (j+1)mod k;
V(empty); //释放1个空缓冲
V(mutex); //退出缓冲区
…
2、死锁
在计算机系统中有很多独占性的资源,在任一时刻只能被一个进程使用。如打印机、磁带机。
例如:一个计算机系统,有4台磁带机和2个并发执行的进程。某一时刻,每一进程都已占有2台磁带机,还要再请求一台才能完成它们的任务。这时,由于再无空闲的磁带机,两个进程就处于永远的等待状态,系统产生了死锁。
我们上边讲到的P、V操作,如果使用不当,就会造成死锁的情况。若将生产者进程中的两次P操作交换顺序,
//empty:表示空缓冲区的个数,初值为k
//full:有数据的缓冲区个数,初值为0
//mutex:互斥访问临界区的信号量,初值为1
int mutex=1, empty=k, full=0, i=0, j=0;
DataType array[k];
Producer:
…
produce a product x;
//下面两句发生了互换
P(mutex); //申请进入缓冲区
P(empty); //申请一个空缓冲
array[i] = x; //放入产品
i = (i+1)mod k;
V(full); // 有数据的缓冲区个数加1
V(mutex); //退出缓冲区
…
Consumer:
…
P(full); //申请一个产品
P(mutex); //申请进入缓冲区
y = array[j]; //取产品
j = (j+1)mod k;
V(empty); //释放1个空缓冲
V(mutex); //退出缓冲区
…
- 当缓冲区满时,生成者将在P(empty)上等待,但不释放对缓冲区的互斥使用权。
- 此后,消费者欲取产品时,由于申请使用缓冲区不成功,它将在P(mutex)上等待。
- 相互等待就会造成系统发生死锁现象。
产生死锁的必要条件:
- 互斥条件。独占性的资源。
- 保持和等待条件。进程因请求资源而阻塞时,对已经获得的资源保持不放。
- 不剥夺条件。已分配给进程的资源不能被剥夺,只能由进程自己释放。
- 循环等待条件。存在一个进程循环链,链中每个进程都在等待链中的下一个进程所占用的资源。
产生死锁的根本原因:是对独占资源的共享,并发执行进程的同步关系不当。
解决死锁的常见办法:
- 鸵鸟算法。忽略死锁。
- 死锁的预防。通过破坏产生死锁的四个必要条件中的一个或几个,来防止发生死锁。
- 死锁的避免。是在资源的动态分配过程中,用某种方法去防止系统进入不安全状态,从而避免发生死锁。
- 死锁的检测和恢复。允许死锁发生,通过设置检测机构,及时检测出死锁的发生,然后采取适当措施清除死锁。
3、自旋锁
自旋锁(spin lock)是一种非阻塞锁,也就是说,如果某进程需要获取锁,但该锁已经被其他进程占用时,该进程不会被挂起,而是在不断的消耗CPU的时间,不停的试图获取锁。
上面提到的互斥量(mutex)是阻塞锁,当某进程无法获取锁时,该进程会被直接挂起,该进程不再消耗CPU时间,当其他进程释放锁后,操作系统会激活那个被挂起的进程,让其投入运行。
为什么要使用自旋锁?
互斥锁有一个缺点,他的执行流程是这样的 托管代码 - 用户态代码 - 内核态代码、上下文切换开销与损耗,假如获取到资源锁的进程A立马处理完逻辑释放掉资源锁,如果是采取互斥的方式,那么进程B从没有获取锁到获取锁这个过程中,就要用户态和内核态调度、上下文切换的开销和损耗。所以就有了自旋锁的模式,让进程B就在用户态一直循环等待着(会消耗CPU的时间),看是否能够等到A释放了资源的锁,减少消耗。
自旋锁比较适用于使用者保持锁时间比较短的情况,这种情况下自旋锁的效率要远高于互斥锁。
自旋锁可能潜在的问题:过多占用CPU的资源,如果锁持有者进程A一直长时间的持有锁处理自己的逻辑,那么这个进程B就会一直循环等待过度占用cpu资源。
4、总结
文章中提到的进程,对于线程来说也同样适合。