进程经常需要与其他进程通信。例如,在一个shell管道中,第一个进程的输出必须传送给第二个进程,这样沿着管道传递下去。
进程间通信的三个问题:
- 一个进程怎么将信息传递给另一个。
- 确保两个或更多的进程在关键活动不会出现交叉。(两个进程同时访问同一块内存)。
- 进程之间正确的顺序(进程A产生数据而进程B打印数据,则B在打印之前必须等待)。
1 竞争条件
在一些操作系统中,协作的进程可能共享一些彼此都能读写的公用储存区。这个公用储存区可能在内存中(可能在内核数据结构中),也可能是一个共享文件。考虑下面一个假脱机打印程序:
假脱机目录中有许多槽位,编号分别为0,1,2,…,每个槽位存放一个文件名。同时假设有两个共享变量:out,指向下一个要打印的文件;in,指向目录中下一个空闲槽位。可以把这两个变量保存在一个所有进程都能访问的文件中,该文件长度为2个字节。可能发生以下情况:进程A读到in的值为7,将7存在一个局部变量中,此时发生一次时钟中断,CPU认为进程A已经运行了足够长的时间,决定切换到进程B,此时进程B读到in的值也是7,于是7也存在进程B的局部变量中,继续运行,将文件存到槽位7并将in更新为8,此时轮到进程A运行,其读取局部变量是7,于是又在槽位7存入文件,那么进程B存在槽位7的文件将丢失。
类似于这种情况,两个或多个程序读写某些共享数据,而最后的结果取决于进程运行的精确时序,称为竞争条件
Murphy法则:任何可能出错的地方终将出错。
2 临界区
凡涉及共享内存,共享文件以及共享任何资源的情况都会引发与前面类似的错误,要避免这种错误,关键是要找出某种途径来阻止多个进程同时读写共享的数据。需要一种互斥,即以某种手段确保一个进程在使用一个共享变量或文件时,其他进程不能做相同的操作。
对内存进行访问的程序片段叫做临界区域或临界区,如果可以适当地安排,使得两个进程不可能同时处于临界区中,就能避免竞争条件。
在避免竞争条件时,还要保证使用共享数据的并发进程能够正确和高效地进行协作。一个好的解决方案要满足4个条件:
- 任何两个进程不能同时处于其临界区。
- 不应对CPU的速度和数量做任何假设。
- 临界区外运行的进程不得阻塞其他进程。
- 不得使程序无限期等待进入临界区。
从抽象角度看,进程的行为最好如下图所示:
3 忙等待的互斥
下面的方案中,当一个进程在临界区中更新共享内存时,其他进程不会进入起临界区,也不会带来任何麻烦。
屏蔽中断
在单处理器系统中,可以使每个进程在刚刚进入临界区后立即屏蔽所有中断,并在就要离开之前再打开中断。屏蔽中断之后,时钟中断也会屏蔽。CPU只有在发生时钟中断或其他中断时才会发生进程切换。这样在屏蔽终端之后CPU不会切换到其它线程。所以在一个进程检查和修改共享内存时,不用担心其他进程进入。但这种做法会有其缺点:
- 把屏蔽中断的权力交给用户是不明智的。如何一个进程在屏蔽中断之后不在打开中断会使整个系统终止。
- 如果系统是多处理器,那么屏蔽中断只有对执行disable的那个CPU有效,其他CPU也可以访问该共享内存。
- 对内核来说,当它在更新变量或列表的几条指令期间将中断屏蔽是很方便的。当就绪进程队列之类的数据状态不一致时发生中断,则会导致竞争条件。屏蔽中断对操作系统本身很有用,但是对用户进程不合适。
锁变量
设想有一个共享(锁)变量,其初始值为0。当一个进程想进入其临界区时,它首先检测这把锁。如果该锁的值为0,则该进程将其设置为1并进入临界区。若这把锁的值为1,则该进程将等待知道其值变为0。于是0就表示临界区没有进程,1表示已经有某个进程进入临界区。
但是如果进程A读出锁变量为0并且在其将该锁变量置为1之前,进程B运行读取该锁变量,也为0,进程B将其置为1并进入临界区,接着进程A运行,也进入临界区,这时就会出现问题。
严格轮换法
程序如下,整型变量turn,初始值为0,用于记录哪个进程进入临界区,并检查和更新共享内存。开始时,进程a检查turn,发现其值为0,进入临界区。进程1也发现其值为0,所以在一个等待循环不断测试turn,直到其变为1。连续测试一个变量知道某个值出现为止,称为忙等待。应该避免这种方式。
while(TRUE)//进程a
{
while(turn != 0);//循环
critical_region();//进入临界区
turn = 1;
noncritical_region();//退出临界区
}
while(TRUE)//进程b
{
while(turn != 1);//循环
critical_region();//进入临界区
turn = 0;
noncritical_region();//退出临界区
}
该算法的缺点:
- 当进程a被一个临界区之外的进程阻塞时,它会因为进程b在做其他的事情而阻塞。违反了前述的条件3.
它要求两个进程轮流进入它们的临界区。
4.Peterson算法
#define FALSE 0
#define TRUE 1
#define N 2 //进程数量
int turn; //现在轮到谁
int interested[N]; //所有值初始化为0(FALSE)
void enter_region(int process)//进程是0或1
{
int other;//其他进程号
other = 1 - process;//另一方进程
interested[process] = TRUE;//表明所感兴趣的
turn = process; //设置标志
while(turn == process && interest[other] == TRUE);//空语句
}
void leave_region(int process)//进程:谁离开?
{
interest[process] = FALSE;//表示离开临界区
}
在使用共享变量之前,各个进程使用其进程号0或1作为参数来调用enter__region。该调用在需要时将使进程等待,直到可以安全地进入临界区。在完成对共享变量的此操作之后,进程调用 leave__region,表示操作已完成,若其他的进程希望进入临界区,则现在可以进入。
5.TSL指令
某些计算机,特别是含有多处理器的计算机,都有下面这一条指令:
TSL RX,LOCK
称为测试并加锁。它将一个内存字lock读到寄存器中,然后往里面写入一个非零值。注意这是一条原子语句,它在运行时会锁住内存总线,不可被其他进程打断。
用该语句来防止两个进程同时进入临界区的程序如下:
enter_region:
TSL REGISTER,LOCK |复制锁到寄存器并将锁设为1
CMP REGISTER,#0 |锁是0吗?
LNE enter_region |若不是0,说明锁已被设置,循环
RET |返回调用者,进入临界区
leave_region:
MOVE LOCK,#0 |在锁中存入0
RET |返回调用者,进入临界区
一个可代替TSL指令的是XCHG,它原子性地交换两个位置的内容那个,例如一个寄存器和一个储存器字
enter_region:
MOVE REGISTER,#1 |在寄存器中放入一个1
XCHG REGISTER,LOCK |复制锁到寄存器并将锁设为1
CMP REGISTER,#0 |锁是0吗?
LNE enter_region |若不是0,说明锁已被设置,循环
RET |返回调用者,进入临界区
leave_region:
MOVE LOCK,#0 |在锁中存入0
RET |返回调用者,进入临界区
4 睡眠与唤醒
上面的算法都有一个忙等待的缺点:当一个进程想进入临界区,先检查是否允许进入,若不允许,则该进程将原地等待,直到允许为止。这种方法浪费CPU时间。
如果一台计算机有两个进程,H优先级较高,L的优先级较低。调度规则规定,只要H处于就绪态它就可以运行。某一时刻,L处于临界区,H处于就绪态,且开始忙等待。由于H处于就绪态,L将不会被调度,也就无法离开临界区,H将永远等待下去。这有时被称为优先级反转问题。
下面有两条进程间通信原语,它们在无法进入临界区时将阻塞,而不是忙等待:
- sleep:引起调用进程阻塞的系统调用,即被挂起,直到另一个进程将其唤醒。
- wakeup:有一个参数,即被唤醒的进程。
另一种方法是让sleep和wakeup各有一个参数,即用于匹配sleep和wakeup的内存地址。
生产者-消费者问题
也称为界限问题。两个进程共享一个公共的固定大小的缓冲区。其中一个是生产者,将信息存入缓冲区;另一个是消费者,从缓冲区中读出数据。下面是一种解决方法:
#define N 100 //缓冲区的槽数目
int count = 0; //缓冲区内的数据项数目
void produce(void)
{
int item;
while(TRUE){//无限循环
item = produce_item();//产生下一新数据项
if(count == N) sleep();//如果缓冲区满了,就进入休眠状态
insert_item(item);//将数据项放入缓冲区中
count = count + 1;//将魂宠去的数据项计数器增1
if(count == 1) wakeup(consumer);//缓冲区空吗?
}
}
void consume(void)
{
int item;
while(TRUE){//无限循环
if(count == 0) sleep();//如果缓冲区为空,则进入休眠状态
item = remove_item();//从缓冲区中取出一个数据项
count = count - 1;//将缓冲区的数据项计数器减1
if(count == N - 1) wakeup(producer);//缓冲区满吗?
consume_item(item);//打印数据项
}
}
这个程序会出现wakeup信号丢失(竞争条件)的问题:当缓冲区为空的时候,消费者刚刚读取count值发现它为0,此时调度程序决定暂停消费者,并启动生产者,生产者写入数据,count加1,此时count为1,生产者wakeup来唤醒消费者。由于此时消费者还未睡眠,信号丢失,轮到消费者运行的时候,它会进入睡眠状态,进入睡眠。于是生产者将填满整个缓冲区,然后睡眠,这样一来两个进程将永远睡眠下去。
5 信号量
Dijkstra提出了信号量的方法,它使用一个整型变量来累计唤醒次数,供以后使用。他引入了一个新的变量类型,叫作信号量。一个信号量的值可以为0,可以为正值。
Dijkstra设立了两种操作:
- down:对一个信号量,若该值(必须大于等于0)为0,则进程将睡眠,检查数值,修改变量值以及可能发生的睡眠操作均作为一个单一的,不可分割的原子操作完成。若该值大于0,则将其减1。
- up:对信号量的值增1。如果一个或多个进程在该信号量上睡眠,无法完成一个先前的down操作,则由系统选择出其中的一个(比如随机挑选)并允许该进程完成它的down操作。于是,对一个有进程在其上睡眠的信号量执行一次up操作之后,改信号量的值仍然是0,但是在其上睡眠的进程却少了一个。
用信号量解决生产者-消费者问题
用信号量解决丢失的wakeup问题,如下程序:
#define N 100 //缓冲区中的槽数目
typedef int semaphore; //信号量是一种特殊的整型数据
semaphore mutex = 1; //控制对临界区的访问
semaphore empty = N; //计数缓冲区的空槽数目
semophore full = 0; //计数缓冲区的满槽数目
void producer(void)
{
int item;
while(TRUE)
{
item = produce_item();//产生放在缓冲区的一些数据
down(&empty); //将空槽数目减1
down(&mutex); //进入临界区
insert_item(item); //将新数据放进缓冲区中
up(&mutex); //离开缓冲区
up(&full); //将满槽数目加1
}
}
void consumer(void)
{
int item;
while(TRUE){
down(&full); //将满槽数目减1
down(&mutex); //进入临界区
item = remove_item(); //从临界区中取出数据
up(&mutex); //离开临界区
up(&empty); //将空槽数目加1
consumer_item(item); //处理数据项
}
供两个或多个进程使用的信号量,其初值为1,保证同时只有一个进程可以进入临界区,成为二元信号量。
信号量的另一种用途是用于实现同步。信号量full和empty用来保证某件事情的顺序发生或不发生。这种做法与互斥是不一样的。
6 互斥量
互斥量是一个可以处于两态之一的变量:解锁和加锁。只用一个二进制位即可,0表示解锁,1表示加锁。
mutex_lock:
TSL REGSITER,MUTEX |将互斥信号量复制到寄存器,并且将互斥信号量置为1
CMP REGSITER,#0 |互斥信号量是0吗?
JZE ok |如果互斥信号量为0,它被解锁,所以放回
CALL thred_yield |互斥信号量忙,调度另一个线程
JMP mutex_lock |稍后再试
ok:RET |返回调用者;进入临界区
mutex_unlock:
MOVE MUTEX,#0 |将mutex置为0
RET |返回调用者
enter_region与mutex_lock的区别:
- enter_region在进入临界区失败时,会重复测试锁。由于时钟超时的作用,会调度其他进程,这样迟早拥有锁的进程会进入运行并释放锁。但是在线程中,没有时钟停止运行时间过长的线程,结果通过忙等待试图获得锁的程序会一直运行下去。
- enter_region在取锁失败的时候,会调用thread_yield将CPU弃给另一个线程,这样就没有忙等待。
进程间如何共享变量?
- 有些共享数据结构,如信号量,存放在内核中,只能由系统调用来访问。
- 让进程与其他进程共享其部分地址空间。
- 使用共享文件。
Pthread中的互斥函数
线程调用 | 描述 |
---|---|
pthread_mutex_init | 创建一个互斥量 |
pthread_mutex_destroy | 撤销一个已存在的互斥量 |
pthread_mutex_lock | 获得一个锁或阻塞 |
pthread_mutex_trylock | 获得一个锁或失败 |
pthread_metux_unlock | 释放一个锁 |
Pthread的条件变量
互斥量在允许或阻塞对临界区的访问上是很有用的,条件变量则允许线程由于一些未达到的条件而阻塞。
线程调用 | 描述 |
---|---|
pthread_cond_init | 创建一个条件变量 |
pthread_cond_destroy | 撤销一个条件变量 |
pthread_cond_wait | 阻塞以等待一个信号 |
pthread_cond_signal | 向另一个线程发信号来唤醒它 |
pthread_cond_broadcast | 向多个线程发信号来让它们全部唤醒 |
用互斥量和条件变量解决生产者-消费者问题
#include <stdio.h>
#include <pthread.h>
#define MAX 1000000000
pthread_mutex_t the_mutex;
pthread_cond_t condc,condp;
int buffer = 0;
void *producer(void *ptr) {
int i;
for(i = 0; i <= MAX; i++) {
pthread_mutex_lock(&the_mutex);
while(buffer != 0) {
pthread_cond_wait(&condp,&the_mutex);
}
buffer = 1;
pthread_cond_signal(&condc);
pthread_mutex_unlock(&the_mutex);
}
pthread_exit(0);
}
void *consumer(coid *ptr) {
int i;
for(i = 0; i <= MAX; i++) {
pthread_mutex_lock(&the_mutex);
while(buffer == 0) {
pthread_cond_wait(&condc,&the_mutex);
}
buffer = 0;
pthread_cond_signal(&condp);
pthread_mutex_unlock(&the_mutex);
}
pthread_exit(0);
}
int main(int argc,char *argv) {
pthread_t pro,con;
pthread_mutex_init(&the_mutex,0);
pthread_cond_init(&condc);
pthread_cond_init(&condp);
pthread_create(&con,0,consumer,0);
pthread_create(&pro,0,producer,0);
pthread_join(pro,0);
pthread_join(con,0);
pthread_cond_destroy(&condc);
pthread_cond_destroy(&condp);
pthread_mutex_destroy(&the_mutex);
}
7 管程
一个管程是一个有过程,变量和数据结构与等组成的一个集合,它组成一个特殊的模块或软件包。进程可以在任何需要的时候调用管程中的过程,但是不能在管程之外直接访问管程内的数据结构。
管程最大的特性:任一时刻管程中只能有一个活跃进程,这可以让它能有效地完成互斥。
monitor exmaple
integer i;
condittion c;
procedure producer();
.
.
end
procedure consumer()
.
.
end
end monitor
8 消息传递
进程间通信的方式使用两条原语:
- send(destination,&message) 给一个既定的目标发送一条消息
- receive(source,&message)从一个给定的源接收一条信息。如无信息,则可能会阻塞调用者或者返回一个错误码。
消息传递系统的要点
消息有可能被网络丢失。
为了防止信息丢失,发送方和接收方可以达成一致。接收方收到数据之后给发送方送回一条确认信息。如发送方在规定时间内没收到确认,则重发信息。但也有可能在发回确认的信息丢失了,这是有可能会重发一次导致接收了两次。通常可以在每条原始信息中嵌入一个连续的序号来解决这个问题。如果接收者收到的信息的序号跟上一条一样,则可以判断信息重复了。进程命名的问题
- 接收者和发送者在同一台机器上时。将信息从一个进程复制到另一个进程通常比信号量操作和进入管程要慢
用消息传递解决消费者-生产者问题
#define N 100 //缓冲区中的槽数目
void producer(void)
{
int item;
message m; //消息缓冲区
while(TRUE{
item = produce_item();//产生放入缓冲区的一些数据
recieve(consumer,&m);//等待消费者发送空缓冲区
build_message(&m,item);//建立一个待发送的消息
send(consumer,&m);//发送数据项给消费者
}
}
void consumer(void)
{
int item,i;
message m;
for(i = 0;i < N;i++) send(producer,&m);//发送N个空缓冲区
while(TRUE)
{
recieve(producer,&m);//接受包含数据项的消息
item = extract_item(&m);//将数据项从消息中提取出来
send(producer,&m);//将空缓冲区发送回生产者
consume_item(item);//处理数据项
}
}
消息传递中对消息进行编址:一种方法是为每个进程分配唯一的地址,让消息按进程的地址编址。另一种方法是引入一种数据结构,称作信箱。信箱是一个用对一定数量的消息进行缓冲的地方,信箱消息数量的设置方法有多种,最典型的是在信箱创建的时候确定消息的数量。使用信箱时,在send和recieve调用中使用的地址参数就是信箱的地址,而不是进程的地址。
9 屏障
屏障是用于进程组而不是用于双进程的生产者-消费真类清醒的。在有些应用中划分了若干阶段,并且规定,除非所有的进程都就绪准备着手下一个阶段,否则任何进程都不能进入下一个阶段。可以通过在每个阶段的结尾安置屏障来实现这种行为。当一个进程到达屏障时,它就被屏障拦截,直到所有的进程都到达该屏障。