并发程序设计: 把一个具体问题求解设计成若干个可同时执行的程序模块的方法
并发设计举例
先是最简单的顺序程序设计。
不难发现,处理器利用率为, 52 78 + 52 + 20 ≈ 35 % \displaystyle\frac{52}{78+52+20}\approx 35\% 78+52+2052≈35%。因此我们对上述过程进行改进,引入并发程序设计,如下所示。
在这种设计方式中,我们可以得到处理器利用率为, 52 ∗ n 78 ∗ n + 52 + 20 ≈ 67 % \displaystyle\frac{52*n}{78*n+52+20}\approx 67\% 78∗n+52+2052∗n≈67%。
特性
从上述例子中,我们很明显地看到了并发程序设计的优势,因此我们来总结这种程序设计方式的特性。
就是这个交往性导致了并发程序设计中关于进程互斥与同步的问题。
无关与交往的并发进程
无关的并发进程:
交往的并发进程:
进程互斥问题
进程互斥: 并发进程之间因相互争夺独占性资源而产生的竞争制约关系
如在上图的 if 部分,进程 1 执行到此处恰好被中断,Xi 还没有变化,因此进程 2 进入卖掉了票。之后轮到进程 1 执行,进程 1 也把票卖了,造成了错误。
进程同步问题
进程同步: 并发进程之间为完成共同任务基于某个条件来协调执行先后关系而产生的协作制约关系。
如上述的进程获取与释放资源的同步过程。在分配函数中,进程 1 进入 if,但没有进入队列时被中断。此时进程 2 释放了所有进程,然后回到进程 1 进入等待队列,此时进程 1 将会永远等待。
临界资源
临界资源: 互斥共享变量所代表的资源,即一次只能被一个进程使用的资源。
临界区
临界区: 并发过程中与互斥共享变量相关的程序段。
如果两个进程同时停留在相关的临界区内,就会出现进程互斥或同步问题。
临界区的描述
shared <variable>
region <variable> do <statement_list>
临界区管理的三个要求
临界区的嵌套使用
如果要对临界区进行嵌套使用,则必须规定各临界区资源的优先级,否则就会出现下述的死锁情况。
错误想法: 利用一个标志量来标记进程是否在临界区中,并与 while 一同使用。
尝试一
看上去没有问题,但是会导致两个进程同时进入临界区。
尝试二
下述方式依然存在问题,两个进程会陷入死锁。
根据两次尝试,我们可以发现上述的赋值和 while 两条指令的执行过程不能发生中断,否则就会出现错误。
因此我们将赋值与 while 两条指令进行合并,得到了测试并建立指令。
或稍微进行改进,形成 swap 指令。
不难发现,两条指令虽然保证程序不会出问题,但会导致忙式等待,非常降低程序效率。
还有一个非常简单的方法是在进出临界区时开关中断,这样临界区执行就不会发生中断了,执行具有原子性。
关中断;
临界区
开中断;
缺点
这种方式仅适用于系统内部程序的临界区操作手段,而且要保证指令长度短小精悍。
如果把开关中断权限开放给用户,可能会发生滥用,即造成巨大破坏性。
为了改进 TS 或开关中断方式,我们引入 PV 操作。
信号量
核心数据结构: 等待进程队列
信号量声明: 资源报到,建立队列
申请资源原语: 若申请不到,调用进程入队等待
归还资源原语: 若队列中有等待进程,需释放
信号量撤销: 资源注销,撤销队列
typedef struct semaphore{
int value; // 信号量值
struct pcb *list; // 等待进程队列指针
}
PV 操作
procedure P(semaphore:s) {
s = s - 1; // 信号量减 1
if (s < 0) W(s); // 若信号量小于 0,则进程进入等待队列
}
procedure V(semaphore:s) {
s = s + 1; // 信号量加 1
if (s <= 0) R(s); // 若信号量小于等于 0,则释放等待队列中一个进程
}
我们可以利用 PV 操作来解决进程互斥问题。
Semaphore s;
s = 1;
cobegin
process Pi {
...
P(s);
临界区
V(s);
...
}
coend;
PV 操作解决机票问题
int A[m];
Semaphore s;
s = 1;
cobegin
process Pi {
Li: 按旅客订票要去找到 A[j]
P(s);
if ( A[j] >= 1 ) {
A[j]--;
V(s);
// 输出一张票
}
else {
V(s);
// 输出票已售完
}
goto Li;
}
coend;
注意上述代码中,P 操作与 V 操作在执行路径上一一匹配。除此之外,上述代码还有一个问题,即只有相同航班的票数才是相关的临界资源,因此用一个信号量处理全部机票会影响进程并发度,下述代码为修改后的代码。
int A[m];
Semaphore s[m];
for (int j = 0; j < m; j++) s[j] = 1;
cobegin
process Pi {
L1: 按旅客订票要去找到 A[j]
P(s[j]);
if ( A[j] >= 1 ) {
A[j]--;
V(s);
// 输出一张票
}
else {
V(s);
// 输出票已售完
}
goto Li;
}
coend;
进程同步: 并发进程为完成共同任务基于某个条件来协调执行先后关系而产生的协作制约关系
生产者、消费者共享缓冲区。
缓冲区扩大了,因此需要加上缓冲区首尾指针。
由于生产者和消费者数量增加,因此缓冲区的首尾指针也需要保证互斥性。
该问题有个注意点,先同步后互斥,否则会死锁。如果是先互斥再同步的话,会出现生产者把缓冲区生产满了,此时先拿锁,然后进入等待队列,而消费者拿不到锁也没办法消费,导致死锁。
如果盘子里面可以放多个水果的话,首先扩大缓冲区,其次令 sp = n,然后设置一个缓冲区锁,每次只能有一个人放或取东西。每次拿或取的时候采用遍历搜寻的方式。
接下来需要考虑 P(s) 放的位置,P(s) 互斥操作要在同步操作之后,否则会发生错误。V(s) 操作无先后关系。
概念提出
管程本质是对信号量的类封装,简化操作。
管程的条件变量
执行模型
注意,管程中一共有 3 个等待进程队列。由于进程只能互斥地调用管程中的过程,因此有了互斥调用管程过程队列。
又因为当前在管程中的进程一旦调用 signal,则会有两个进程在管程中,所以又有一个更高优先级的互斥调用队列。
signal 问题
当使用 signal 释放一个等待进程时,可能出现两个进程同时停留在管程内,此时有三种解决方法。
霍尔管程
使用 signal 释放一个等待进程时,霍尔管程让执行 signal 的进程等待,直到被释放进程退出管程或等待另一个条件。
霍尔管程基于 PV 操作原语实现。
信号量
两个互斥信号量。
if (IM.next_count > 0) V(IM.next);
else V(IM.mutex);
条件变量
x_sem: semaphore; // 与资源相关的信号量
x_count: integer; // 在 x_sem 上等待的进程数
Wait
void Wait(semaphore, x_count, IM){
x_count++; // 等待数加上自己
if(IM.next_count > 0) V(IM.next);
else V(IM.mutex);
P(x_sem); // 挂起自己
x_count--; // 等待数减去自己
}
Signal
void Signal(semaphore, x_count, IM){
if(x_count > 0){
IM.next_count++; // 进程队列加入自己
V(x_sem); // 信号量操作
P(IM.next); // 将自己阻塞住,先执行被 signal 的进程
IM.next_count--; // 进程队列去除自己
}
}
问题描述
霍尔管程方法
其中 s 是盘子状态。
问题描述
霍尔管程
交往进程通过信号量操作实现进程互斥和同步,这是一种低级通信方式。
进程有时还需要交换更多的信息(如何把数据传送给另一个进程),可以引入高级通信方式 —— 进程通信机制,实现进程间用信件来交换信息。
进程通信扩充了并发进程的数据共享。
发送或接收信件的进程指出信件发给谁,或从谁那里接收信件。
间接通信概念
发送或者接收信件通过一个信箱来进行,该信箱有唯一标识符
多个进程共享一个信箱
信箱
信箱是存放信件的存储区域,每个信箱可以分成信箱特征和信箱体两部分。
发送信件原语流程
接收信件原语流程
这里需要主要一下死锁防止和死锁避免的区别,下面给出一段英文描述。
The main difference between deadlock prevention and deadlock avoidance is that deadlock prevention ensures that at least one of the necessary conditions to cause a deadlock will never occur while deadlock avoidance ensures that the system will not enter an unsafe state.
关键点在于死锁防止是从根本上解决死锁问题,而死锁避免是人为地尽可能避开,还是有出意外的可能。
一组进程处于死锁状态是指,每一个进程都在等待被另一个进程所占有的、不能抢占的资源。
独占型资源改为共享(破坏条件1)
独占变共享,可以破坏条件 1,但这对许多资源往往是不能做到的。
剥夺式调度(破坏条件3)
剥夺式调度目前只适用于对主存资源和处理器资源的分配,而不适用于所有资源。
静态分配(预分配 - 破坏条件2)
具体方法: 一个进程必须在执行前就申请它所要的全部资源,并且直到它所要的资源都得到满足之后才开始执行。
缺点: 可能会出现一种情况。进程执行时,资源 A 只使用了一小段时间,但在整个执行过程中,资源 A 始终被占用,造成资源利用率的低效。
层次分配(破坏条件4)
具体方法: 将资源分成多个层次。一个进程得到某一层的一个资源后,它只能再申请在较高层的资源,当一个进程要释放某层的一个资源时,必须先释放所占用的较高层的资源。当一个进程获得了某一层的一个资源后,它想再申请该层中的另一个资源,那么必须先释放该层中的已占资源。
缺点: 低层次资源使用效率依然较低。
当不能防止死锁的产生时,如果能掌握并发进程中与每个进程有关的资源申请情况,仍然可以避免死锁的发生。
只需在为申请者分配资源前先测试系统状态,若把资源分配给申请者会产生死锁的话,则拒绝分配,否则接收申请,为它分配资源。
银行家算法: 借钱给有偿还能力的客户
注意: 这种判断不仅是对单个用户的判断,而是对全体用户的判断。可以采用循环探测方法来判断。
例如下述系统,一共有三个进程 P、Q、R,系统只有 10 个资源。
则此时只有 Q 申请资源,系统才会予以分配,其余进程申请均不会分配。
死锁的检测对资源的分配不加限制,但系统定时运行一个 “死锁检测” 程序,判断系统内是否已出现死锁,若检测到死锁则设法加以解除。
检测方法
因此可以根据上述两张表构建出一张死锁的有向图,若 P i P_i Pi 等待资源 r k r_k rk,且 r k r_k rk 被进程 P j P_j Pj 占用,则 P i P_i Pi 和 P j P_j Pj 具有 “等待占用关系”,记为 W ( P i , P j ) W(P_i,P_j) W(Pi,Pj)。
我们可以构造一个二维矩阵,运行传递必包算法,如果对角线为 1,则存在环,出现死锁。也可以用拓扑排序的方法,O(n) 判环。
for(int k = 1; k <= n; k++)
for(int i = 1; i <= n; i++)
for(int j = 1; j <= n; j++)
b[i][j] = b[i][j] | (b[i][k] & b[k][j]);
死锁后的解决方法