正在学习操作系统,记录笔记。
参考资料: 《操作系统(精髓与设计原理 第6版) 》
操纵系统设计中的核心问题就是关于进程和线程的管理(包括以下三个方面):
支持并发进程的基本要求是加强**互斥(Mutual exclusion)**的能力(即排他性),具体表现为:当一个进程被授予互斥能力时,在该进程的访问共享资源(如文件、I/O访问)的活动期间,不允许其它进程访问该资源。
为了实现互斥,根据上述提到的三类多进程技术,这里会介绍三种实现互斥的方案:信号量(Semaphores)、管程(Monitors)、消息传递(Message Passing)。
在正式介绍本章内容之前先理解以下两个部分的内容👇👇👇👇
首先要了解并发可以在三种平台以三种方式进行:
接着了解下和并发相关的关键术语:
术语 | 说明 |
---|---|
原子操作(Atomic operation) | 一个或多个指令的序列,对外是不可分的;即没有其他进程可以看到其中间状态或者中断此操作(指令集合在执行时不允许被中断,要么就全部执行,要么就不执行) |
临界区(Critical section) | 是一段代码,在这段代码中进程将访问共享资源,当另外一个进程已经在这段代码中运行时,这个进程就不能在这段代码中执行 |
死锁(Deadlock) | 两个或两个以上的进程因其中的每个进程都在等待其他进程做完某些事情而不能继续执行,这样的情形叫做死锁(比如只能通过一辆汽车的公路上,两辆汽车相向而行) |
活锁(Livelock) | 两个或两个以上进程为了响应其他进程中的变化而持续改变自己的状态但不做有用的工作,这样的情形叫做活锁(同样是两个并发的进程可能会死锁,但是其中一个进程会主动改变状态) |
互斥(Mutual exclusion) | 当一个进程在临界区访问共享资源时,其他进程不能进入该临界区访问任何共享资源,这种情形叫做互斥(排他性) |
竞争条件(Race condition) | 多个线程或者进程在读写一个共享数据时,结果依赖于它们执行的相对时间,这种情形叫做竞争(共享资源的状态由最后执行的进程决定的情形) |
饥饿(Starvation) | 是指一个可运行的进程尽管能继续执行,但被调度器无限期地忽视,而不能被调度执行的情况 |
从临界区中我们又可以引出临界资源的概念:两个或多个进程需要访问的一个不可共享的资源。
在操作系统中引入并发的根本目的是为了提高计算机的运行效率,并发在三种硬件平台上有两种形式:Concurrency(并发)
与Parallel(并行)
,:
下面两张图可以较为直观的理解并发的原理:
由于多道程序设计系统的一个基本特性:进程的相对执行速度不可预测。(它取决于其他进程的活动、操作系统处理中断的方式、操作系统的调度策略)会导致并发面临以下问题:
为了更好理解这章的关键,考虑下面一个简单的程序:
void echo()
{
chin = getchar();
chout = chin;
putchar(chout);
}
这个过程显示了字符回显程序的基本步骤,每当击一下键,就可从键盘获得输入。每个输入字符就保存在变量chin中,然后传送给变量chout,并回送给显示器。任何程序可以重复地调用这个过程,接收用户输入,并在屏幕上显示。
假定博主本人使用的是一个支持单用户的单处理器多道程序设计系统,我要同时使用多个应用程序,而且每个应用程序都要使用同一个键盘输入,同一块显示器输出,因此每个应用程序都需要使用这个echo()
函数。为了节省空间,我会把这个程序载入到一个所有应用程序都可以访问的全局存储区域。这种设计一方面节省了空间,另一方面允许各个进程间有效而紧密的交互。但是这样的设计也会带来一些问题,考虑以下P1和P2进程的执行顺序:
最终造成的结果就是第一个字符x丢失,第二个字符y被显示了两次。
问题的本质在于共享全局变量chin。多个进程访问这个全局变量,如果一个进程修改了它,然后被中断,那么另一个进程可能在第一个进程使用它的值之前又修改了这个变量。
假设在这个过程中一次只可以有一个进程,那么前面的顺序会产生如下结果:
这样的输出结果是我们所希望的。
上述这个简单的案例说明,如果需要保护共享的全局变量(以及其他共享的全局资源),唯一的办法是控制访问该变量的代码。如果我们定义了一条规则,一次只允许一个进程进入echo,并且只有在echo过程运行结束后它才对另一个进程是可用的,那么刚才讨论的那类错误就不会发生了。
如何实施此规则是本章的重要内容!!!!
上述我们讨论的情况是在单处理器多道程序设计系统中,但其实在多处理器系统中,同样也存在保护共享资源的问题,其解决方法也是相同的:控制对共享资源的访问。
竞争条件发生在多个进程或线程读写数据时,其最终的结果依赖于多个进程的指令执行顺序。
考虑下面两个简单的例子:
并发会带来如下设计和管理问题:
根据进程相互间是否指导对方存在的程度,对进程间的交互方式进行如下三种分类:
下面的表格更为详细地说明了进程间的三种交互方式:
知晓程度 | 关系 | 一个进程对其他进程的影响 | 潜在的控制问题 |
---|---|---|---|
进程之间相互不知道对方的存在 | 竞争 | ·一个进程的结果与其他进程的活动无关·进程的执行时间可能会受到影响 | ·互斥·死锁(可复用的资源)·饥饿 |
进程间接知道对方的存在(如共享对象) | 通过共享合作 | ·一个进程的结果可能依赖于从其他进程获得的信息·进程的执行时间可能会受到影响 | ·互斥·死锁(可复用的资源)·饥饿·数据一致性 |
进程直接知道对方的存在(它们有可用的通信原语) | 通过通信合作 | ·一个进程的结果可能依赖于从其他进程获得的信息·进程的计时可能会受到影响 | ·死锁(可消费的资源)·饥饿 |
对上述表格中不同关系存在的控制问题做一个详细补充:
实际条件并不总是像上表中给出的那么清晰,几个进程可能既表现出竞争,又表现出合作。然而,对操作系统而言,分别检查表中的每一项并确定它们的本质是必要的。
当并发进程竞争使用同一资源时,它们之间就会发生冲突。(这类资源包括I/O设备、存储器、处理器时间和时钟)
竞争进程面临以下三个控制问题:
下图用抽象术语给出了互斥机制。假设有n个进程并发执行,每个进程都包括了在某资源Ra上操作的临界区以及不涉及资源Ra的替它代码。因为所有的进程都需要访问同一个资源Ra,因此在某一时刻只有一个进程在临界区是很重要的。 为实施互斥,需要两个函数:
entercritical
和exitcritical
。每个函数的参数都是竞争使用的资源名。如果一个进程在它的临界区中,那么其他任何试图进入临界区的进程都必须等待。
通过共享进行合作的情况,包括进程间在互相并不确切知道对方的情况下进行交互。
例如,多个进程可能访问一个共享变量、共享文件或数据库,进程可能使用并修改共享变量而不涉及其他进程,但却知道其他进程也可能访问同一个数据。因此,这些进程必须合作,以确保它们共享的数据得到正确的管理。控制机制必须必须确保共享数据的完整性。
当进程通过通信进行合作时,各个进程都与其他进程进行连接,通信提供了同步和协调各种活动的方法。
在典型情况下,通信可由各种类型的消息组成,发送消息和接收消息的原语由程序设计语言提供,或者由操作系统的内核提供。 由于在传递消息的过程中进程间没有共享任何对象,因而这类合作不需要互斥,但是仍然存在死锁和饥饿的问题。
为了提供对互斥的支持,必须满足以下要求(十六字简记):
许多增强互斥的软件算法已经开发出来了。软件方法会带来高开销并且很容易产生逻辑错误。这一部分我们来讨论提供互斥的硬件支持。
前文介绍过,在单处理机器中,并发进程不能重叠,只能交替。一个进程直到它调用了一个操作系统服务或者被中断,它将一直运行。因此为了保证互斥,只需要保证一个进程不被中断就可以了。
思路:
这种能力可以通过系统内核为启用和禁用中断定义的原语来提供。一个进程可以通过下面的方法实施互斥:
while(true)
{
/*disable interrupts(禁用中断)*/
/*critical section(临界区)*/
/*enable interrupts(启用中断)*/
/*remainder(其余部分)*/
}
中断禁用的方法使得程序执行到临界区之前不能被其他进程打断,因此可以保证互斥。但是这种方法也有两种缺陷:
在多处理器系统中,多个处理器共享内存,这种情况处理器之间的行为是无关的,它们之间也没有支持互斥的中断机制。但是在硬件级别上,我们可以追求对存储单元的访问排斥对相同单元的其他访问。基于这一点,处理器的设计者提出了一些机器指令,用于保证两个动作的原子性。
原子性:“原子”表示不能被中断的单个步骤的指令。 例如在一个取指令周期中对一个存储器单元的读和写或者读和测试。在该指令执行的过程中,任何其他的指令访问内存将被阻止。而这些动作在一个指令周期中完成。
接下来会介绍两种常见的指令:
int compare_and_swap (int *word, int testval, int newval)
{
int oldval;
oldval = *word;
if (oldval == testval) *word = newval;
return oldval;
}
以上代码为
比较和交换
指令的一个版本:
该指令的另一个版本返回一个布尔(Boolean)值:交换发生时为真(true);否则为假(false)。 (几乎所有处理器家族(x86、IA64、sparc、/390等)中都支持该指令的某个版本,而且多数操作系统都利用该指令支持并发。)
下面的代码将展示基于使用比较和交换
指令的互斥规程:
/*program mutualexclusion*/
const int n = /*进程个数*/;
int bolt; /*共享变量*/
void p(int i)
{
while (true){
while (compare_and_swap(bolt,0,1) == 1){
/*不做任何事*/;
}
/*临界区*/;
bolt = 0;
/*其余部分*/;
}
}
void main()
{
bolt = o; /*初始化共享变量*/
parbegin (P(1), P(2),...,P(n)); /*多个进程并发*/
}
共享变量bolt被初始化为0。唯一可以进入临界区的进程是发现bolt等于0的那个进程。所有试图进入临界区的其他进程进入忙等待模式。 忙等待(busy waiting)/自旋等待(spin waiting): 进程在得到临界区访问权之前,它只能继续执行测试变量的指令来得到访问权,除此之外不能做其他事情。 当一个进程离开临界区时,它把bolt重置为0,此时只有一个等待进程被允许进入临界区。进程的选择取决于哪个进程正好执行紧接着的
比较和交换
指令。
void exchange(int register, int memory)
{
int temp;
temp = memory;
memory = register;
register = temp;
}
该指令交换一个寄存器的内容和一个存储单元的内容。
下面的代码将展示基于exchange
指令的互斥协议:
/*program mutualexclusion*/
int const n = /*进程个数*/;
int bolt; /*共享变量*/
void p(int i)
{
int keyi = 1;
while (true){
do exchange (keyi, bolt)
while (keyi != 0);
/*临界区*/;
bolt = 0;
/*其余部分*/;
}
}
void main()
{
bolt = o; /*初始化共享变量*/
parbegin (P(1), P(2),...,P(n)); /*多个进程并发*/
}
共享变量bolt被初始化为0,每个进程都使用一个局部变量key且初始化为1。唯一可以进入临界区的进程是发现bolt等于0的那个进程。它通过把bolt置为1排斥所有其他进程进入临界区。当一个进程离开临界区时,它把bolt重置为0,允许另一个进程进入它的临界区。
机器指令方法的优点:
机器指令方法的缺点:
敲黑板!!!!(重点来啦!!!!
信号量是1965年由荷兰科学家迪杰斯特拉提出来的(数据结构最短路径算法的提出者),信号量的本质是一种数据结构,用来处理并发进程之间的互斥与同步的。
在详细介绍信号量之前我们先了解一下常用的并发机制: 并发机制 说明 信号量(Semaphore) 用于进程间传递信号的一个整数值。在信号量上只有三种操作可以进行,初始化、递减和增加,这三种操作都是原子操作。递减操作可以用于阻塞一个进程,增加操作可以用于解除阻塞一个进程。也称为计数信号量或一般信号量 二元信号量(Binary semaphore) 只取0值和1值的信号量 互斥量(Mutex) 类似于二元信号量。关键区别在于为其加锁(设定值为0)的进程和为其解锁(设定值为1)的进程必须为同一个进程 条件变量(Condition variable) 一种数据类型。用于阻塞进程或者线程,直到特定的条件为真 管程(Monitor) 一种编程语言结构,在一个抽象数据类型中封装了变量、访问过程和初始化代码。管程的变量只能由管程自己的访问过程来访问,每次只能有一个进程在其中执行。访问过程即临界区。管程可以有一个等待进程队列 事件标志(Event flags) 作为同步机制的一个内存字。应用程序代码可以为标志中的每个位关联不同的事件。通过测试相关的一个或多个位,线程可以等待一个事件或多个事件。在全部的所需位都被设定(AND)或者至少一个位被设定(OR)之前线程会一直被阻塞 信箱/消息(Mailboxes/messages) 两个进程交换信息的一种方法,也可以用于同步 自旋锁(Spinlocks) 一种互斥机制,进程在一个无条件循环中执行,等待锁变量的值变为可用
两个或多个进程可以通过简单的信号进行合作,一个进程可以被迫在某一位置停止,直到它接收到一个特定的信号。(任何复杂的合作需求都可以通过适当的信号结构得到满足)
信号量的结构包括两个成员和两个原子操作:
semWait(x)
: semWait 对 count 进行减一操作,如果操作结果为负数(小于零),执行semWait 的进程会被阻塞到队列上;semSingal(x)
:semsignal对count进行加一操作,如果执行后的结果小于等于零,会从阻塞队列的队首的进程出队,并变为就绪状态。上面的概念定义了一般信号量的概念,下面还有几个衍生的概念:
说明:
对以上操作的解释如下:
如上文所述,引入信号量的的目的是实现同步互斥的,使用信号量的操作一般包含以下两个步骤:
对于不同的关系,操作方法不同:
以下代码给出了关于信号原语更规范的定义。(semWait和semsignal原语被假设是原子操作)
(非二元信号量常常也称为计数信号量或者一般信号量)
/*semaphore*/
struct semaphore{
int count;
queueType queue;
};
/*semWait*/
void semWait(semaphore s)
{
s.count--;
if (s.count < 0){
/*把当前进程插入到阻塞队列当中*/;
/*阻塞当前进程*/;
}
}
/*semSignal*/
void semSignal(semaphore s)
{
s.count++;
if (s.count <= 0){
/*把进程p从阻塞队列当中移除*/;
/*把进程p插入到就绪队列*/;
}
}
以下代码定义了信号量更为严格的形式,信号量的值只能为0或1。
/*binary_semaphore*/
struct binary_semaphore{
enum {zero, one} value;
queueType queue;
};
/*semWaitB*/
void semWaitB(binary_semaphore s)
{
if (s.value == one){
s.value = zero;
}
else {
/*把当前进程插入到阻塞队列当中*/;
/*阻塞当前进程*/;
}
}
/*semSignalB*/
void semSignalB(semaphore s)
{
if (s.queue is empty()){
s.value = one;
}
else {
/*把进程p从阻塞队列中移除*/;
/*把进程p插入到就绪队列*/;
}
}
说明:
不论是哪一种信号量,都需要使用队列来保存在信号量上等待的进程。这就会产生一个问题:进程按照什么顺序从队列中移出?
下图将展示(强)信号量机制的一个例子:
说明:
以下代码给出了一种使用信号量s解决互斥问题的方法:
/*program mutualexclusion*/
const int n = /*进程数*/;
semaphore s = 1;
void P(int i)
{
while (true){
semWait(s);
/*临界区*/;
semSignal(s);
/*其他部分*/;
}
}
void main()
{
parbegin (P(1), P(2),...,P(n)); /*多进程并发执行*/
}
说明: 设有n个进程,用数组P(i)表示,所有的进程都需要访问共享资源。每个进程中进入临界区前执行semWait(s),如果s的值为负,则进程被挂起;如果值为1,则s被减为0,进程立即进人临界区;由于s不再为正,因而其他任何进程都不能进入临界区。 可以这样理解,当一个进程想要调用公共资源时,就要发出
semWait(s)
指令,以获取资源,如果获取不到,则一直调用该指令;一旦获取了该资源,则其他进程无法使用;等到该进程使用完资源后,就会调用semSignal(s)
指令,告诉其它进程:“我用完啦,并且把资源交还回公共区域啦。”
s.count ≥ 0
:s.count是可以执行semWait(s)而不被挂起的进程数(如果其间没有semSignal(a)被执行)。这种情形允许信号量支持同步与互斥。s.count<0
:s.count的大小是挂起在s.queue队列中的进程数.下图给出了信号量机制执行过程的另一种表述,这里不再说明(看得懂,应该看得懂)
(为了使得文章整体结构看起来不那么混乱,这里用另一篇文章去综合一些关于信号量的问题,点击下方按钮跳转)
semWait
和semSignal
操作必须作为原子原语实现。可以通过以下三种方式:
从上述内容可以看出信号量有效地提供了进程间互斥与合作的支持,但实际上信号量对于编写者来说是很困难的,用信号量设计一个正确的程序并非易事:由于信号量分布在整个程序中,使用不当,可能造成程序错误,甚至死锁。下面来看三个错误案例:
基于这种情况,DJstra在1971年提出为每一个共享的资源设立一个秘书管理他的访问的思想。即:一切进程访问共享资源时,必须通过设立的秘书,秘书一次只允许一个进程访问共享资源,通过这种方式,即实现了资源的共享,同时也保证了资源的互斥访问与同步。1973年,汉森和霍尔又将秘书的概念发展成为管程。
管程定义了一组局部数据和内部操作,只有内部操作才能使用局部变量,访问管程的进程通过内部操作访问或修改内容数据,从而实现进程间的互斥与访问。
管程允许程序员用其锁定任何对象(对类似于链表之类的对象,可以用一个锁锁住整个链表,也可以每个表用一个锁,还可以为表中的每一个元素用一个锁)。 管程天生具有排他性(互斥)。因此程序员只需要在管程当中解决好同步问题,互斥的工作可以完全交由编译器处理。 关于实现管程的同步的措施:
上述两种机制的实现时通过条件变量和两个内部操作cwait
和csignal
实现的(下文将详细介绍):
管程本质上也是一种数据结构,包含5个部分:
管程具有三个特性:
管程是由一个或多个过程、一个初始化序列和局部数据组成的软件模块,其主要特点如下:
为了进行并发处理,管程必须包含同步工具。
例如,假设一个进程调用了管程,并且当它在管程中时必须被挂起,直到满足某些条件。这就需要一种机制,使得该进程不仅被挂起,而且能释放这个管程,以便某些其他的进程可以进入。以后,当条件满足并且管程再次可用时,需要恢复该进程并允许它在挂起点重新进入管程。
管程通过使用条件变量提供对同步的支持,这些条件变量包含在管程中,并且只有在管程中才能被访问。有两个函数可以操作条件变量:
cwait(c)
:调用进程的执行在条件c上挂起,管程现在可被另一个进程使用。
csignal(c)
:恢复执行在cwait之后因为某些条件而挂起的进程。如果有多个这样的进程,选择其中一个;如果没有这样的进程,什么也不做。
注意管程的wait
和signal
操作与信号量不同:
如果在管程中的一个进程发信号,但没有在这个条件变量上等待的任务,则丢弃这个信号。
下图给出了一个管程的结构:
说明: 尽管一个进程可以通过调用管程的任何一个过程进入管程,但我们仍可以把管程想像成具有一个入口点,并保证一次只有一个进程可以进入。其他试图进入管程的进程被阻塞并加入等待管程可用的进程队列中。当一个进程在管程中时,它可能会通过发送cwalt(x) 把自己暂时阻塞在条件x上,随后它被放入等待条件改变以重新进入管程的进程队列中,在cwalt(x)调用的下一条指令开始恢复执行。 如果在管程中执行的一个进程发现条件变量x发生了变化,它发送csignal(x),通知相应的条件队列条件已改变。
以下代码给出了一个使用管程的例子,再次考虑生产者/消费者问题:
/*program producerconsumer*/
monitor boundedbuffer;
char buffer [N]; /*分配N个数据项空间*/
int nextin, nextout; /*缓冲区指针*/
int count; /*缓冲区中数据项个数*/
cond notfull, notempty; /*为同步设置的条件变量*/
void append(char x)
{
if (count == N){
cwait(notfull); /*缓冲区满,防止溢出*/
}
buffer[nextin] = x;
nextin = (nextin + 1) % N;
count++;
/*缓冲区中数据项个数加一*/
csignal(nonempty); /*释放任何一个等待的进程*/
}
void take(char x)
{
if (count == 0){
cwait(notempty); /*缓冲区空,防止下溢*/
}
x = buffer[nextout];
nextout = (nextout + 1) % N;
count--; /*缓冲区中数据项个数减一*/
csignal(notfull); /*释放任何一个等待的进程*/
}
{ /*管程体*/
nextin = 0; nextout = 0; count = 0; /*缓冲区初始化为空*/
}
/*producer*/
void producer()
{
char x;
while (true){
produce(x);
append(x);
}
}
/*consumer*/
void consumer()
{
char x;
while (true){
take(x);
consume(x);
}
}
void main()
{
parbegin(producer, consumer);
}
说明: 管程模块boundedbuffer控制着用于保存和取回字符的缓冲区,管程中有两个条件变量(使用结构cond声明):当缓冲区中至少有增加一个字符的空间时,notfull为真;当缓冲区中至少有一个字符时,notempty为真。 生产者可以通过管程中的过程append往缓冲区中增加字符,它不能直接访问buffer。该过程首先检查条件notfiull,以确定缓冲区是否还有可用空间。如果没有,执行管程的进程在这个条件上被阻塞。其他某个进程(生产者或消费者)现在可以进入管程。然后,当缓冲区不再满时,被阻塞进程可以从队列中移出,重新被激活,并恢复处理。在往缓冲区中放置了一个字符后,该进程发送notempty条件信号。对消费者函数也可以进行类似的描述。
Hoare关于管程的定义[HOAR74]要求在条件队列中至少有一个进程,当另一个进程为该条件产生caigna1时,该队列中的一个进程立即运行。因此,产生caigna1的进程必须立即退出管程,或者阻塞在管程上。
这种方法有两个缺陷:
因此又开发出了一种不同的管程以解决以上问题:
void append(char x)
{
while (count == N){
cwait(notfull); /*缓冲区满,防止溢出*/
}
buffer[nextin] = x;
nextin = (nextin + 1) % N;
count++; /*缓冲区中数据项个数加一*/
cnotify(notempty); /*通知正在等待的进程*/
}
void take(char x)
{
while (count == 0){
cwait(notempty); /*缓冲区空,防止下溢*/
}
x = buffer[nextout];
nextout = (nextout + 1) % N;
count--; /*缓冲区中数据项个数减一*/
cnotify(notfull); /*通知正在等待的进程*/
}
说明: csignal原语被 cnotify取代。当一个正在管程中的进程执行cnotify(x)时,它使得x条件队列得到通知,但发信号的进程继续执行。通知的结果是使得位于条件队列头的进程在将来合适的时候且当处理器可用时被恢复执行。但是,由于不能保证在它之前没有其他进程进入管程,因而这个等待进程必须重新检查条件。 if语句被 while循环取代,因此,这个方案导致对条件变量至少多一次额外的检测。作为回报,它不再有额外的进程切换,并且对等待进程在cnotify之后什么时候运行没有任何限制。
进程交互式,必须满足两个基本要求:同步和通信。为实施互斥,进程间需要同步;为了合作,进程间需要交换信息。之前介绍的两种机制(信号量以及管程)可以在单处理器系统以及多处理器系统中实现,但是对于分布式系统而言,没有办法提供支持。此外,管程对编译器有依赖,而很多编程语言的编译器并没有提供对管程的支持(比如gcc)。为此,引入了消息传递。
消息传递的实际功能以一对原语的形式提供:
send(destination, message)
receive(source, message)
说明: 这是进程间进行消息传递所需要的最小操作集。一个进程以消息(message)的形式给另一个指定的目标(destination)进程发送信息;进程通过执行receive原语接收信息,receive原语中指明发送消息的源进程(source)和消息。 send与receive操作既可以是阻塞操作也可以是非阻塞操作:
因此对于阻塞send 而言,当程序调用了这条指令后,在send中的消息传送给指定进程之前,程序始终停留在send指令上,不向后推进; 而对于非阻塞send,当程序调用这条指令后,指令马上返回,立即执行后面的指令; 同理,对于receive也是如此,阻塞的receive在收到消息之前,程序停留在这条指令之上不会向下执行,非阻塞receive则是调用指令后,立即向下执行。
两个进程间的消息通信隐含着某种同步的信息:只有当一个进程发送消息之后,接收者才能接收消息。
当一个进程执行时,在它发送send
或receive
原语时,都有阻塞或不阻塞两种状态,因此会有如下四种组合:
对于大多数并发程序设计任务来说:
显然,在send原语中确定哪个进程接收消息是很有必要的。同样,大多数实现允许接收进程指明消息的来源。
在send和receive原语中确定目标或源进程的方案可分为两类:直接寻址(direct addressing)和间接寻址(indirect addressing)。
进程和信箱的关联可以是静态的,也可以是动态的。端口常常是静态地关联到一个特定的进程上,也就是说,端口是被永久地创建并指定到该进程。(在一对一的关系中就是典型的静态和永久性的关系)
消息的格式取决于消息机制的目标以及该机制是运行在一台计算机上还是分布式系统中。
对某些操作系统,设计者优先选用短的、固定长度的消息,以减少处理和存储的开销。如果需要传递大量的数据,数据可以放置到一个文件中,消息可以简单地引用该文件。一种更灵活的方法是允许可变长度的消息。
下图给出了一种操作系统的支持可变长度消息的典型消息格式:
该消息被划分成两部分:包含相关信息的消息头和包含实际内容的消息体。
消息头可以包含消息的源和目标的标识符、长度域和判定各种消息类型的类型域,还可能含有一些额外的控制信息。(例如用于创建消息链表的指针域、记录源和目标之间传递的消息的数目、顺序和序号,以及一个优先级域)
最简单的排队原则是先进先出原则,但是当某些消息比其他消息更紧急时,仅有这种原则是不够的。一个可选的原则是允许指定消息的优先级,这可以基于消息的类型或者由发送者指定,另一种选择是允许接收者检查消息队列并选择下一次接收哪个消息。
以下代码给出了可用于实施互斥的消息传递方式:
/*program mutualexclusion*/
const int n = /*进程数*/
void p(int i)
{
message msg;
while (true){
receive (box, msg);
/*临界区*/;
send (box, msg);
/*其他部分*/;
}
}
void main()
{
creat mailbox (box);
send (box, null);
prabeginc(P(1), P(2),...,P(n));
}
说明: 假设使用阻塞 receive原语和无阻塞 send原语,一组并发进程共享一个信箱box,它可供所有进程在发送和接收消息时使用,该信箱被初始化成一个无内容的消息。希望进人临界区的进程首先试图接收一条消息,如果信箱为空,则该进程被阻塞;一旦进程获得消息,它执行它的临界区,然后把该消息放回信箱。因此,消息函数可以看做是在进程之间传递的一个令牌。 上面的解决方案假设有多个进程并发地执行接收操作,则:
作为使用消息传递的另一个例子,以下代码是解决有界缓冲区生产者/消费者问题的一种方法:
const int
capacity = /*缓冲区容量*/
null = /*空消息*/
int i;
/*producer*/
void producer()
{
message pmsg;
while (true){
receive (mayproduce, pmsg);
pmsg = produce();
send (mayconsume, pmsg);
}
}
/*consumer*/
void consumer()
{
message cmsg;
while (true){
receive (mayconsume, cmsg);
consume (cmsg);
send (mayproduce, null);
}
}
void main()
{
create_mailbox (mayproduce);
create_mailbox (mayconsume);
for (int i = 1; i <= capacity; i++){
send (mayproduce, null);
}
parbegin (producer, consumer);
}
说明: 它使用了两个信箱。当生产者产生了数据,它作为消息被发送到信箱mayconsume,只要该信箱中有一条消息,消费者就可以开始消费。从此之后mayconsume作为缓冲区,缓冲区中的数据被组织成消息队列,缓冲区的大小由全局变量capacity确定。信箱mayproduce最初填满了空消息,空消息的数量等于信箱的容量,每次生产使得mayproduce中的消息数缩小,每次消费使得mayproduce中的消息数增长。 这种方法非常灵活,可以有多个生产者和消费者,只要它们都访问这两个信箱即可。系统甚至可以是分布式系统,所有生产者进程和mayproduce信箱在一个站点上,所有消费者进程和mayconsume信箱在另一个站点上。
本篇已完结
(如有修改或补充欢迎评论)