文章目录
- 一、并发进程
- 1.1 前驱图的定义
- 1.2 顺序程序及其特性
- 1.2.1 程序的顺序执行
- 1.2.2 顺序程序的特性
- 1.3 并发程序及其特性
- 1.3.1 程序的并发执行
- 1.3.2 并发程序的特性
- 1.4 程序并发执行的条件
- 1.5 与时间有关的错误
- 二、进程互斥
- 2.1 什么是进程互斥
- 2.2 进程互斥原则
- 2.3 进程互斥的软件实现
- 2.3.1 单标志法
- 2.3.2 双标志先检查法
- 2.3.3 双标志后检查法
- 2.3.4 Dekker 互斥算法
- 2.3.5 Peterson算法
- 2.3.6 Lamport 面包店算法
- 2.3.7 Eisenberg-Mcguire 算法
- 2.4 进程互斥的硬件实现
- 2.4.1 硬件提供存储障碍语句
- 2.4.2 硬件提供原子变量
- 2.4.3 中断屏蔽方法(开/关中断)
- 2.4.4 TestAndSet指令
- 2.4.5 Swap指令
- 2.5 互斥锁
- 2.5.1 互斥锁
- 三、进程同步
- 3.1 什么是进程同步
- 3.2 信号量与 PV 操作
- 3.2.1 信号量与 PV 操作的定义
- 3.2.2 信号量与 PV 操作的实现
- 3.2.3 信号量与PV操作的应用
- 3.2.3.1 信号量实现进程互斥
- 3.2.3.2 信号量机制实现进程同步
- 3.3 经典同步互斥问题
- 3.3.1 生产者、消费者问题
- 3.3.2 吸烟者问题
- 3.3.3 读者、写者问题
- 3.3.4 哲学家进餐问题
- 3.3.5 3台打印机管理
- 四、管程
- 4.1 管程的引入
- 4.2 管程的形式
- 4.3 管程语义
- 4.4 管程的应用
- 4.4.1 管程解决生产者消费者问题
- 4.4.2 Java中类似于管程的机制
- 五、进程通信
- 5.1 进程通信的概念
- 5.2 进程间通信的模式
- 5.2.1 共享内存模式
- 5.2.2 消息传递模式
- 5.2.2.1 直接方式
- 5.2.2.2 间接方式
- 六、系统举例(待补充)
- 6.1 Linux 进程间通信
- 6.2 Windows 10 的并发控制
一、并发进程
1.1 前驱图的定义
前驱图(precedence graph)是一个有向无环图,图中的每个结点表示一条语句、一个计算步骤或一个进程。结点间的有向边表示偏序或前驱关系(precedence relation)“->”,->={ (Pi, Pj) | Pi必须在Pj启动之前已经完成 } 。(Pi,Pj) ∈ -> 可记为Pi -> Pj,称Pi 为 Pj的前驱,Pj 是 Pi的后继。
在前驱图中,入度为0的点为初始节点,出度为0的点为终止结点。此外,每个点可以有一个点权来表示该结点的程序量或计算时间。
如上面这张图前驱图就表示前驱关系:P1 -> P2,P1->P3,P1->P4,P2->P5,P3->P5,P4->P6,P5->P7,P6->P7,P6->P8
不难理解,前驱关系满足传递性,即若P1->P2,P2->P3,则必有P1->P3。
1.2 顺序程序及其特性
1.2.1 程序的顺序执行
顺序程序可以从内部和外部两个角度来看。
-
内部顺序性:对于一个进程来说,它的所有指令是按顺序执行的。
S1:a := x + y S2:b := a - z S3:c := a + b S4:d := c + 5
上面四条语句,每一条都必须等前面语句执行完毕后方可执行,那么其前驱图有如下表示:
-
外部顺序性:对于多个进程来说,所有进程的活动是依次执行的。考虑由输入(I)、计算(C)、打印(P)这三个活动构成的进程,每个进程内部活动是有序的,即Ii -> Ci -> Pi,多个进程的活动也是有序的。
1.2.2 顺序程序的特性
顺序程序设计有如下3个良好的特性:
- 连续性。一个程序的指令是连续执行的,中间不会夹杂其他程序的指令。
- 封闭性。程序在执行过程中独占系统中的全部资源,该程序的运行环境只与其自身的动作有关,不受其他程序及外界因素的影响,
- 可再现性。程序的执行结果与执行速度无关,而只与初始条件有关。
1.3 并发程序及其特性
1.3.1 程序的并发执行
程序的并发性同样从内部和外部两个视角来看。
1、内部并发性:即一个程序内部的并发性
S1: a:=x+2;
S2: b:=y+2;
S3: c:=a+b;
S4: d:=c-6;
S5: e:=c+6;
S6: f:=c-e;
容易看出,S3必须在S1和S2之后执行,S4和S5必须在S3之后执行,S6必须在S3和S6之后执行:而S1和S2可以并发执行,S4和S5可以并发执行,S4和S6可以并发执行。
2、外部并发性:即多个程序的并发性
和前面举的例子,由输入(I)、计算(C)、打印(P)这三个活动构成的进程间的并发执行情况如下:
1.3.2 并发程序的特性
并发程序丧失了顺序程序的优点。
- 间歇性。多个程序是交叉执行的,处理器在执行一个程序的过程中有可能被中断,并转而执行另一个程序。
- 非封闭性。一个进程的运行环境可能被其他进程所改变,从而相互产生影响。
- 不可再现性。由于交叉的随机性,并发程序的多次执行可能对应不同的交叉,因而不能期望重新运行的程序能够再现上次运行时产生的结果。
1.4 程序并发执行的条件
我们先引入几个概念:
R(pi) = { a1, q2, … , am }表示程序 pi 在执行期间所读取的所有变量的集合,称为**“读集”。
W(pi) = { b1, b2, … , bn }表示程序 pi 在执行期间所修改的所有变量的集合,称为“写集”**。
1966年Bernstein提出,若两个程序p1和p2满足以下条件,则能够保持可再现性,因而可以并发执行:
R ( P 1 ) ⋂ W ( P 2 ) ⋃ R ( P 2 ) ⋂ W ( P 1 ) ⋃ W ( P 1 ) ⋂ W ( P 2 ) = ∅ R(P_{1}) \bigcap W(P_{2}) \bigcup R(P_{2}) \bigcap W(P_{1}) \bigcup W(P_{1}) \bigcap W(P_{2}) = \empty R(P1)⋂W(P2)⋃R(P2)⋂W(P1)⋃W(P1)⋂W(P2)=∅
如下面四条语句:
S1: a:=x-y;
S2: b:=x+1;
S3: v:=a+b;
S4: w:=v+1;
R(S1) = { x, y }, R(S₂) = { z }, R(S3) = { a, b }, R(S4) = { v }
W(S1) = { a }, W(S2) = { b }, W(S3) = { v }, W(S4) = { w }
上面S1和S2满足条件可以并发执行,S1、S3和S2、S3以及S3、S4都不能并发执行
1.5 与时间有关的错误
我们通过一个例子来说明什么是与时间有关的错误。
假设有一个图书馆管理系统,连有两个终端,用户可以通过终端借书。假设所有用户借阅的图书都是相同的。设x代表图书的剩余数量,为两个终端用户服务的图书借阅系统如图
假设当前只剩一本书。即x = 1。有读者在终端1上借书,进程P执行。当程序执行到①处时被中断,终端2上有读者借书,进程P执行。当程序执行到②处时被中断,此时P1和P2都判断有书,此后二者并发执行,分别将x减1,将同一本书借给了两位读者,即发生了错误。
多个进程在访问变量时,因实际交叉次序不同而导致执行结果不同,这种现象称为竞争条件(race condition)。
上述错误并不是一定会发生的,它与进程的推进速度有关。假若当进程 P1执行到③处而不是①处时被中断,之后进程P2插入,则不会发生上述错误,即上述错误发生与否与进程P1和P2的推进速度有关。速度是时间的函数,因而这种错误称为与时间有关的错误。
发生上述错误的原因在于两个进程P1和P2同时对一个变量x进行操作,一个进程对x的操作仅做了一部分,另一个进程中途插入使得变量x处于一种不确定的状态。用数据库的术语来说,就是失去了变量x的数据完整性。
二、进程互斥
2.1 什么是进程互斥
1)概念介绍
进程的“并发” 需要 “共享”的支持。各个并发执行的进程不可避免的需要共享一些系统资源(比如内存,又比如打印机、摄像头这样的I/O设备)。
而操作系统中资源共享方式可分为两种:
- 互斥共享方式:系统中的某些资源,虽然可以提供给多个进程使用,但一个时间段内只允许一个进程访问该资源
- 同时共享方式:系统中的某些资源,允许一个时间段内由多个进程“同时”对它们进行访问
我们把**一个时间段内只允许一个进程使用的资源称为临界资源(critical resource)。**许多物理设备(比如摄像头、打印机)都属于临界资源。此外还有许多变量、数据、内存缓冲区等都属于临界资源。
对临界资源的访问,必须互斥地进行。互斥,亦称间接制约关系。进程互斥指当一个进程访问某临界资源时,另一个想要访问该临界资源的进程必须等待。当前访问临界资源的进程访问结束,释放该资源之后,另一个进程才能去访问临界资源。
多个进程均需访问的变量称为共享变量(sharedvariable)。访问共享变量的程序段我们称之为临界区(critical region),也称为临界段(critical section)
共享变量和临界区的表示:
通常,共享变量说明为以下形式:
shared <一组变量>
临界区则标记为以下形式:
region <一组变量> do <语句>
<语句>部分可以是任意语句,包括临界区语句,即临界区嵌套,例如:
shared x1, x2;
shared y1, y2;
region x1, x2 do{...region y1, y2 do{...}...
}
值得一提的是,临界区嵌套的使用一定要小心,否则很容易出现死锁现象。
2.2 进程互斥原则
对临界区的互斥访问,在逻辑上可以分为如下四个部分:
do{entry section; //进入区critical section; //临界区exit section; //退出区remainder section; //剩余区
} while(true);
- **进入区:**负责检查是否可进入临界区,若可进入,则应设置正在访问临界资源的标志(可理解为“上锁”),以阻止其他进程同时进入临界区
- **临界区:**访问临界资源的程序段
- **退出区:**负责解除正在访问临界资源的标志(可以理解为解锁)
- **剩余区:**做其他处理
思考:
如果一个进程暂时不能进入临界区,那么该进程是否应该一直占着处理机?该进程有没有可能一直进不了临界区?
为了实现对临界资源的互斥访问,同时保证系统整体性能,需要遵循以下原则:
- 空闲让进:临界区空闲时,可以允许一个请求进入临界区的进程立即进入临界区;
- 忙则等待:当已有进程进入临界区时,其他试图进入临界区的进程必须等待;
- 有限等待:对请求访问的进程,应保证能在有限时间内进入临界区(保证不会饥饿);
- 让权等待:当进程不能进入临界区时,应立即释放处理机,防止进程忙等待。
2.3 进程互斥的软件实现
2.3.1 单标志法
算法思想:两个进程在访问完临界区后会把使用临界区的权限转交给另一个进程。也就是说每个进程进入临界区的权限只能被另一个进程赋予
代码如下:
int turn = 0; //turn 表示当前允许进入临界区的进程号// P0 进程
while (turn != 0); //①进入区
critical section; //②临界区
turn = 1; //③退出区
remainder section; //④剩余区// P1 进程
while (turn != 1); //⑤进入区
critical section; //⑥临界区
turn = 0; //⑦退出区
remainder section; //⑧剩余区
不难理解,该算法可以实现“同一时刻最多只允许一个进程访问临界区”
思考:
考虑如下场景:进程P0访问完临界区后标志位为0,此后P0不再访问临界区,那么如果P1想要访问临界区,由于turn = 0,P1始终不能访问临界区。
因而,单标志法的主要问题是:违背“空闲让进”原则
2.3.2 双标志先检查法
算法思想:设置一个布尔型数组 flag0],数组中各个元素用来标记各进程想进入临界区的意愿,比如“flag[0]=ture”意味着0号进程 P0现在想要进入临界区。每个进程在进入临界区之前先检查当前有没有别的进程想进入临界区,如果没有,则把自身对应的标志flag[i]设为true,之后开始访问临界区。
代码如下:
bool flag[2];
flag[0] = false;
flag[1] = false;// P0 进程
while (flag[1]); //①
flag[0] = true; //②
critical section; //③
flag[0] = false; //④
remainder section;// P1 进程
while (flag[0]); //⑤
flag[1] = true; //⑥
critical section; //⑦
flag[1] = false; //⑧
remainder section;
不出意外的话,要出意外了。
若按照①⑤②⑥③⑦……顺序执行下去,虽然在P0和P1的视角下它们在按规则行事,但事实上,P0P1同时访问了临界区
因此,双标志先检查法违背了:忙则等待原则
更进一步,其原因在于检查和上锁这两个处理不是一气呵成的,换句话说,对权限的操作不是原子的,导致上锁前后可能发生进程切换。
2.3.3 双标志后检查法
**算法思想:**双标志先检查法的改版。前一个算法的问题是先 “检查” 后 “上锁”,但是这两个操作又无法一气呵成,因此导致了两个进程同时进入临界区的问题。因此,人们又想到先 “上锁” 后 “检查” 的方法,来避免上述问题。
代码如下:
bool flag[2];
flag[0] = false;
flag[1] = false;// P0
flag[0] = true; //①
while (flag[1]); //②
critical section; //③
flag[0] = false; //④
remainder section;// P1
flag[1] = true; //⑤
while (flag[0]); //⑥
critical section; //⑦
flag[1] = true; //⑧
remainder section;
改了个寂寞
我们发现按照①⑤②⑥……的顺序执行,P0和P1将都无法进入临界区
因此,双标志后检查法虽然解决了“忙则等待”的问题,但是又违背了 “空闲让进” 和 "有限等待"原则,会因各进程都长期无法访问临界资源而**产生“饥饿”**现象。
两个进程都争着想进入临界区,但是谁也不让谁,最后谁都无法进入临界区。
2.3.4 Dekker 互斥算法
荷兰数学家 T.Dekker 给出的第一个正确实现互斥的软件解法。
算法实现:在双标志后检查的基础上,加入轮流次序,当进程出临界区时交还turn给其他进程从而解决了“空闲让进” 和 "有限等待"原则
代码如下:
bool flag[2]; //进入意愿
int turn; //轮流次序// P0
flag[0] = true;
while(flag[1]){if (turn == 1){flag[0] = false;while (turn == 1);flag[0] = true;}
}
critical section;
turn = 1;
flag[0] = false;// P1
flag[1] = true;
while(flag[0]){if (turn == 0){flag[1] = false;while (turn == 0);flag[1] = true;}
}
critical section;
turn = 0;
flag[1] = false;
我们考虑该算法是否能满足互斥原则:
- 空闲让进:当临界区空闲时,假如P0想要进入临界区且turn = 0,那么flag[1]此时若为true,那么P0直接进入临界区。反之,由于turn = 0,那么P1一定会置flag[1] = false,P0进入临界区,因而临界区空闲时总能让一个进程进入临界区。
- 忙则等待:当存在进程访问临界区时,turn始终在该进程手中,其他进程只能等待
- 有限等待:假如P0在临界区中,P1处于entry section。P0离开临界区,置turn为0,flag[0] = false, 若P1在判断外层while循环前P0没有提出进入临界区请求,那么P1可以顺利进入临界区。反之,若P0提出请求,由于turn = 0,P1会置flag[0] = false,等待P1,从而P1可以进入临界区。
- 让权等待:会出现忙式等待的情况,未能满足。
2.3.5 Peterson算法
1981 年,G.L. Peterson 提出了一个比较简单的进程互斥算法。算法应用与Dekker算法相似的两个数据结构。
**算法思想:**结合双标志法、单标志法的思想。如果双方都争着想进入临界区,那可以让进程尝试“孔融让梨”(谦让)。做一个有礼貌的进程。
代码如下:
bool flag[2]; //进入意愿
int turn; //轮流次序//P0
flag[0] = true;
turn = 1
while (flag[1] && turn = 1);
critical section;
flag[0] = false;
remainder section;//P1
flag[1] = true;
turn = 0;
while(flag[0] && turn == 0);
critical section;
flag[1] = true;
remainder section;
我们考虑该算法是否能满足互斥原则:
- 空闲让进:当临界区空闲时,假如P0想要进入临界区,flag[0]置true的同时让权turn = 1,如果此时P1也想进入临界区,那么P1进入,否则P0进入。
- 忙则等待:当存在进程访问临界区时,其他进程等待必须使得while循环的两个条件都成立,只要访问临界区的进程仍在临界区内,那么其他进程等待的条件就始终满足。
- 有限等待:假如P0在临界区中,P1提出进入请求,并在忙式等待,那么当P0离开临界区,置flag[0] = false,P1获得执行机会进入临界区。若P0在P1获取处理机之前再次提出进入临界区请求,将flag[1] 置为 true,turn置为1,然后忙式等待,P0仍能进入临界区,所以P1在P0再次进入临界区之前一定能够进入临界区,满足有限等待性。
- 让权等待:会出现忙式等待的情况,未能满足。
2.3.6 Lamport 面包店算法
计算机科学家Leslie Lamport设计的计算机算法,该算法的基本思想源于顾客在面包店中购买面包时的排队原理。
**算法思想:**设置一个发号者,按0、1、2……发号,想进入临界区的进程抓号,抓到号后按照从小到大的次序依次进入。
思考:现实中两人不可能抓到同一个号,因为每一个号被抓是互斥的。而操作系统中却可能出现两个进程抓到相同的号,如果出现该情况,我们按照pid大小由小到大进入临界区。
代码如下:
bool choosing[N]; //进程是否正在抓号
int number[N]; //进程所抓到的号码// Pi
//entry section
choosing[i] = 1;
number[i] = max{ number[0], number[1], ..., number[n - 1] } + 1;
choosing[i] = 0;
for(j < 0; j < n; j ++) {while (choosing[j]) ;while ((number[j] <> 0) && (number[j], j) < (number[i], i)) ;
}
critical section
exit section
remainder section//初始化
number[i] == 0;
我们考虑该算法是否能满足互斥原则:
- 空闲让进:当临界区空闲时,假如Pi想要进入临界区所抓号码为number[i],对于其他也想进入临界区的Pj,如果Pj还未置choosing[j] = 1,那么Pi直接进入临界区;如果Pj已经置choosing[j] = 1,那么由于Pi先抓号,(number[i], i) < (number[j], j),;如果抓到相同的号,那么pid小的进程也能进入临界区,其他进程会等待。故空闲时总能有进程进入临界区
- 忙则等待:当存在进程Pi访问临界区时,其他想要访问临界区的Pj一定满足(number[j], j) < (number[i], i),会等待
- 有限等待:因为竞争进程按照先进先出的次序进入临界区,而且进程的数量是有限的,所以进程不会无限期地等待进入临界区。
- 让权等待:会出现忙式等待的情况,未能满足。
2.3.7 Eisenberg-Mcguire 算法
**算法思想:**环形等待其他进程都退出临界区或者自己拿到轮流次序时才访问临界区,离开时,turn转交给下一个请求进程。
enum flag[N] (idle, want_in, in_cs);
int turn;//Pi
do{flag[i] = want_in;j = turn;while (j != i){if (flag[j] != idle)j = turn;elsej = (j + 1) % n; }flag[i] = in_cs;j = 0;while((j < n) && (j == i || flag[j] != in_cs)) j ++;
} while(j != n);
turn = i;
critical section
j = (turn + 1) % n;
while (flag[j] == idle)j = (j + 1) % n;
turn = j;
flag[i] = idle;
remainder section
我们考虑该算法是否能满足互斥原则:
- 空闲让进:当临界区空闲时,假如Pi想要进入临界区,如果无其他进程想进入临界区,那么Pi直接进入。否则,总有一个进程能拿到turn进入临界区,其他进程因此等待。故临界区空闲时,总能让进程进入临界区。
- 忙则等待:当存在进程Pi访问临界区时,其他想要访问临界区的Pj会等待Pi退出临界区。
- 有限等待:因为当一个进程离开临界区时,它必须在上述循环次序中指定唯一的竞争进程作为其后继,所以任意一个要求进入临界区的进程最多在等待n-1个进程进入并离开临界区后就一定能够进入临界区,因而满足有限等待性原则。
- 让权等待:会出现忙式等待的情况,未能满足。
2.4 进程互斥的硬件实现
以上介绍的软件互斥算法一般适应于单处理器系统,在多处理器系统环境中,一个进程内部顺序执行的指令,可能由于满足 Bernstein 条件而被并行执行,而并行执行的效果可能是重排序(reordering)执行的结果。例如:Peterson算法中,P0进入临界区前要执行turn = 1, flag[0] = true,被重排序后就变成了flag[0] = true,turn = 0,如果此时P1没有被重排的话很可能导致二者都能进入临界区,从而导致临界区被多个进程同时访问。故在现代计算机体系结构下软件互斥算法很可能出现问题。
硬件对互斥的支持需要提供原子化的硬件指令,可直接用来实现互斥;也需要提供一般基础性支持,用以改进软件解法或硬件解法。
2.4.1 硬件提供存储障碍语句
存储障碍(memory barrier)语句形式抽象表示为memory barrier(),该指令出现在相继指令中间,保证前面指令先于后面指令执行。例如对于Peterson算法,可以这样使用:
flag[0] = 1;
memory_barrier();
turn = 1;
保证 flag[0] = 1 与 turn = 1 的次序不被重排序,从而保证算法在多处理器环境下的正确性。存储障碍语句也可以用在下面改进的基于test and set指令和swap 指令的互斥算法中,以保证满足互斥正确性。
2.4.2 硬件提供原子变量
对一个基本变量的访问与修改不被分割的变量称为原子变量(atomic variable)
我们看下面一条自加指令:
r <- count
r ++
count <- r1
如果三条指令被打断和其他对 count 的操作交替执行,则可能不正确。为此在现代计算机系统中都支持原子变量,如果count被说明为原子变量,则上述 3 条汇编指令不会被打断。有这样的硬件支持,实现互斥会容易一些,但请注意,原子变量并没有完全解决竞争条件问题。
2.4.3 中断屏蔽方法(开/关中断)
利用“开/关中断指令”实现(与原语的实现思想相同,即在某进程开始访问临界区到结束访问为止都不允许被中断,也就不能发生进程切换,因此也不可能发生两个同时访问临界区的情况)
...
关中断
critical section
开中断
...
优点:简单、高效
缺点:
- 不适用于多处理机;当前处理机关中断,其他处理机上的进程仍有可能访问临界区。
- 只适用于操作系统内核进程,不适用于用户进程(因为开/关中断指令只能运行在内核态,这组指令如果能让用户随意使用会很危险)
2.4.4 TestAndSet指令
测试并设置(Test And Set)指令,简称TS指令,又称Test And Set Lock指令或TSL指令。
TS指令实质上是取出内存某一单元(位)中的值,然后再给该单元(位)赋予一个新值。该指令在执行时是不可分割的,即为原子的(atomic)。
//lock为共享变量
bool TestAndSet(bool* lock){bool key = *lock;*lock = true;return key;
}while(TestAndSet(&lock)) ; //上锁并检查
critical section
lock = false; //解锁
remainder section
优点:
- 实现简单,无需像软件检查是否会有逻辑漏洞;
- 适用于多处理机环境
缺点:
- 不满足”让权等待“原则,暂时无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致”忙等“
2.4.5 Swap指令
有的地方也叫 Exchange指令,或简称 XCHG指令。
Swap指令是由硬件实现的,执行的过程中不允许被中断,只能一气呵成。
void Swap(int *a, int *b) {bool tmp = *a;*a = *b;*b = tmp;
}bool key = true;
while(key == true)Swap(&lock, &key);
critical section;
lock = false;
remainder section;
逻辑上来看 Swap 和 TSL 并无太大区别,都是先记录下此时临界区是否已经被上锁,再将上锁标记lock设置为true,最后检查key,如果key为false 则说明之前没有别的进程对临界区上锁,则可跳出循环,进入临界区。(可以形象地理解为钥匙只有一把,不断地取钥匙查看自己是否拿到了钥匙)
优点:
- 实现简单,无需像软件实现方法那样严格检查是否会有逻辑漏洞
- 适用于多处理机环境
缺点:不满足“让权等待”原则,暂时无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致“忙等”
2.5 互斥锁
2.5.1 互斥锁
解决临界区最简单的工具就是互斥锁(mutex lock)。一个进程在进入临界区时应获得锁;在退出临界区时释放锁。函数acquire()获得锁,而函数release()释放锁。
每个互斥锁有一个布尔变量 available,表示锁是否可用。如果锁是可用的,调用acqiure()会成功,且锁不再可用。当一个进程试图获取不可用的锁时,会被阻塞,直到锁被释放。
void acquire() {while(!available) ; //忙等available = false; //获得锁
}
void release() {available = true; //释放锁
}
acquire() 或 release()的执行必须是原子操作,因此互斥锁通常采用硬件机制来实现。
互斥锁的主要缺点是忙等待,当有一个进程在临界区中,任何其他进程在进入临界区时必须处于 acquire()的忙等状态。当多个进程共享同一CPU时,就浪费了CPU周期。因此,互斥锁通常用于多处理器系统,一个线程可以在一个处理器上等待,不影响其他线程的执行。
需要连续循环忙等的互斥锁,都可称为自旋锁(spin lock),如TSL指令,swap指令,单标志法。
特性:
- 需忙等,进程时间片用完才下处理机,违反“让权等待”
- 优点:等待期间不用切换进程上下文,多处理器系统中,若上锁的时间短,则等待代价很低
- 常用于多处理器系统,一个核忙等,其他核照常工作,并快速释放临界区,这样通过忙等避免了线程切换的代价。
- 不太适用于单处理机系统,忙等的过程中不可能解锁
三、进程同步
3.1 什么是进程同步
进程具有异步性的特征。异步性是指,各并发执行的进程以各自独立的、不可预知的速度向前推进。
我们看管道通信(后面进程通信会介绍,就是一种进程通信的方式)的例子,读进程和写进程并发地运行,由于并发必然导致异步性,因此 ”写数据“ 和 ”读数据“ 两个操作执行的先后顺序是不确定的。而实际运用中,又必须按照”写数据 -> 读数据“的顺序来执行。这就使进程同步要讨论的内容。
同步亦称直接制约关系,它是指为完成某种任务而建立的两个或多个进程,这些进程因为需要在某些位置上协调它们的工作次序而产生的制约关系。进程间的直接制约关系就是源于它们之间的相互合作。
一组进程,如果它们单独不能正常执行,但是并发却可以正常执行,这种现象称为进程合作(process cooperation)。参与合作的进程称为合作进程(cooperating process)。
3.2 信号量与 PV 操作
3.2.1 信号量与 PV 操作的定义
信号量和PV操作是荷兰著名学者 E.W.Dijkstra 于 1965 年提出的,是最早的也是最成功的同步机制。
这种同步机制包括一种称为信号量类型的变量以及对于此种变量所能进行的两个操作,即P(荷兰语proberen)操作和V(荷兰语 verhogen)。
由于荷兰语不够流行,所以有些书中将P操作称为Down操作,V操作称为Up操作。
信号量类型定义如下:
struct semaphore {int value;pointer_to_PCB queue;
}
//信号量变量
semaphore s;
可见,一个信号量变量包括两个部分,即值部分 s.value 和指针部分 s.queue。在任意时刻,s.queue 可能指向空,也可能指向一个由进程控制块所构成的队列的头部,如下图所示。初始时它指向空。
P V操作原语定义如下:
void P(semaphore *s) {s -> value --;if (s -> value < 0)asleep(s -> queue);
}void V(semaphore *s) {s -> value ++;if (s -> value <= 0)wakeup(s -> queue);
}
其中调用了 asleep
和 wakeup
这两个标准函数,它们的定义如下。
asleep(s -> queue)
: 执行此操作的进程的进程控制块进入队列 s->queue 的尾部,其状态由运行态转为等待态,系统转到处理器调度程序。wakeup(s -> queue)
:将队列 s->queue 头部进程的进程控制块由该队列中取出,并将其排入就绪队列,其状态由等待态转为就绪态。
一段不可间断执行的程序称为原语(primitive)。**P、V操作被定义为原语。**具体实现可以采用前面的软件互斥方式或者硬件互斥方式。
如果将信号量看作共享变量,则P操作和V操作为其临界区。为不发生与时间有关的错误,只需保证多个进程不对同一个信号量变量并发地执行 P 操作和 V 操作。不过,因为P操作和V操作的代码长度和执行时间都很短,为简单起见,实现时通常采用开关中断的进程互斥方法。
关于信号量变量的使用,有以下两个基本要求:
- 必须置一次初值,也只能置一次初值,而且初值必须为非负整数;
- 只能执行P操作和V操作,其他操作均是非法的。
根据上面对于PV操作的说明,可以得到一下几个结论:
- 当s -> value ≥ 0时,s->queue 为空。
- 当s -> value < 0时,|s -> value|为 s -> queue 中等待进程的个数。
- 当s -> value的初值为1时,可以用来实现进程互斥,这只需在进入临界区时执行一次P
3.2.2 信号量与 PV 操作的实现
开关中断实现方式
void P(semaphore *s) {disable interrupt;s -> value --;if (s -> value < 0) {set process status to blocked:place this process in s -> queue;enable interrupt;goto CPU dispatcher;}
}void V(semaphore *s) {disable interrupt;s -> value ++;if(s -> value <=0)remove a process from s -> queue;place this process on ready list;}elseenable interrupt;
}
test and set 实现方式
void P(semaphore *s) {while(TS(s -> flag));s -> value --;if(s -> value < 0){set process status to blocked:place this process in s -> queue;s -> flag = 0;goto CPU dispatcher;}elses -> flag = 0;
}void V(semaphore *s) {while(TS(s -> flag));s -> value --;if(s -> value <= 0){remove a process from s -> queue;place this process on ready list;}s -> flag = 0;
}
3.2.3 信号量与PV操作的应用
3.2.3.1 信号量实现进程互斥
-
分析并发进程的关键活动,划定临界区(如: 对临界资源打印机的访问就应放在临界区)
-
设置 互斥信号量 mutex,初值为 1
-
在进入区 P(mutex)——申请资源
-
在退出区 V(mutex)——释放资源
semaphore mutex = 1;
P1() {...P(mutex); //使用临界资源前加锁临界区代码段...V(mutex); //使用临界资源后解锁...
}
P2() {...P(mutex);临界区代码段...V(mutex);...
}
注意:
- 对不同的临界资源需要设置不同的互斥信号量。
- P、V操作必须成对出现。缺少P(mutex)就不能保证临界资源的互斥访问。缺少V(mutex)会导致资源永不被释放,等待进程永不被唤醒。
3.2.3.2 信号量机制实现进程同步
用信号量实现进程同步:
- 分析什么地方需要实现“同步关系”,即必须保证 “一前一后” 执行的两个操作(或两句代码)
- 设置同步信号量S,初始为 0
- 在“前操作”之后执行 V(S)
- 在“后操作”之前执行 P(S)
semaphore S = 0;
P1() {代码1代码2V(S);代码3
}
P2() {P(S);代码4代码5代码6
}
在上面这段代码逻辑中,只有P1释放了S后P2才能运行。保证了二者同步。
下面看一下信号量机制实现前驱关系
按如下前驱图所示的顺序来执行代码S1:进程P1中有句代码S1,P2中有句代码S2,P3中有句代码S3…P6中有句代码S6。这些代码要求按如下前驱图所示的顺序来执行:
其实每一对前驱关系都是一个进程同步问题(需要保证一前一后的操作)
因此,
- 要为每一对前驱关系各设置一个同步信号量
- 在“前操作”之后对相应的同步信号量执行V操作
- 在“后操作”之前对相应的同步信号量执行P操作
以下是代码逻辑。
其实很好写,写个拓扑序列然后每个结点给自己的扩展结点打个tag。
P1(){...S1;V(a);V(b);...
}
P2(){...P(a);S2;V(c);V(d);...
}
P3(){...P(b);S3;V(g);...
}
P4(){...P(c);S4;V(e);...
}
P5(){...P(d);S5;V(f);...
}
P6(){...P(e);P(f);P(g);S6;...
}
3.3 经典同步互斥问题
PV操作题目分析步骤:
- 关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。
- 整理思路。根据各进程的操作流程确定P、V操作的大致顺序。
- 设置信号量。并根据题目条件确定信号量初值。(互斥信号量初值一般为1,同步信号量的初始值要看对应咨源的初始值具多少)
3.3.1 生产者、消费者问题
问题描述:系统中有一组生产者进程和一组消费者进程,生产者进程每次生产一个产品放入缓冲区,消费者进程每次从缓冲区中取出一个产品并使用。(注:这里的 “产品” 理解为某种数据)
生产者、消费者共享一个初始为空、大小为n的缓冲区。
只有缓冲区没满时,生产者才能把产品放入缓冲区,否则必须等待。
只有缓冲区不空时,消费者才能从中取出产品,否则必须等待。
缓冲区是临界资源,各进程必须互斥地访问。
代码逻辑如下:
//同步信号量,S1,S2分别表示空闲缓冲区和物品
//读、写同步,生产、取同步
semaphore S1, S2;
semaphore mutex; //互斥信号量,对缓冲区的互斥访问
var B[k]; //缓冲区
int in, out; //缓冲区指针
producer() {while (true) {produce a product;P(S1);P(mutex);B[in] = product;in = (in + 1) % k;V(mutex);V(S2);}
}
consumer() {while (true) {P(S2);P(mutex);x = B[out];out = (out + 1) % k;V(mutex);V(S1);consume x;}
}
//main
S1.value = k, S2.value = 0;
mutex.value = 1;
in = 0, out = 0;
fork(producer, 0), ..., fork(producer, m - 1);
fork(consumer, 0), ..., fork(consumer, n - 1);
思考
-
能否改变相邻P操作的顺序?
- 不能。如果互斥的P操作在同步的P操作之前会发生死锁,可以自己试一下。
-
能否改变相邻V操作的顺序?
- 可以,V操作不会导致进程阻塞,可以交换。
-
consume x能否放在PV操作之间
- 不会产生逻辑错误,但是临界区上锁时间变长,等待开销变大了
并发性提高策略
生产者消费者不操作缓冲区B的相同分量
生产者共享变量:in
消费者共享变量:out
in = out说明缓冲区满或空
semaphore mutex1, mutex2;
//...
producer() {while (true) {produce a product;P(S1);P(mutex1);B[in] = product;in = (in + 1) % k;V(mutex1);V(S2);}
}
consumer() {while (true) {P(S2);P(mutex2);x = B[out];out = (out + 1) % k;V(mutex2);V(S1);consume x;}
}
//其他
mutex1.value = mutex2,value = 1;
3.3.2 吸烟者问题
问题描述:假设一个系统有三个抽烟者进程和三个供应者进程。每个抽烟者不停地卷烟并抽掉它,但是要卷起并抽掉一支烟,抽烟者需要有三种材料:tabacco(烟草)、match(火柴)和wrapper(包装纸)。三个抽烟者中,第一个拥有烟草、第二个拥有火柴、第三个拥有包装纸。三个供应者进程分别可以提供tabacco和match、tabacco和wrapper、match和wrapper,但是同一时刻只有一个供应者可以供应资源,只有一张桌子且桌子上最多只能有两个材料。只有当所提供的资源被消耗完了,三个供应者中能有一个可以继续。
吸烟者问题本质上也是生产者 - 消费者问题。
传统信号量代码逻辑如下:
semaphore t, m, w; //烟草、火柴、包装纸 0 0 0
semaphore s; //桌子 1
provider1 () {P(s);V(t);V(m);
}
provider2 () {P(s);V(t);V(w);
}
provider3 () {P(s);V(m);V(w);
}
smoker1() {P(m);P(w);smoke;V(s);
}
smoker2() {P(t);P(w);smoke;V(s);
}
smoker3() {P(t);P(m);smoke;V(s);
}
显然上面的代码是有漏洞的:假如某个供应者提供的两个材料分别被两个吸烟者获得,那么将导致死锁,无论如何改变抽烟者获取信号量的顺序都无法避免死锁的发生。如果两种资源同时申请(同时执行P操作),则可防止这一问题。为此需要扩展P操作,使其能够对多个信号量变量同时执行P操作和V操作,这就是 SP(simultaneousP,同时执行P操作)和SV(simultaneous V,同时执行V操作),它们作用在信号量集合上。
SP(S1, t1, d1;...;Sn, tn, dn) {if (S1 >= t1 && ... && Sn >= tn) {for (int i = 1; i <= n; i ++)Si -= i;}else {将运行进程的进程控制块连到第一个Si < ti的队列中;将该进程的指令计数器内容设置为SP操作的起始位置,使得当该进程重新执行时可以对所有等待条件重新进行评估;}
}
SV(S1, d1;...Sn, dn) {for (int i = 1; i <= n; i ++) {Si = Si + d;将Si队列上的所有进程控制块取出,并连到就绪队列中;}
}
上面Si
为信号量,ti
、di
为大于0的整数。当ti
和di
均为1时,称为AND
型信号量,是最常用的一种形式。应用上述 SP
操作可以给出吸烟者问题的正确解法如下。
shared semaphore t, m, w, s; // 0 0 0 1
provider1 () {P(s);SV(t, 1; m, 1);
}
provider2 () {P(s);SV(t, 1; w, 1);
}
provider3 () {P(s);SV(m, 1; w, 1);
}
smoker1() {SP(m, 1, 1; w, 1, 1);smoke;V(s);
}
smoker2() {SP(t, 1, 1; w, 1, 1);smoke;V(s);
}
smoker3() {SP(t, 1, 1; m, 1, 1);smoke;V(s);
}
3.3.3 读者、写者问题
问题描述:有读者和写者两组并发进程,共享一个文件,当两个或两个以上的读进程同时访问共享数据时不会产生副作用,但若某个写进程和其他进程(读进程或写进程)同时访问共享数据时则可能导致数据不一致的错误。
因此要求:
- 允许多个读者可以同时对文件执行读操作;
- 只允许一个写者往文件中写信息;
- 任一写者在完成写操作之前不允许其他读者或写者工作;
- 写者执行写操作前,应让已有的读者和写者全部退出。
代码逻辑如下:
int readCount = 0; //读者的数目
semaphore r_w_w = 1; //读者、写者以及写者、写者互斥
semaphore mutex = 1; //对readCount的互斥访问reader () {while (1) {// 其他操作P(&mutex);readCount = readCount + 1;if (readCount == 1) //第一个读者来处理读者写者间的互斥P(&r_w_w);V(&mutex);//读文件P(&mutex);readCount = readCount - 1;if (readCount == 0) //最后一个读者来解锁V(&r_w_w);V(&mutex);}
}writer () {while (1) {//其他操作P(&r_w_w);//修改操作V(&r_w_w);}
}// main
fork(reader, 0), ..., fork(reader, m - 1);
fork(writer, 0), ..., fork(wirter, n - 1);
还是比较简单的,但是上面的代码有个问题,就是如果读者源源不断地来,前面读者尚未离开,后面读者已经到达,这就会导致写者无限期等待的情况,陷入饥饿(starvation)。这不合理,因为写者更新数据应该优先进行,否则读者会读到过时信息(当然,实际情况中我们希望怎样的处理逻辑依情况而定)。
我们对上面算法进行改善:将逻辑改为读写公平
int readCount = 0; //读者的数目
semaphore r_w_w = 1; //读者、写者以及写者、写者互斥
semaphore mutex = 1; //对readCount的互斥访问
semaphore w = 1; //写优先reader () {while (1) {// 其他操作P(w);P(&mutex);readCount = readCount + 1;if (readCount == 1) //第一个读者来处理读者写者间的互斥P(&r_w_w);V(&mutex);V(w);//读文件P(&mutex);readCount = readCount - 1;if (readCount == 0) //最后一个读者来解锁V(&r_w_w);V(&mutex);}
}writer () {while (1) {P(w);//其他操作P(&r_w_w);//修改操作V(&r_w_w);V(w);}
}// main
fork(reader, 0), ..., fork(reader, m - 1);
fork(writer, 0), ..., fork(wirter, n - 1);
我们只是增加了一个信号量w来实现读写公平,我们通过下面几种情况来分析它是如何在满足读写公平且不破坏程序正确性的
- reader1 -> reader2
- 这个是显然正确的
- writer1 -> writer2
- 这个也显然正确
- writer1 -> reader1
- 写者先获取 w 和 r_w_w,写文件的时候读者会被阻塞,直到写者完成操作,释放两个信号量读者才能获取信号量开始操作。文件被写者和读者互斥访问
- reader1 -> writer1 -> reader2
- 读者先获取 w 和 r_w_w,对于writer1 和 reader2由于 writer1 先到达,所以 writer1 会比 reader2 先获取w,保证了读写公平,而读写互斥显然没有破坏
- writer1 -> reader1 -> writer2
- writer1 获取 w 和 r_w_w,然后reader1 一定会比 writer2先获取 w 和 r_w_w,然后writer2要等 read2 完成工作后才能获取 w
因此我们发现啊,每个读者都进程都是先获取 w ,完成读者计数后,在读文件前就释放 w 了,这保证即使 w 源源不断,如果有写者到来的话,写者是一定可以在某个读者释放 w 后 获取 w 的,而r_w_w又保证了写者写者和读者写者对于文件操作的互斥性。
事实上,我们发现读者写者对于 w 的获取是和它们的到达时间是有关的,这显然是公平的。
3.3.4 哲学家进餐问题
问题描述:一张圆桌上坐着5名哲学家,每两个哲学家之间的桌上摆一根筷子,桌子的中间是一碗米饭。哲学家们倾注毕生的精力用于思考和进餐,哲学家在思考时,并不影响他人。只有当哲学家饥饿时,才试图拿起左、右两根筷子(一根一根地拿起)。如果筷子已在他人手上,则需等待。饥饿的哲学家只有同时拿起两根筷子才可以开始进餐,当进餐完毕后,放下筷子继续思考。
我们观察下面这段代码逻辑有何问题:
semaphore chopsticks[5] = { 1, 1, 1, 1, 1 };
Pi() {while (true) {P(chopstick[i]); //拿左P(chopstick[(i + 1) % 5]); //拿右吃饭...V(chopstick[i]); //放左V(chopstick[(i + 1) % 5]); //放右思考...}
}
很可能发生每个人都拿起一根筷子然后陷入死锁……
我们尝试用三种策略避免死锁:
- 可以对哲学家进程施加一些限制条件,比如最多允许四个哲学家同时进餐。这样可以保证至少有一个哲学家是可以拿到左右两只筷子的
- 要求奇数号哲学家先拿左边的筷子,然后再拿右边的筷子,而偶数号哲学家刚好相反。用这种方法可以保证**如果相邻的两个奇偶号哲学家都想吃饭,那么只会有其中一个可以拿起第一只筷子,另一个会直接阻塞。**这样避免了占有一支后再等待另一只的情况
- 仅当一个哲学家左右两支筷子都可用时才允许他抓起筷子。这种方式虽然保证了正确性但是并发性较差。
下面我们通过1226. 哲学家进餐这道题来分别练习三种实现方式
c 内置信号量 sem_t,初始化信号量接口为:sem_init()
获取信号量接口:sem_wait()
释放信号量接口:sem_post()
解法1,最多四个同时吃:
class DiningPhilosophers {
sem_t fork[5], mutex;
public:DiningPhilosophers() {sem_init(&mutex, 0, 4);for (int i = 0; i < 5; i ++)sem_init(&fork[i], 0, 1);}void wantsToEat(int philosopher,function<void()> pickLeftFork,function<void()> pickRightFork,function<void()> eat,function<void()> putLeftFork,function<void()> putRightFork) {sem_wait(&mutex);sem_wait(fork + philosopher);sem_wait(fork + (philosopher + 1) % 5);pickLeftFork();pickRightFork();eat();putLeftFork();putRightFork();sem_post(fork + philosopher);sem_post(fork + (philosopher + 1) % 5);sem_post(&mutex);}
};
解法2,奇偶性:
class DiningPhilosophers {
sem_t fork[5];
public:DiningPhilosophers() {for (int i = 0; i < 5; i ++)sem_init(&fork[i], 0, 1);}void wantsToEat(int philosopher,function<void()> pickLeftFork,function<void()> pickRightFork,function<void()> eat,function<void()> putLeftFork,function<void()> putRightFork) {if(philosopher & 1) {sem_wait(fork + philosopher);sem_wait(fork + (philosopher + 1) % 5);}else {sem_wait(fork + (philosopher + 1) % 5);sem_wait(fork + philosopher);}pickLeftFork();pickRightFork();eat();putLeftFork();putRightFork();sem_post(fork + philosopher);sem_post(fork + (philosopher + 1) % 5);}
};
解法3,当左右都有才允许抓:
class DiningPhilosophers {
sem_t fork[5], mutex;
public:DiningPhilosophers() {sem_init(&mutex, 0, 1);for (int i = 0; i < 5; i ++)sem_init(&fork[i], 0, 1);}void wantsToEat(int philosopher,function<void()> pickLeftFork,function<void()> pickRightFork,function<void()> eat,function<void()> putLeftFork,function<void()> putRightFork) {sem_wait(&mutex);sem_wait(fork + philosopher);sem_wait(fork + (philosopher + 1) % 5);pickLeftFork();pickRightFork();sem_post(&mutex);eat();putLeftFork();putRightFork();sem_post(fork + philosopher);sem_post(fork + (philosopher + 1) % 5);}
};
3.3.5 3台打印机管理
问题描述: 设有3台类型相同的打印机,其编号分别为1、2、3,试编写一个申请函数 requrie 和 一个释放函数 return. 当有打印机空闲时, 返回分得的打印机的编号; 当无打印机空闲时则等待, 被唤醒后返回分得的打印机的编号. return 用于于释放指定编号的空闲打印机,当有申请者等待时就将其一唤醒。
代码逻辑如下:
其实经过前四个问题, 互斥同步的相关问题处理思路都已经很清晰了. 这个问题还是比较简单的.
enum state {free,used
} lp[4]; //initial value is free
semaphore S; //initial value is 3
semaphore mutex; //initial value is 1
int require () {P(S);P(mutex);int i = 1;for (i = 1; i <= 3; i ++)if (lp[i] == free)break;lp[i] = used;V(mutex);return i;
}void return (int i) {P(mutex);lp[i] = free;V(mutex);V(S);
}
四、管程
4.1 管程的引入
前面的信号量及PV操作属于分散式同步机制, 如果采用这种同步设施来编写并发程序,则对于共享变量及信号量变量的操作将被分散于各个进程当中。
其缺点是很明显的, 在学习前面的几种经典同步互斥问题中也能感受到:
- 可读性差: 检测共享变量和信号量操作是否正确需要通读整个系统
- 并发程序正确性不易保证: 程序规模很大,保证没有逻辑错误困难
- 不易修改: 程序的局部性差,任意一组变量或任意一段代码修改都影响全局
当然也有优点:
- 高效,灵活.
为了克服分散式同步机制的缺点,在20世纪70年代初期,以结构化程序设计和软件工程的思想为背景,E.W.Dijkstra、C.A.R.Hoare 和 P.B.Hansen 同时提出了管程(monitor)这种集中式同步设施.
管程是一种特殊的软件模块:
- 集中式同步机制: 共享变量及其所有相关操作集中在一个模块中。即将分散在各个进程中互斥访问公共变量的那些临界区集中起来,提供对它们的保护
- 优点:
- 可读性好: 模块通常比较短,之间联系清晰
- 正确性易于保证;
- 易于修改。
- 缺点:
- 不甚灵活,效率略低。
由于管程提出时 C 语言还未流行, 所以大多数管程以Pascal语言实现
4.2 管程的形式
管程一般形式如下:
type monitor_name = MONITOR;共享变量说明define 本管程内所定义、本管程外可调用的过程(函数)名表;use 本管程外所定义、本管程内将调用的过程(函数)名表;procedure 过程名(形参表);过程局部变量说明;begin语句序列;end;......function 函数名(形参表): 值类型;函数局部变量说明;begin语句序列;end;......begin共享变量初始化语句序列end;
- 其中,过程说明及函数说明可以有多个,并且它们可以任意交叉。如果所说明的过程(函数)名写在 define之后,则该过程(函数)被称为外部过程(函数)。外部过程(函数)可以在该管程的外部被调用。若过程(函数)名未出现在define之后,则该过程(函数)为局部过程(函数),对外部不可见
- 为保证对共享变量操作的数据完整性,规定管程互斥进入,每次仅允许一个进程在管程内执行某个外部过程,即进程互斥地通过调用外部过程进入管程,当某进程在管程内执行时,其它想进入管程的进程必须等待。
从语言的角度来看,管程有3个主要特性:
- 模块化,一个管程是一个模块;
- 抽象数据类型,管程是一种特殊的数据类型,其中不仅有数据,而且有对数据进行操作的代码;
- 信息掩蔽,管程是半透明的,管程中的外部过程(函数)实现了某些功能。至于这些功能是怎样实现的,在其外部则是不可见的。
4.3 管程语义
管程用于管理资源, 具有三种等待队列:
- 入口等待队列: 每个管程变量一个,用于排队进入;
- 条件等待队列: 每个管程变量一个,用于唤醒等待
- 紧急等待队列: var c: condition; 可根据需要定义多个,用于设置等待条件
条件变量操作
- Var c: condition;
- 条件型变量实际上是一个指针,它可以指向空,或者指向由进程控制块所构成队列的头部,初始时它指向空。
当一个进入管程的进程执行唤醒操作(如 P 唤醒 Q 等), 管程中便有了两个同时处于活动状态的进程,这时存在3种处理方法:
- Hoare管程的处理方式是指从条件队列中被唤醒的进程继续执行,执行唤醒操作的进程进入到紧急等待队列。当它从紧急队列被唤醒后,继续执行管程内的其它代码.
- Hansen管程的处理方式是被唤醒的进程继续执行,执行唤醒操作的进程离开管程,Signal是管程中的最后一条指令.
- Java管程的处理方式是执行唤醒操作的进程继续执行,被唤醒的进程等待, 当执行唤醒操作的进程执行完毕后, 被唤醒的进程开始执行。
不难发现, 第三种方法其实就是第一种方法的特例, 我们接下来只讨论第一种方法:
- wait©;
- 执行此操作的进程(线程)进入c链尾;
- 如紧急队列非空,唤醒第一个等待者,否则唤醒入口等待队列中的一个进程,并释放管程互斥权。
- signal©:
- 如c链空,相当空操作;
- 否则唤醒第一个,执行此操作的进程(线程)有3种方式.
在进程进入管程和离开管程时, 应当分别执行如下操作:
- 进入管程: 申请管程的互斥权
- 离开管程: 如果紧急等待队列非空,则唤醒第一个等待者,否则释放管程的互斥权。
4.4 管程的应用
4.4.1 管程解决生产者消费者问题
monitor ProducerConsumercondition full, empty; //条件变量来实现同步int Count = 0; //缓冲区中的产品数void insert (Item item) { //把产品item放入缓冲区if (count == N)wait(full);count ++;insert_item(item);if (count == 1)signal(empty);}Item remove () { //从缓冲区中取出一个产品if (count == 0)wait(empty);count --;if (count == N - 1)signal(full);return remove_item();}//生产者进程
producer() {while (true) {itme = 生产一个产品:ProducerConsumer.insert(item);s}
}
//消费者进程
consumer() {while (true) {item = ProducerConsumer.remove();消费产品item;}
}
上面的代码逻辑没有展示出各进程互斥访问管程, 事实上这一点有编译器完成.
每次仅允许一个进程在管程内执行某个内部过程。
4.4.2 Java中类似于管程的机制
Java中. 如果用关键字 synchronized 来描述一个函数,那么这个函数同一时间段内只能被一个线程调用
static class Monitor {private Item buffer[] = new Item[N];private int count = 0;public synchronized void insert (Item item) {......}
}
五、进程通信
5.1 进程通信的概念
进程之间的互斥、同步及信息交换统称为进程间通信(interprocess communication,IPC)
进程互斥与进程同步称为进程之间的低级通信,进程之间大量数据的传递称为进程之间的高级通信。
在一般场合下,进程间通信专指进程间的高级通信。下面主要讲述进程之间的高级通信模式。
5.2 进程间通信的模式
进程间通信主要有两种模式: 共享内存模式和消息传递模式。下面分别加以介绍。
5.2.1 共享内存模式
采用这种模式时,相互通信的进程之间要有公共内存,一组进程向该公共内存中写,另一组进程由该公共内存中读,如此便实现了进程之间的信息传递。
这种进程间通信模式需要解决以下两个问题:
- 为相互通信的进程之间提供公共内存。
- 为访问公共内存提供必要的同步机制。显然,公共内存等价于共享变量,对它的访问可能需要互斥机制,这需要操作系统提供互斥或同步机制。
5.2.2 消息传递模式
采用这种高级通信模式时,相互通信的进程之间并不存在公共内存。操作系统为用户进程间的通信提供两个基本的系统调用命令(又称原语),即发送命令(send)和接收命令(receive)。前者用于发送,后者用于接收。当需要进行消息传递时,发送者仅需执行发送命令,接收者仅需执行接收命令,消息便由发送者传送给接收者。
消息传递模式在实现时可以分为两种方式,分别称为直接方式和间接方式。下面分别加以叙述。
5.2.2.1 直接方式
所谓直接方式是指相互通信的进程之间在通信时直呼其名。也就是说,发送者在发送时要指定接收者的名字,接收者在接收时要指定发送者的名字。其系统调用主要有以下两种形式:
- 对称形式:一对一的,即发送者在发送时指定唯一的接收者,接收者在接收时指定唯一的发送者。
-
send(R, M);将消息M发送给进程R
-
receive(S, N);由进程S处接收消息至N
- 非对称形式:多对一的,即发送者在发送时指定唯一的接收者,接收者在接收时不指定具体的发送者。
-
send(R, M);将消息M发送给进程R
-
receive(pid, N);接收消息至N,返回时设pid为发送进程标识
无论对称形式还是非对称形式,在实现时都存在这样一个问题,即信息是如何由发送进程空间传送到接收进程空间的。这有两条途径,即有缓冲途径和无缓冲途径。下面分别加以介绍。
1.有缓冲途径
- 在操作系统空间中保存着一组缓冲区
- 发送消息:
- 发送进程在执行send系统调用命令时,产生自愿性中断进入操作系统,操作系统将为发送进程分配一个缓冲区,并将所发送的消息内容由发送进程空间复制到缓冲区中,然后将载有消息的缓冲区连到接收进程的消息链中。
- 如此就完成了消息的发送,发送进程返回到用户态,继续执行其下面的程序。
- 接收消息:
- 当接收进程执行到receive 系统调用命令时,也产生自愿性中断进入操作系统,操作系统将载有消息的缓冲区由消息链中取出,并将消息内容复制到接收进程空间中,然后收回该空闲缓冲区。
- 如此就完成了消息的接收,接收进程返回到用户态,继续执行其下面的程序。
当多个发送者同时向一个接收者发送消息时,它们均需对接收者的消息链实施操作。此外,当接收者接收消息时,也需要对该消息链实施操作。显然,这些操作应当是互斥进行的,因而需要一些信号量来完成消息通信的管理。
- Message passing, direct, non-symmetric, buffering
- 载有消息的缓冲:- Size:消息长度
- text:消息正文
- sender:消息发送者
- link:消息链指针
- 进程消息队列管理:- semaphore Sm(0); //管理消息- 收取消息前:P(Sm)- 消息入队后:V(Sm)
- 消息队列互斥- semaphore m_mutex(1);- P(m_mutex); 入队(出队)动作 V(m_mutex);
-
缓冲池
-
semaphore Sb(k), b_mutex(1); //k个缓冲区和1条链
-
申请:
- P(Sb);
- P(b_mutex);
- 头缓冲出链;
- V(b_mutex);
-
释放:
- P(b_mutex);
- 缓冲进入链头
- V(b_mutex)
-
发送-接收原语
-
Send(R, M)
- 根据R找接收者;
- P(Sb);
- P(b_mutex);
- 取一空buf;
- V(b_mutex);
- size, text, sender => buf
- P(m_mutex);
- 消息入链尾;
- V(m_mutex);
- V(Sm);
-
Recieve(pid, N)
- P(Sm);
- P(m_mutex);
- 头消息出链
- V(m_mutex);
- size, text => N
- sender => pid
- P(m_mutex)
- 空buf入链
- V(b_mutex);
- V(Sb);//释放消息缓冲区
-
强调一下:
- 对于发送命令来说,信息需要由发送进程空间复制到消息缓冲区中。对于接收命令来说,信息需要由消息缓冲区复制到接收进程空间中。发送进程空间和接收进程空间均属于用户区,而消息缓冲区、发送和接收进程均属于操作系统区,因此操作系统应当能访问用户区。
- send和receive属于高级通信原语,PV 操作属于低级通信原语(还记得高级通信和低级通信分别指的是什么吗(^U^)ノ~YO),用低级通信原语可以实现高级通信原语。
2.无缓冲途径
如果操作系统没有提供消息缓冲区,将由发送进程空间直接传送到接收进程空间,这个传送固然也是由操作系统完成的。
- 当发送进程执行到send 系统调用命令时,如果接收进程尚未执行receive系统调用命令,则发送进程将等待
- 反之,当接收进程执行到receive系统调用命令时,如果发送进程尚未执行send 系统调用命令,则接收进程将等待。
- 当发送进程执行到 send 系统调用命令且接收进程执行到receive 系统调用命令时,信息传输才真正开始,此时消息以字为单位由发送进程空间传送到接收进程空间中,由操作系统完成复制,传输时可以使用寄存器。
下面考虑用PV操作来实现进程间的缓冲直接通信。在进程控制块中应当保存两个信号量变量,其一用于接收者等待,设为S_m(初值为0):其二用于发送者等待,设为S_w(初值为0)。
消息的发送过程send(R,M)描述如下:
- 根据 R找到消息接收者。
- 发送消息进程数增1,如果接收进程等待,则将其唤醒,即执行V(Sm)。
- 等待消息传送完毕,即执行P(Sw)。
消息的接收过程receive(pid, N)描述如下。
- 等待消息到达,即执行P(Sm)。
- 将消息内容由发送进程空间传送到接收进程空间中。
- 唤醒发送消息的进程,即执行V(Sw)。
显然,与有缓冲途径相比,无缓冲途径的优点是节省空间,因为操作系统不需要提供缓冲区。
其缺点是并发性差,因为发送进程必须等到接收进程执行receive命令并将消息由发送进程空间复制到接收进程空间之后才能返回,以继续向前推进。
5.2.2.2 间接方式
所谓间接方式是指相互通信的进程之间在通信时不是直呼对方名字,而是指明一个中间媒体,即信箱,进程之间通过信箱来实现通信。此时,系统所提供的高级通信原语以信箱取代进程。
发送和接收原语如下:
send_MB(MB, M); //将消息M发送到信箱MB中
receive(MB, N); //由信箱 MB 中接收消息至 N。
在实现时,需要考虑信箱的存放位置,它既可以属于操作系统空间,也可以属于用户进程空间。
下面仅以属于操作系统空间的信箱为例来说明信箱通信的实现。
typedef struct {int in, out; //in the range of [0, k - 1]semaphore s1, s2;semaphore mutex;message letter[k];
} mailbox;
- in、out 分别为读指针、写指针,其初值均为0
- s1 和 s2为协调发送与接收进度的信号量变量,其初值分别为 k 和 0
- mutex 用于对信箱操作的互斥,其初值为1。
- 上述信息构成信箱头。letter为信箱体,可以保存k封信件。
信箱类型的变量是在操作系统区域内分配空间的
假如用户定义了变量
mailbox box
编译时并不为mb分配空间。在执行信箱创建这一系统调用命令时进入操作系统,由操作系统在系统区域内为mb分配空间并对其进行初始化
操作系统提供有关信箱的4个系统调用命令,分别用于创建信箱createMB、撤销信箱delete MB、向信箱发送信件 send MB 和由信箱接收信件receive MB。
发送和接收信件的系统调用命令分别定义如下:
send_MB(mailbox A; message M) {P(A.s1);P(A.mutex);A.letter[A.in] = M;A.in = (A.in + 1) % k;V(A.mutex);V(A.s2);
}
receive_MB(mailbox A; message N) {P(A.s2);P(A.mutex);N = A.letter[A.out] = M;A.in = (A.out + 1) % k;V(A.mutex);V(A.s1);
}
进程之间通过操作系统空间中的信箱发送和接收信件的原理如图