在
OS
中引入进程后,系统中的多道程序可以并发执行,但系统却变得更加复杂,为使进程有序运行,引入了同步机制。在进程之间传送大量数据,也需要利用进程通信工具。这篇文章总结了进程的几种同步方式和进程之间的通信方式。
1. 进程间同步
1.1 基本概念
为避免竞争条件,操作系统需要利用同步机制在并发执行时,保证对临界区的互斥访问。进程同步的解决方案主要有:信号量和管程。
对于同步机制,需要遵循以下四个规则:
- 空闲则入:没有进程在临界区时,任何进程可以进入;
- 忙则等待:有进程在临界区时,其他进程均不能进入临界区;
- 有限等待:等待进入临界区的进程不能无限期等待;
- 让权等待(可选):不能进入临界区的进程,应该释放
CPU
,如转换到阻塞态;
1.2 信号量
信号量机制(semaphore
)是一种协调共享资源访问的方法。信号量由一个变量 semaphore
和两个原子操作组成,信号量只能通过 P
和 V
操作来完成,而且 P
和 V
操作都是原子操作。
将信号量表示如下:
typedef struct {
int value;
struct process_control_block *list;
} semaphore;
相应的 P(wait)
操作和 V(signal)
操作如下实现:
wait(semaphore *S) {
S->value--;
if(S->value < 0) {
block(S->list);
}
}
signal(semaphore *S) {
S->value++;
if(S->value <= 0) {
wakeup(S->list);
}
}
信号量可分为两类:互斥信号量,信号量大小为为 0
或 1
,用来实现进程的互斥访问;资源信号量,信号量大小为资源数,用来表示系统资源数目。
资源信号量
代表资源信号量时,S->value
初值表示系统资源的数目,P
操作意味着进程请求一个资源,于是系统中可分配的资源数减一,如果 S->value < 0
,表示该类资源已分配完毕,因此阻塞该进程,并插入信号量链表 S->list
中。小于 0
时,S->value
的绝对值表示该信号量链表中阻塞的进程数。
V
操作表示进程释放一个资源,于是系统中可分配的资源数加一,如果增加一后仍然 S->value <= 0
,表示该信号量链表中仍然有阻塞的进程,因此调用 wakeup
,将 S->list
中的第一个进程唤醒。
互斥信号量
代表互斥信号量时,S->value
初值为 1
,表示只允许一个进程访问该资源。
利用信号量实现两个进程互斥描述如下:
semaphore mutex = 1;
P() {
wait(mutex);
临界区;
signal(mutex);
}
当 mutex = 1
时,表示两个进程都没有进入临界区,当 mutex = 0
时,表示一个进程进入临界区运行;当 mutex = -1
时,表示一个进程进入临界区运行,另一个进程被阻塞在信号量队列中。
1.3 管程
管程采用面向对象思想,将表示共享资源的数据结构及相关的操作,包括同步机制,都集中并封装到一起。所有进程都只能通过管程间接访问临界资源,而管程只允许一个进程进入并执行操作,从而实现进程互斥。
Monitor monitor_name {
share variable declarations;
condition declarations;
public:
void P1(···) {
···
}
{
initialization code;
}
}
管程中设置了多个条件变量,表示多个进程被阻塞或挂起的条件,条件变量的形式为 condition x, y;
,它也是一种抽象数据类型,每个变量保存了一条链表,记录因该条件而阻塞的进程,与条件变量相关的两个操作:condition.cwait
和 condition.csignal
。
condition.cwait
:正在调用管程的进程因condition
条件需要被阻塞,则调用condition.cwait
将自己插入到condition
的等待队列中,并释放管程。此时其他进程可以使用该管程。condition.csignal
:正在调用管程的进程发现condition
条件发生变化,则调用condition.csignal
唤醒一个因condition
条件而阻塞的进程。如果没有阻塞的进程,则不产生任何结果。
2. 经典同步问题
2.1 生产者-消费者问题
生产者-消费者问题描述的是:生产者和消费者两个线程共享一个公共的固定大小的缓冲区,生产者在生成产品后将产品放入缓冲区;而消费者从缓冲区取出产品进行处理。
它需要保证以下三个问题:
- 在任何时刻只能有一个生产者或消费者访问缓冲区(互斥访问);
- 当缓冲区已满时,生产者不能再放入数据,必须等待消费者取出一个数据(条件同步);
- 而当缓冲区为空时,消费者不能读数据,必须等待生产者放入一个数据(条件同步)。
利用信号量解决
用信号量解决生产者-消费者问题,使用了三个信号量:
- 互斥信号量
mutex
:用来保证生产者和消费者对缓冲区的互斥访问; - 资源信号量
full
:记录已填充的缓冲槽数目; - 资源信号量
empty
:记录空的缓冲槽数目。
#define N 10
int in = 0, out = 0;
item buffer[N];
semaphere mutex = 1, full = 0, empty = N;
void producer(void) {
while(TRUE) {
item nextp = produce_item();
wait(empty);
wait(mutex);
buffer[in] = nextp;
in = (in + 1) % N;
signal(mutex);
signal(full);
}
}
void consumer(void) {
while(TRUE) {
wait(full);
wait(mutex);
item nextc = buffer[out];
out = (out + 1) % N;
signal(mutex);
signal(empty);
consume_item(nextc);
}
}
需要注意的是进程中的多个 wait
操作顺序不能颠倒,否则可能造成死锁。例如在生产者中,当系统中没有空的缓冲槽时,生产者进程的 wait(mutex)
获取了缓冲区的访问权,但 wait(empty)
会阻塞,这样消费者也无法执行。
利用管程解决
利用管程解决时,需要为它们建立一个管程,其中 count
表示缓冲区中已有的产品数目,条件变量 full
和 empty
有 cwait
和 csignal
两个操作,另外还包括两个过程:
put(x)
:生产者将自己生产的产品放入到缓冲区中,而如果count >= N
,表示缓冲区已满,生产者需要等待;get(x)
:消费者从缓冲区中取出一个产品,如果count <= 0
,表示缓冲区为空,消费者应该等待;
Monitor producerconsumer {
item buffer[N];
int in, out;
condition full, emtpy;
int count;
public:
void put(item x) {
if(count >= N) {
cwait(full);
}
buffer[in] = x;
in = (in + 1) % N;
count++;
csignal(emtpy);
}
item get() {
if(count <= 0) {
cwait(emtpy);
}
x = buffer[out];
out = (out + 1) % N;
count--;
csignal(full);
}
{ in = 0; out = 0; count = 0; }
}
于是生产者和消费者可描述为:
void producer() {
while(TRUE) {
item nextp = produce_item();
producerconsumer.put(nextp);
}
}
void consumer() {
while(TRUE) {
item nextc = producerconsumer.get();
consume_item(nextc);
}
}
2.2 哲学家就餐问题
哲学家就餐问题描述的是:有五个哲学家共用一个圆桌,分别坐在周围的五张椅子上,在圆桌上有五个碗和五只筷子,他们交替地进行思考和进餐。哲学家在平时进行思考,在饥饿时试图获取左右两只筷子,拿到两只筷子才能进餐,进餐完后放下筷子继续思考。
为实现筷子的互斥使用,可以用一个信号量表示一只筷子,五个信号量构成信号量数组,也都被初始化为 1
。
semaphore chopstick[5] = {1, 1, 1, 1, 1};
第 i
位哲学家的活动可描述为:
void philosopher(int i) {
while(TRUE) {
wait(chopstick[i]);
wait(chopstick[(i + 1) % 5]);
// eat
signal(chopstick[i]);
signal(chopstick[(i + 1) % 5]);
// think
}
}
上述解法中,如果五位哲学家同时饥饿而都拿起左边的筷子,再试图去拿右边的筷子时,会出现无限期等待而引起死锁。
2.3 读者-写者问题
读者-写者问题描绘的是:一个文件可以被多个进程共享,允许多个 Reader
进程同时读这个文件,但不允许 Wirter
进程和其他 Reader
进程或 Writer
进程同时访问这个文件。所以读者-写者需要保证一个 Writer
进程必须与其他进程互斥地访问共享对象。
解决这个问题需要设置两个互斥信号量和一个整形变量:
- 互斥信号量
wmutext
:实现Reader
进程和Writer
进程在读或写时的互斥; - 整形变量
readcount
:正在读的进程数目; - 互斥信号量
rmutext
:实现多个Reader
进程对readcount
变量的互斥访问;
semaphore rmutex = 1, wmutex = 1;
int readcount = 0;
void Reader() {
while(TRUE) {
wait(rmutex);
if(readcount == 0) {
wait(wmutex);
}
readcount++;
signal(rmutex);
// perform read opertaion
wait(rmutex);
readcount--;
if(readcount == 0) {
signal(wmutex);
}
signal(rmutex);
}
}
void Writer() {
while(TRUE) {
wait(wmutex);
// perform wirte opertaion
signal(wmutex);
}
}
只要有一个 Reader
进程在读,便不允许 Writer
进程去写。所以,仅当 readcount = 0
,表示没有 Reader
进程在读时,Reader
进程才需要执行 wait(wmutex)
操作,而 readcount != 0
时,表示有其他 Reader
进程在读,也就肯定没有 Writer
在写。同理,仅当 readcount = 0
时,才执行 signal(wmutex)
类似。
3. 进程通信
进程通信是指进程之间的信息交换。在进程间要传送大量数据时,应利用高级通信方法。
3.1 共享内存
在共享内存系统中,多个通信的进程共享某些数据结构或存储区,进程之间能够通过这些空间进行通信。
可分为两种类型:
- 基于共享数据结构的通信方式。多个进程共用某些数据结构,实现进程之间的信息交换,例如生产者-消费者问题中的缓冲区。这种方式仅适用于少量的数据,通信效率低下。
- 基于共享存储区的通信方式。在内存中分配一块共享存储区,多个进程可通过对该共享区域的读或写交换信息。通信的进程在通信前,需要先向系统申请共享存储区的一个分区,以便对其中的数据进行读写。
3.2 管道
管道(Pipe
)是指用于连接一个读进程和一个写进程以实现进程间通信的一个共享文件。发送进程以字符形式将数据送入管道,而接收进程则从管道中接收数据。
管道机制提供了三方面的协调能力:
- 互斥:当一个进程对管道执行读或写操作时,其他进程必须等待;
- 同步:当写进程把一定数量的数据写入管道,便睡眠等待,直到读进程取走数据后再把它唤醒;
- 确定对方是否存在,只有确定对方存在才能通信。
3.3 消息传递
消息传递机制中,进程以格式化的消息为单位,将通信的数据封装在消息中,并利用操作系统提供的原语,在进程之间进行消息传递,完成进程间数据交换。
按照实现方式,可分为两类:
- 直接通信方式:发送进程利用操作系统提供的发送原语,直接把消息发送给进程,接收进程则利用接收原语来接收消息;
- 间接通信方式:发送和接收进程,通过共享中间实体方式进行消息的发送和接收,完成进程间的通信。