目录
一、进程同步
进程具有异步性的特征。异步性是指,各并发执行的进程以各自独立的、不可预知的速度向前推进。
Exp:
读进程和写进程并发地运行,由于并发必然导致异步性,因此“写数据”和“读数据”两个操作执行的先后顺序是不确定的。而实际应用中,又必须按照“写数据→读数据”的顺序来执行的。如何解决这种异步问题,就是“进程同步”所讨论的内容。
同步亦称直接制约关系,它是指为完成某种任务而建立的两个或多个进程,这些进程因为需要在某些位置上协调它们的工作次序而产生的制约关系。进程间的直接制约关系就是源于它们之间的相互合作。
简单来说就是:并发性带来了异步性,有时需要通过进程同步解决这种异步问题。
有的进程之间需要相互配合地完成工作,各进程的工作推进需要遵循一定的先后顺序。
二、进程互斥
进程的“并发”需要“共享”的支持。各个并发执行的进程不可避免的需要共享一些系统资源(比如内存,又比如打印机、摄像头这样的I/0设备)
两种资源共享方式:
互斥共享方式 | 系统中的某些资源,虽然可以提供给多个进程使用,但一个时间段内只允许一个进程访问该资源 |
同时共享方式 | 系统中的某些资源,允许一个时间段内由多个进程“同时”对它们进行访问 |
我们把一个时间段内只允许一个进程使用的资源称为临界资源。许多物理设备(比如摄像头、打印机)都属于临界资源。此外还有许多变量、数据、内存缓冲区等都属于临界资源。
对临界资源的访问,必须互斥地进行。互斥,亦称间接制约关系。进程互斥指当一个进程访问某临界资源时,另一个想要访问该临界资源的进程必须等待。当前访问临界资源的进程访问结束,释放该资源之后另一个进程才能去访问临界资源。
1.临界资源访问代码:
对临界资源的互斥访问,可以在逻辑上分为如下四个部分:
①进入区
负责检查是否可进入临界区,若可进入,则应设置正在访问临界资源的标志(可理解为“上锁”),以阻止其他进程同时进入临界区
②临界区
访问临界资源的那段代码
③退出区
负责解除正在访问临界资源的
标志(可理解为“解锁”)
④剩余区
做其他处理
注:
临界区是进程中访问临界资源的代码段。
进入区和退出区是负责实现互斥的代码段。
临界区也可称为“临界段”。
2.互斥准则:
为了实现对临界资源的互斥访问,同时保证系统整体性能,需要遵循以下原则:
①.空闲让进。
临界区空闲时,可以允许一个请求进入临界区的进程立即进入临界区:
②.忙则等待。
当已有进程进入临界区时,其他试图进入临界区的进程必须等待;
③.有限等待。
对请求访问的进程,应保证能在有限时间内进入临界区(保证不会饥饿);
④.让权等待。
当进程不能进入临界区时,应立即释放处理机,防止进程忙等待。
三、进程互斥的软件实现方法
1.单标志法
算法思想:两个进程在访问完临界区后会把使用临界区的权限转交给另一个进程。也就是说每个进程进入临界区的权限只能被另一个进程赋子。
在单标志法中,进程间通过一个共享变量turn来控制谁可以进入临界区。
#include <stdio.h>
#include <pthread.h>
int turn = 0; // 标志,0表示P0进程,1表示P1进程
void *process_P0(void *arg) {
while (1) {
while (turn != 0); // 等待 P1 交出权限
// 进入临界区
printf("P0 is in the critical section.\n");
// 离开临界区
turn = 1; // 交出权限给P1
}
}
void *process_P1(void *arg) {
while (1) {
while (turn != 1); // 等待 P0 交出权限
// 进入临界区
printf("P1 is in the critical section.\n");
// 离开临界区
turn = 0; // 交出权限给P0
}
}
int main() {
pthread_t t0, t1;
pthread_create(&t0, NULL, process_P0, NULL);
pthread_create(&t1, NULL, process_P1, NULL);
pthread_join(t0, NULL);
pthread_join(t1, NULL);
return 0;
}
2.双标志先检查
算法思想:设置一个布尔型数组flag0,数组中各个元素用来标记各进程想进入临界区的意愿,比如“flag[0]=ture”意味着0号进程 P0现在想要进入临界区。每个进程在进入临界区之前先检查当前有没有别的进程想进入临界区,如果没有,则把自身对应的标志fag[i]设为true,之后开始访问临界区。
每个进程通过flag数组表示是否想进入临界区,进入之前会检查对方是否有意图进入临界区。
#include <stdio.h>
#include <pthread.h>
int flag[2] = {0, 0}; // flag[0] 表示 P0 是否想进入,flag[1] 表示 P1
void *process_P0(void *arg) {
while (1) {
flag[0] = 1; // P0 想要进入
while (flag[1]); // 如果 P1 也想进入,等待
// 进入临界区
printf("P0 is in the critical section.\n");
// 离开临界区
flag[0] = 0; // 离开后,释放标志
}
}
void *process_P1(void *arg) {
while (1) {
flag[1] = 1; // P1 想要进入
while (flag[0]); // 如果 P0 也想进入,等待
// 进入临界区
printf("P1 is in the critical section.\n");
// 离开临界区
flag[1] = 0; // 离开后,释放标志
}
}
int main() {
pthread_t t0, t1;
pthread_create(&t0, NULL, process_P0, NULL);
pthread_create(&t1, NULL, process_P1, NULL);
pthread_join(t0, NULL);
pthread_join(t1, NULL);
return 0;
}
3.双标志后检查
算法思想:双标志先检查法的改版。前一个算法的问题是先“检査”后“上锁”,但是这两个操作又无法一气呵成,因此导致了两个进程同时进入临界区的问题。因此,人们又想到先“上锁”后“检查”的方法,来避免上述问题。
此方法在设定flag标志后再检查对方的标志,从而减少了竞争条件。
#include <stdio.h>
#include <pthread.h>
int flag[2] = {0, 0};
void *process_P0(void *arg) {
while (1) {
flag[0] = 1; // 先设置标志
while (flag[1]) { // 检查 P1 是否想进入
flag[0] = 0; // 暂时放弃
// 再次尝试进入
flag[0] = 1;
}
// 进入临界区
printf("P0 is in the critical section.\n");
// 离开临界区
flag[0] = 0;
}
}
void *process_P1(void *arg) {
while (1) {
flag[1] = 1; // 先设置标志
while (flag[0]) { // 检查 P0 是否想进入
flag[1] = 0; // 暂时放弃
// 再次尝试进入
flag[1] = 1;
}
// 进入临界区
printf("P1 is in the critical section.\n");
// 离开临界区
flag[1] = 0;
}
}
int main() {
pthread_t t0, t1;
pthread_create(&t0, NULL, process_P0, NULL);
pthread_create(&t1, NULL, process_P1, NULL);
pthread_join(t0, NULL);
pthread_join(t1, NULL);
return 0;
}
4.Peterson算法
算法思想:结合双标志法、单标志法的思想。如果双方都争着想进入临界区,那可以让进程尝试“孔融让梨”(谦让)。做一个有礼貌的进程。
Peterson 算法通过结合标志位和turn变量,确保进程间互斥访问。
#include <stdio.h>
#include <pthread.h>
int flag[2] = {0, 0}; // 进程想进入的标志位
int turn = 0; // 谁让出临界区的标志
void *process_P0(void *arg) {
while (1) {
flag[0] = 1;
turn = 1;
while (flag[1] && turn == 1); // 等待 P1 释放临界区
// 进入临界区
printf("P0 is in the critical section.\n");
// 离开临界区
flag[0] = 0;
}
}
void *process_P1(void *arg) {
while (1) {
flag[1] = 1;
turn = 0;
while (flag[0] && turn == 0); // 等待 P0 释放临界区
// 进入临界区
printf("P1 is in the critical section.\n");
// 离开临界区
flag[1] = 0;
}
}
int main() {
pthread_t t0, t1;
pthread_create(&t0, NULL, process_P0, NULL);
pthread_create(&t1, NULL, process_P1, NULL);
pthread_join(t0, NULL);
pthread_join(t1, NULL);
return 0;
}
四、进程互斥的硬件实现方法
1.中断屏蔽方法
中断屏蔽通过禁止中断实现进程互斥,但这个方法适用于单处理器系统。
void critical_section() {
disable_interrupts(); // 屏蔽中断
// 临界区操作
enable_interrupts(); // 恢复中断
}
2.TestAndSet(TS指令/TSL指令)
TestAndSet 是一种硬件指令,利用它可以实现互斥锁的获取和释放。
int lock = 0;
int TestAndSet(int *target) {
int old = *target;
*target = 1;
return old;
}
void enter_critical_section() {
while (TestAndSet(&lock) == 1); // 自旋等待
// 临界区操作
}
void exit_critical_section() {
lock = 0; // 释放锁
}
3.Swap指令(XCHG指令)
利用 Swap 指令,可以原子性地交换两个变量的值,从而实现互斥。
int lock = 0;
void Swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
void enter_critical_section() {
int key = 1;
while (key == 1) {
Swap(&lock, &key); // 自旋等待直到成功
}
// 临界区操作
}
void exit_critical_section() {
lock = 0; // 释放锁
}
五、软硬件方法是否满足互斥准则的分析:
在分析这些互斥算法是否满足互斥准则时,可以将四个标准分别解释如下:
- 空闲让进:当没有其他进程在临界区时,一个进程应当能够立即进入临界区。
- 忙则等待:如果另一个进程正在进入或已经在临界区,其他进程必须等待,不能进入临界区。
- 有限等待:每个进程在请求进入临界区后,必须能够在有限的时间内进入临界区,避免无限等待或饥饿问题。
- 让权等待:当一个进程无法进入临界区时,它应该主动放弃 CPU,以便其他进程能够继续执行,而不是无限自旋占用 CPU。
各个算法满足互斥准则的分析表
算法 | 空闲让进 | 忙则等待 | 有限等待 | 让权等待 |
单标志法 | ✅ | ✅ | ❌ | ❌ |
双标志先检查法 | ✅ | ✅ | ❌ | ❌ |
双标志后检查法 | ✅ | ✅ | ❌ | ❌ |
Peterson算法 | ✅ | ✅ | ✅ | ❌ |
中断屏蔽法 | ✅ | ✅ | ✅ | ❌ |
TestAndSet指令 | ✅ | ✅ | ❌ | ❌ |
Swap指令 | ✅ | ✅ | ❌ | ❌ |
详细分析:
- 单标志法:
- 空闲让进:如果对方没有进入临界区,当前进程可以立即进入。
- 忙则等待:如果对方正在使用临界区,当前进程会等待。
- 有限等待:无法保证有限等待,因为进程交替传递访问权,但如果某个进程长时间执行,可能会导致另一个进程无限等待。
- 让权等待:使用的是轮询等待(自旋锁),进程不会主动放弃 CPU。
- 双标志先检查法:
- 空闲让进:没有其他进程想进入时,当前进程可以进入。
- 忙则等待:当前进程会等待其他进程退出临界区。
- 有限等待:没有保证,因为两个进程可能同时设置标志位,造成“忙等”死锁。
- 让权等待:也是自旋锁,没有主动让出 CPU。
- 双标志后检查法:
- 空闲让进:如果没有其他进程在临界区,当前进程可以进入。
- 忙则等待:如果另一个进程设置了标志位,当前进程会等待。
- 有限等待:依然存在竞争条件,无法完全避免无限等待的可能。
- 让权等待:自旋等待,没有让权。
- Peterson算法:
- 空闲让进:如果对方没有设置标志,当前进程可以进入。
- 忙则等待:如果对方已经在临界区,当前进程会等待。
- 有限等待:Peterson 算法保证了进程不会无限等待,能够在有限的时间内进入临界区。
- 让权等待:没有主动让出 CPU,自旋等待。
- 中断屏蔽法:
- 空闲让进:无其他进程时,可以进入临界区。
- 忙则等待:当另一个进程进入临界区时,其他进程无法响应中断,因此无法进入。
- 有限等待:可以保证有限等待,因为进入临界区的进程可以禁用中断,保证临界区的独占性。
- 让权等待:没有自旋,但禁用中断导致其他进程无法运行,也不符合让权等待。
- TestAndSet指令:
- 空闲让进:当没有其他进程锁定时,当前进程可以进入临界区。
- 忙则等待:如果临界区被锁定,当前进程会一直尝试获取锁(自旋)。
- 有限等待:没有保证有限等待,可能会产生饥饿问题。
- 让权等待:是自旋锁,没有主动让出 CPU。
- Swap指令:
- 空闲让进:没有其他进程锁定时,当前进程可以进入临界区。
- 忙则等待:进程会一直尝试交换锁(自旋)。
- 有限等待:没有保证有限等待,可能会导致无限等待。
- 让权等待:同样是自旋锁,CPU 不会主动让出。
总结:
- Peterson算法 和 中断屏蔽法 都满足了前三个互斥准则,但它们的实现上,Peterson 算法依然是自旋锁,无法满足让权等待;中断屏蔽法则在单处理器系统上有效,但会阻塞其他进程的执行。
- 其余算法要么无法保证有限等待,要么通过自旋锁实现互斥,不能满足让权等待。
六、互斥锁
互斥锁(Mutex)是实现进程/线程间互斥访问共享资源的常用机制。它提供了一种通过锁定和解锁资源的方式,来确保一次只有一个线程可以进入临界区。
互斥锁的工作原理
- 当一个进程或线程需要进入临界区时,它会尝试获取互斥锁(锁定资源)。
- 如果锁未被其他进程或线程持有,当前进程可以进入临界区并锁定资源。
- 如果锁已被其他进程持有,当前进程将会被阻塞,直到锁被释放。
- 当进程离开临界区时,它会释放互斥锁,让其他等待的进程或线程继续执行。
互斥锁是否满足互斥准则分析
互斥准则 | 互斥锁 |
空闲让进 | ✅ |
忙则等待 | ✅ |
有限等待 | ✅ |
让权等待 | ✅ |
详细分析:
- 空闲让进:当没有其他线程持有锁时,任何线程都可以立即获取锁并进入临界区。因此,互斥锁满足“空闲让进”准则。
- 忙则等待:如果另一个线程持有互斥锁,任何其他线程都会被阻塞,直到锁被释放,因此满足“忙则等待”准则。
- 有限等待:互斥锁通常使用内核提供的阻塞机制,当线程无法获取锁时,它会被放入等待队列,等待锁的释放。由于使用的是阻塞机制而非自旋锁,线程会在有限时间内进入临界区,避免无限等待。因此,互斥锁满足“有限等待”准则。
- 让权等待:与自旋锁不同,互斥锁通常不进行自旋,而是主动让出 CPU,让其他线程继续执行。线程在无法获取锁时会进入等待状态,CPU资源不会被浪费。因此互斥锁满足“让权等待”准则。
互斥锁的实现代码示例(使用 Pthreads)
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t mutex; // 定义互斥锁
void* process(void* arg) {
int id = *((int*)arg);
while (1) {
pthread_mutex_lock(&mutex); // 获取互斥锁
printf("Thread %d is in the critical section.\n", id);
sleep(1); // 模拟临界区操作
printf("Thread %d is leaving the critical section.\n", id);
pthread_mutex_unlock(&mutex); // 释放互斥锁
sleep(1); // 模拟临界区外的操作
}
return NULL;
}
int main() {
pthread_t t1, t2;
int id1 = 1, id2 = 2;
pthread_mutex_init(&mutex, NULL); // 初始化互斥锁
pthread_create(&t1, NULL, process, &id1);
pthread_create(&t2, NULL, process, &id2);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_mutex_destroy(&mutex); // 销毁互斥锁
return 0;
}
互斥锁总结:
- 空闲让进:没有其他线程持有锁时,当前线程可以立即进入临界区。
- 忙则等待:锁被持有时,其他线程会阻塞等待。
- 有限等待:互斥锁使用的是阻塞机制,而不是自旋,因此可以保证线程在有限时间内获取锁。
- 让权等待:线程在等待锁时主动放弃 CPU,避免资源浪费。
互斥锁作为经典的进程同步和互斥机制,能够有效满足四个互斥准则,避免了自旋锁的资源浪费问题,是多线程编程中非常常用的同步手段。
七、信号量
信号量机制是一种功能较强的机制,可用来解决互斥与同步问题,它只能被两个标准的原语wait()和 signal( )访问,也可简写为 P()和 V(),或者简称 P操作和 V 操作。
原语是指完成某种功能且不被分割、不被中断执行的操作序列,通常可由硬件来实现。例如,前述的TS指令和 Swap指令就是由硬件实现的原子操作。原语功能的不被中断执行特性在单处理机上可由软件通过屏蔽中断方法实现。原语之所以不能被中断执行,是因为原语对变量的操作过程若被打断,可能会去运行另一个对同一变量的操作过程,从而出现临界段问题。
1.整型信号量
整型信号量被定义为一个用于表示资源数目的整型量S,相比于普通整型变量,对整型信号量的操作只有三种:初始化、wait 操作和signa操作。wait 操作和 signal 操作可描述为:
wait(S){ //相当于进入区
while(S<=0); //若资源数不够,则一直循环等待
S=S-1; //若资源数够,则占用一个资源
}
signal(S){ //相当于退出区
S=S+1; //使用完后,就释放一个资源
}
在整型信号量机制中的 wait 操作,只要信号量 S<0,就会不断循环测试。因此,该机制并未遵循“让权等待”的准则,而是使进程处于“忙等”的状态。
2.记录型信号量
记录型信号量机制是一种不存在“忙等”现象的进程同步机制。除了需要一个用于代表资源数目的整型变量 value 外,再增加一个进程链表L,用于链接所有等待该资源的进程。记录型信号量得名于采用了记录型的数据结构。记录型信号量可描述为
typedef struct {
int value;
struct process *L;
} semaphore;
相应的 wait(S)和 signal(S)的操作如下。
void wait(semaphore S){ //相当于申请资源
S.value--;
if(S.value<0){
add this process to S.L;
block(S.L);
}
}
对信号量S的一次P操作,表示进程请求一个该类资源,因此执行S.value--,使系统中可供分配的该类资源数减1。当S.value<0时,表示该类资源已分配完毕,因此应调用block 原语进行自我阻塞(当前运行的进程:运行态一阻塞态),主动放弃CPU,并插入该类资源的等待队列S.L,可见该机制遵循了“让权等待”准则。
void signal(semaphore s){//相当于释放资源
S.value++;
if(S.value<=0){
remove a process P from S.L;
wakeup(P);
}
对信号量S的一次 V操作,表示进程释放一个该类资源,因此执行S.value++,使系统中可供分配的该类资源数加1。若加1后仍是S.value<0,则表示仍有进程在等待该类资源,因此应调用 wakeup 原语将 SL中的第一个进程唤醒(被唤醒进程:阻塞态一就绪态)。
3.信号量实现互斥
为了使多个进程能互斥地访问某个临界资源,需要为该资源设置一个互斥信号量S,其初值为1(可用资源数为1),然后将各个进程访问该资源的临界区置于 P(S)和 V(S)之间。这样,每个要访问该资源的进程在进入临界区之前,都要先对S执行P操作,若该资源此刻未被访问,则本次P操作必然成功,进程便可进入自己的临界区。这时,若再有其他进程也要进入自己的临界区,由于对S执行P操作必然失败,因此主动阻塞,从而保证了该资源能被互斥访问。当访问该资源的进程退出临界区后,要对S执行V操作,以便释放该临界资源。其实现如下:
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
sem_t mutex; // 定义一个信号量,用于互斥
void* process(void* arg) {
// P操作,申请资源(进入临界区)
sem_wait(&mutex); // 当信号量的值为0时,进程会被阻塞在这里
// 临界区
printf("进程 %ld 进入临界区\n", (long)arg);
sleep(1); // 模拟在临界区的操作
// V操作,释放资源(离开临界区)
printf("进程 %ld 离开临界区\n", (long)arg);
sem_post(&mutex); // 增加信号量的值,允许其他进程进入临界区
return NULL;
}
int main() {
pthread_t t1, t2;
// 初始化信号量,初始值为1,表示临界资源可用
sem_init(&mutex, 0, 1);
// 创建两个线程,模拟两个进程
pthread_create(&t1, NULL, process, (void*)1);
pthread_create(&t2, NULL, process, (void*)2);
// 等待线程结束
pthread_join(t1, NULL);
pthread_join(t2, NULL);
// 销毁信号量
sem_destroy(&mutex);
return 0;
}
S 的取值范围为(-1,0,1)。当S=1时,表示两个进程都未进入临界区;当S=0时,表示有一个进程已进入临界区;当S=-1时,表示有一个进程正在临界区,另一个进程因等待而阻塞在阻塞队列中,需要被当前已在临界区中运行的进程退出时唤醒。
4.信号量实现同步
同步源于进程之间的相互合作,需要让本来异步的并发进程相互配合,有序推进。例如,进程P,和P,并发执行,由于存在异步性,因此二者推进的次序是不确定的,若P,的语句v要使用P的语句x的运行结果,则必须保证语句y一定在语句x之后执行。为了实现这种同步关系,需要设置一个同步信号量S,其初值为0(可以这么理解:刚开始没有这种资源,P,需要使用这种资源,而它又只能由P产生这种资源)。其实现如下:
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
sem_t syncS; // 定义一个同步信号量
void* P1(void* arg) {
// 进程 P1 的操作,执行语句 x
printf("P1 执行语句 x\n");
sleep(1); // 模拟语句 x 的执行时间
// V 操作,通知 P2 可以继续
sem_post(&syncS);
printf("P1 完成,允许 P2 执行\n");
return NULL;
}
void* P2(void* arg) {
// P 操作,等待 P1 完成
sem_wait(&syncS); // 只有 P1 执行完 V 操作后,P2 才能继续
printf("P2 执行语句 y\n");
return NULL;
}
int main() {
pthread_t t1, t2;
// 初始化同步信号量,初始值为0,表示 P2 需要等待 P1 的通知
sem_init(&syncS, 0, 0);
// 创建两个线程,模拟两个进程
pthread_create(&t1, NULL, P1, NULL);
pthread_create(&t2, NULL, P2, NULL);
// 等待线程结束
pthread_join(t1, NULL);
pthread_join(t2, NULL);
// 销毁信号量
sem_destroy(&syncS);
return 0;
}
5.信号量实现前驱
信号量也可用来描述程序或语句之间的前驱关系。图中给出了一个前驱图,其中 S1,S2, S3,…S6是简单的程序段(只有一条语句)。
其实,每对前驱关系都是一个同步问题,因此要为每对前驱关系设置一个同步信号量,其初值均为0,在“前驱操作”之后,对相应的同步信号量执行V操作,在“后继操作”之前,对相应的同步信号量执行P操作。为保证S1→S2,S1→S3,S2-S4,S2→S5,S4→S6,S5→S6,S3→S6,的前驱关系,需分别设置同步信号量 a12,a13,a24,a25,a36,a46,a56。其实现如下:
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
// 定义同步信号量
sem_t a12, a13, a24, a25, a36, a46, a56;
// S1 操作,作为S2和S3的前驱
void* S1(void* arg) {
printf("S1 执行\n");
sleep(1); // 模拟任务执行
sem_post(&a12); // 释放S2可以执行
sem_post(&a13); // 释放S3可以执行
printf("S1 完成,允许S2和S3执行\n");
return NULL;
}
// S2 操作,作为S4和S5的前驱
void* S2(void* arg) {
sem_wait(&a12); // 等待S1完成
printf("S2 执行\n");
sleep(1); // 模拟任务执行
sem_post(&a24); // 释放S4可以执行
sem_post(&a25); // 释放S5可以执行
printf("S2 完成,允许S4和S5执行\n");
return NULL;
}
// S3 操作,作为S6的前驱之一
void* S3(void* arg) {
sem_wait(&a13); // 等待S1完成
printf("S3 执行\n");
sleep(1); // 模拟任务执行
sem_post(&a36); // 释放S6可以执行
printf("S3 完成,允许S6执行\n");
return NULL;
}
// S4 操作,作为S6的前驱之一
void* S4(void* arg) {
sem_wait(&a24); // 等待S2完成
printf("S4 执行\n");
sleep(1); // 模拟任务执行
sem_post(&a46); // 释放S6可以执行
printf("S4 完成,允许S6执行\n");
return NULL;
}
// S5 操作,作为S6的前驱之一
void* S5(void* arg) {
sem_wait(&a25); // 等待S2完成
printf("S5 执行\n");
sleep(1); // 模拟任务执行
sem_post(&a56); // 释放S6可以执行
printf("S5 完成,允许S6执行\n");
return NULL;
}
// S6 操作,需等待S3、S4、S5完成
void* S6(void* arg) {
sem_wait(&a36); // 等待S3完成
sem_wait(&a46); // 等待S4完成
sem_wait(&a56); // 等待S5完成
printf("S6 执行\n");
sleep(1); // 模拟任务执行
printf("S6 完成\n");
return NULL;
}
int main() {
pthread_t t1, t2, t3, t4, t5, t6;
// 初始化同步信号量,初值为0
sem_init(&a12, 0, 0);
sem_init(&a13, 0, 0);
sem_init(&a24, 0, 0);
sem_init(&a25, 0, 0);
sem_init(&a36, 0, 0);
sem_init(&a46, 0, 0);
sem_init(&a56, 0, 0);
// 创建线程,模拟各个操作
pthread_create(&t1, NULL, S1, NULL);
pthread_create(&t2, NULL, S2, NULL);
pthread_create(&t3, NULL, S3, NULL);
pthread_create(&t4, NULL, S4, NULL);
pthread_create(&t5, NULL, S5, NULL);
pthread_create(&t6, NULL, S6, NULL);
// 等待线程结束
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
pthread_join(t5, NULL);
pthread_join(t6, NULL);
// 销毁信号量
sem_destroy(&a12);
sem_destroy(&a13);
sem_destroy(&a24);
sem_destroy(&a25);
sem_destroy(&a36);
sem_destroy(&a46);
sem_destroy(&a56);
return 0;
}
八、管程(monitor)
在信号量机制中,每个要访问临界资源的进程都必须自备同步的PV操作,大量分散的同步操作给系统管理带来了麻烦,且容易因同步操作不当而导致系统死锁。于是,便产生了一种新的进程同步工具——管程。管程的特性保证了进程互斥,无须程序员自己实现互斥,从而降低了死锁发生的可能性。同时管程提供了条件变量,可以让程序员灵活地实现进程同步。
1.管程的组成
①管程的名称;
②局部于管程内部的共享数据结构说明:
③对该数据结构进行操作的一组过程(或函数);
④对局部于管程内部的共享数据设置初始值的语句。
管程的定义描述举例如下:
Monitor Demo{//①定义一个名称为 Demo 的管程
//②定义共享数据结构,对应系统中的某种共享资源
共享数据结构S;
//④对共享数据结构初始化的语句
init code (){
S=5; //初始资源数等于5
}
take away(){//③过程 1:申请一个资源
对共享数据结构x的一系列处理;
S--; //可用资源数-1
…
}
give back(){ //③)过程 2:归还一个资源
对共享数据结构x的一系列处理;
S++; //可用资源数+1
…
}
}
2.条件变量
当一个进程进入管程后被阻塞,直到阻塞的原因解除时,在此期间,如果该进程不释放管程,那么其他进程无法进入管程。为此,将阻塞原因定义为条件变量condition。通常,一个进程被阳塞的原因可以有多个,因此在管程中设置了多个条件变量。每个条件变量保存了一个等待队列,用于记录因该条件变量而阻塞的所有进程,对条件变量只能进行两种操作,即wait 和signal。
x . wait: | 当x对应的条件不满足时,正在调用管程的进程调用 x.wait 将自己插入x条件的等待队列,并释放管程。此时其他进程可以使用该管程。 |
x . signal | x对应的条件发生了变化,则调用x.signal,唤醒一个因x条件而阻塞的进程。 |
下面给出条件变量的定义和使用:
monitor Demo{
共享数据结构S;
condition x; //定义一个条件变量x
init code(){ ... }
take away(){
if(S<=0)x.wait(); //资源不够,在条件变量x上阻塞等待
资源足够,分配资源,做一系列相应处理;
}
give back(){
归还资源,做一系列相应处理;
if(有进程在等待)x.signal();//唤醒一个阻塞进程
}
}
条件变量和信号量的对比:
相似点: | 条件变量的 wait/signal 操作类似于信号量的 P/V 操作,可以实现进程的阻塞/唤醒。 |
不同点: | 条件变量是“没有值”的,仅实现了“排队等待”功能; |
而信号量是“有值”的,信号量的值反映了剩余资源数,而在管程中,剩余资源数用共享数据结构记录。 |