进程间的通信
1. 进程间的通信
需要关注的问题:
- 一个进程如何把消息传递给另一个
- 确保两个或更多进程在关键活动中不会出现交叉
- 确保两个进程的输出为正确的顺序
后两个问题同样适用于线程间的通信,而第一个问题对于线程来说很容易,因为同一个进程中的线程共享一个地址空间。
2. 竞争条件
两个或多个进程读写某些共享数据,而最后的结果取决于进程运行的精确时序。
在一些操作系统中,协作的进程可能共享一些彼此都能读写的共用存储区。例如,打印程序。当一个进程需要打印一个文件,它将文件名放在一个目录中。打印机存在一个进程周期性的检查是否有文件需要打印。假设目录中有很多位置,不同的位置由数字0,1,2,…,来区别,每个位置放一个文件名。同时假设有两个共享变量:out,指向下一个要打印的文件;in,指向目录中下一个空闲位置。在某一时刻,0至3号位置为空(即文件打印完毕),而4到6号位置被占用(有排等待打印的文件)。几乎在同一时刻,进程A,B都决定要打印一个文件。此时A读到in的值为7,将7存在A的局部变量中。此时时钟中断,CPU认为进程A运行了足够的时间切换到进程B。进程B读取in,值也为7,B也将7存在自己的局部变量中。此时A,B都想要把文件防止在位置7打印。所以会存在覆盖动作,而哪个文件会被打印,则取决于A,B接下来的运行顺序。
3. 临界区
想要避免竞争条件,使用互斥可以达到这个目的。
互斥:以某种手段确保当一个进程在使用一个共享变量或文件时,其他进程不能做同样的操作。
我们把对共享内存进行访问的程序片段称为临界区。而通过互斥,我们可以使两个进程不能同时处在临界区中,以此来避免竞争条件。它需要满足以下4个条件:
- 任何两个进程不能同时处于其临界区。
- 不应对CPU的速度和数量做任何建设。
- 临界区外运行的进程不得阻塞其他进程。
- 不得使进程无限期等待进入临界区。
4. 忙等待的互斥
1. 屏蔽中断:
在单处理器系统中,最简单的方法就是使每个进程在刚刚进入临界区后立即屏蔽所有中断,并在就要离开之前在打开中断。但此方法存在很多问题:
- 把屏蔽中断的权利交给用户进程不明智,有可能再也不打开中断,则系统会因此中止。
- 屏蔽中断仅仅对执行disable(屏蔽中断)指令的那个CPU有效,对于多处理器系统,其它CPU会继续运行,依然存在同时访问共享内存。
2. 锁变量:
设置一个共享锁变量,其初始值为0。当一个进程想要进入临界区时,首先测试这把锁。如果该锁值为0,则该进程将其设置为1并进入临界区。如果这把锁的值已经为1,则该进程将等待直到值变为0。于是。0表示临界区内没有进程,1表示已经有进程进入临界区。
type Lock_Tokern is mod 2;
Lock: Lock_Tokern := 0;
task body P is
begin
loop
-- non_critical_section --
loop exit when Lock = 0; end loop;
Lock := Lock + 1;
-- critical_section --
end loop;
end P;
但是这个方法并不能解决问题,试想当A进程检查锁变量为0时结束循环,而恰在此时进程B将A打断,同时检查到此时锁值依然为0,则B也可以跳出循环,这样两个进程就可以同时进入临界区。
3. 严格轮换法:
设置一个整型变量turn,初始值为0,用于记录哪个进程进入临界区,并检查或更新共享内存。当turn为0时,进程0可以进入临界区并将turn值置为1,当turn值为1时,进程1可以进入临界区并将turn值置为0。
type Turn_Token is mod 2;
Turn : Turn_Token := 0;
task body P0 is
begin
loop
-- non_critical_section --
loop exit when Turn = 0; end loop;
-- critical_section --
Turn := Turn + 1;
end loop;
end P0;
task body P1 is
begin
loop
-- non_critical_section --
loop exit when Turn = 1; end loop;
-- critical_section --
Turn := Turn + 1;
end loop;
end P1;
存在的问题是,进程连续测试一个变量直到某个值出现为止,这被称为忙等待(busy waiting)。这种方式非常浪费CPU。用于忙等待的锁,称为自旋锁。而且这个方法违反了上文提到的条件2,即进程被临界区之外的进程阻塞(loop)。
4. Dekker算法与Peterson算法
参见:并发问题-互斥(Dekker算法和Peterson算法)
5. TSL指令
TSL即为Test and Lock。需要硬件支持。读写操作被保证为不可分割,即该指令结束前其他处理器均不允许访问该内存字。执行TSL指令的CPU将锁住内存总线,以禁止其他CPU在本指令结束之前访问内存(不等于屏蔽中断,屏蔽中断对其他CPU无效)。
type Flag is Natural range 0..1;
C : Flag := 0;
task body Pi is
L : Flag;
begin
loop
loop
[L := C; C:= 1];
exit when L = 0;
end loop;
-- critical_section --
C := 0;
end loop;
end Pi;
task body Pj is
L : Flag;
begin
loop
loop
[L := C; C:= 1];
exit when L = 0;
end loop;
-- critical_section --
C := 0;
end loop;
end Pj;
中括号中的操作为原子操作。
一种相似的方法是用原子交换操作,替代原子赋值操作。
type Flag is Natural range 0..1;
C : Flag := 0;
task body Pi is
L : Flag := 1;
begin
loop
loop
[Temp := L; L := C; C := Temp];
exit when L = 0;
end loop;
-- critical_section --
L := 1; C := 0;
end loop;
end Pi;
task body Pj is
L : Flag;
begin
loop
loop
[Temp := L; L := C; C := Temp];
exit when L = 0;
end loop;
-- critical_section --
L := 1; C := 0;
end loop;
end Pj;
这两个方法都有忙等待的缺点。
5. 优先级反转
当使用需要忙等待的方法时,如果进程间存在优先级关系,如H享有高优先级,L享有低优先级,当L在临界区中运行时,H就绪并准备进入临界区,开始忙等待,由于H优先级过高,L不会被调度,永远无法离开临界区,所以H将永远忙等待下去
6. 睡眠与唤醒
使用进程间通信原语,他们在无法进入临界区时将被阻塞,而不是忙等待。
sleep:是一个将引起调用进程阻塞的系统调用,即被挂起,直到被另外一个进程唤醒。
wakeup:调用要被唤醒的进程。
7. 信号量
一个可以取值为0或者正数的值。并有两种操作,P和V,且检查数值、修改变量值、以及可能发生的睡眠操作均为一个单一的,不可分割的原子操作。从而保证了当一个信号量开始操作时,其他进程无法修改信号量。主要是要保证不可分割的方式实现它,因为操作信号量时间很短所以屏蔽所有中断是可行的
一般信号量用于保持在0至指定最大值之间的一个计数值。当线程完成一次对该semaphore对象的等待(wait)时,该计数值减一;当线程完成一次对semaphore对象的释放(release)时,计数值加一。当计数值为0,则线程等待该semaphore对象不再能成功直至该semaphore对象变成signaled状态。semaphore对象的计数值大于0,为signaled状态;计数值等于0,为non-signaled状态.
计数信号量具备两种操作动作,之前称为 V(称signal())与 P(又称wait())。V操作会增加信号量 S的数值,P操作会减少它。
运作方式:
- 初始化,给与它一个非负数的整数值。
- 运行 P(又称wait()),信号量S的值将被减少。企图进入临界区块的进程,需要先运行 P(wait())。当信号量S减为负值时,进程会被挡住,不能继续;当信号量S不为负值时,进程可以获准进入临界区块。
- 运行 V(又称signal((),信号量S的值会被增加。结束离开临界区块的进程,将会运行 V(signal())。当信号量S不为负值时,先前被挡住的其他进程,将可获准进入临界区块。
var mutex : semaphore := 1;
Process P1;
statement X;
wait(mutex);
statement Y;
signal(mutex);
statement Z;
end P1;
Process P2;
statement A;
wait(mutex);
statement B;
signal(mutex);
statement C
end P2;
A->B->C; X->Y->Z; [X,Z|A,B,C]; [A,C|X,Y,Z]; ![B|Y] 以上表达式可以解释为
1. A,B,C 三个操作按顺序执行。
2. X,Y,Z 三个操作按顺序执行。
3. X,Z 操作可以与A,B,C操作并发执行。
4. A,C 操作可以与X,Y,Z操作并发执行。
5. B,Y操作永远不可能并发执行。
互斥量:只有两种状态的信号量。(加锁,解锁)。在用户线程中,没有时钟停止的情况下,也可以使用。因为不存在忙等待。取锁失败时,就放弃CPU给另一个线程。下次运行时,重新对锁进行测试
以上方法对于线程是没问题的,因为他们享有公共地址空间。但对于进程可以有两种方法解决
1)将一些共享数据结构,如信号量,存放在内核中,并且只能通过系统调用访问
2)让进程与其他进程共享部分地址空间,如果没有共享途径,则使用共享文件
条件变量经常与互斥量一起使用。被阻塞的线程常常是在等待发信号的线程做某些工作,释放某些资源而只有满足条件变量后,被阻塞的线程才可以继续运行。条件变量允许这种等待与阻塞原子性地进行。条件变量不会存在内存中。如果将一个信号量传递给一个没有线程在等待的条件变量,这个信号会丢失。
8. 管程
管程是一个由过程,变量及数据结构等组成的一个集合,他们组成一个特殊的模块或软件包。进程可以在任何需要的时候调用管程中的过程,但它们不能再管程之外声明的过程中直接访问管程内的数据结构。任一时刻管程中只能有一个活跃进程,所以可以优先完成互斥。管程是语言概念,即编程语言的组成部分,编译器知道它们的特殊性。当一个进程调用管程时,先检查在管程中是否有其他活跃进程,如果有,调用进程将被挂起。进入管程的互斥是由编译器负责的。
引入条件变量使得进程在无法继续运行时被阻塞。当一个管程过程发现它无法继续运行时则在某个条件变量上执行wait操作。该操作导致调用进程自身阻塞。并且将另一个以前等在管程之外的进程调入管程。如果要唤醒正在睡眠的进程,则使用signal命令,为保证互斥,使用完signal语句的进程需要立即退出管程,所以signal语句只可能作为一个管程过程的最后一条语句。wait操作必须在signal之前,因为条件变量无法累计。
9. 消息传递
以上这些机制都是用来解决访问公共内存的一个或多个CPU上的互斥问题的。如果一个分布式系统具有多个CPU并且每个CPU有自己的私有内存,那么这些机制将失败。
为了解决这个问题,可以使用消息传递这种方法,在这种方法中可以使用两种原语send和receive
send(destination, &message),向一个指定目标发送一条消息
receive(source, &message)从一个给定源接收一条消息。如果没有消息可用,则接收者可能被阻塞,直到一条消息到达,或者返回一个错误。
可以用这种方法解决生产者-消费者问题。消费者首先将N条空消息发送给生产者。当生产者向消费者传递一个数据项时,取走一个空消息,并送回一条填充了内容的消息。为避免生产者产生信息速度过快被阻塞,可以引入一种数据结构,来对消息进行缓冲。目的是容纳那些已经发送但尚未被接收的信息。
#define N 100 //缓冲区中的槽数目
void producer(void){
int item;
message m; //消息缓冲区
while(TRUE){
item = produce.item(); //产生放入缓冲区的数据
receive(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){
receive(producer, &m); //接收包含数据项的消息
item = extract_item(&m); //将数据项从消息中提取出来
send(producer, &m); //将空缓冲区发送回生产者
consume_item(item); //处理数据项
}
}
10. 屏障
在某些应用中划分了若干阶段,除非所有进程都就绪才可以进行下一阶段否则任何进程都不可以进入下一阶段。要实现这个目的,可以再每个阶段结尾安置屏障。当一个进程到达屏障时会被阻拦,直到所有进程到达屏障为止。
参考书目:现代操作系统第三版,分布式系统原理与范型第二版