进程同步、互斥与信号量

进程同步

由于进程调度的存在,进程具有异步性的特征,即并发执行的进程以各自独立、不可预知的速度向前推进,进程之间没有明确的先后执行顺序。

而像管道通信这种进程通信方式,分别写数据和读数据的两个进程必须按照“写数据->读数据”的顺序来执行,这就是进程同步同步亦称直接制约关系,指多个进程在某些位置上协同(制约)它们的工作次序,以实现进程之间的合作。

进程互斥

进程并发执行不可避免地需要共享一些资源(比如内存数据、打印机)。资源有两种共享方式:互斥共享同时共享。互斥共享是一个时间段只能有一个进程访问该资源,同时共享是一个时间段允许多个进程"(宏观上)同时”访问该资源。

临界资源:一个时间段内只允许一个进程使用的资源。对临界资源的访问必须互斥地进行。互斥,亦称间接制约关系,当一个进程访问完临界资源后另一个进程才能访问该资源,否则另一个进程就需要等待。

对临界资源的互斥访问,可以在逻辑上分为如下四个部分:

do{ 
	entry section;    // 进入区:检查是否可以进入临界区,能进入就上锁,不能进入就等待
    critical section; // 临界区:访问临界资源的代码
    exit section;	  // 退出区:解锁
    remainder section;// 剩余区:其他处理
} while(true)

进入区和退出区是对临界区的保护,即实现互斥的代码段。进入区和退出区对临界区进行上锁和解锁,以此来实现对临界区的互斥访问。而设计进程互斥(上锁和解锁的过程)算法就是设计进入区和退出区。其目标是让进入区和退出区能对临界区进行保护,简单的方法就是让进入区和退出区的代码都是原子操作(一气呵成,不发生中断,即不发生进程调度),复杂的方法就是设计算法让进程调度不打乱上锁和解锁的过程。

对临界资源的访问(进程互斥),应遵循一下原则:

  1. 空闲让进。临界区空闲时允许一个进程进入
  2. 忙则等待。空闲区已经有一个进程了,其他试图进入空闲区的进程必须等待;
  3. 有限等待:对请求访问的进程,应保证能在有限时间内进入临界区(不会饥饿);
  4. 让权等待:当进程不能进入临界区时,应立即释放处理机,防止进程忙等待(空转)。

进程互斥的实现应当必须遵循前3条原则,第4条属于锦上添花。

进程互斥的软件实现方法

软件实现就是设计算法,让进程调度的存在不打乱上锁和解锁过程。

  1. 单标志法(轮转法)

算法思想:临界区权限在进程间转移,每个进程进入临界区的权限只能被另一个进程赋予。

int turn = 0; // turn表示当前运行进入临界区的进程号
void p0() { // 0号进程
    while(turn != 0) ; // 进入区
    critical section;  // 临界区
    turn = 1;		   // 退出区
    remainder section; // 剩余区
}
void p1() {
    while(turn != 1) ; 
    critical section;
    turn = 0;
    remainder section;
}

turn的初值为0,刚开始只允许0号进程进入临界区。若P1先上处理机运行,则会一直卡在进入区,直到P1的时间片用完,发生调度,切换P0上处理机运行。P0访问临界区时,切换会P1,P1仍然卡在进入区。

该算法可以实现 “同一时刻最多只允许一个进程访问临界区”,缺点是:如果P0不访问临界区,P1就永远不能访问临界区,违背了“空闲让进”原则。

  1. Peterson算法

上面讲了单标志法,实际上还有双标志先检查法、双标志后检查法,这里就不展开了,快进到Peterson算法。Peterson算法是将单标志法和双标志法的融合改进。

算法思想:如果两个进程都想进入临界区,可以让进程尝试“孔融让梨”,主动让对方先使用临界区,且将最后一次“让梨”视为有效。比如A和B都想洗澡,A说B先洗,B说A先洗,则A先洗。

bool flag[2]; // 表示每个进程进入临界区的意愿,初值为false
int turn = 0; // 表示优先让谁进入临界区
void p0() {
    flag[0] = true;   // #1 表示自己想进入临界区
    turn = 1;		  // #2 优先让对方进入临界区
    while(flag[1] && turn == 1); // #3 对方想进,且最后是自己“让梨”,就等待
    critical section; // #4
    flag[0] = false;  // #5 临界区访问结束
    remainder section;
}
void p1() {
    flag[1] = true;   // #6
    turn = 0;		  // #7
    while(flag[0] && turn == 0) ; // #8
    critical section; // #9
    flag[1] = false;  // #10
    remainder section;
}

上面#1 #2 #3是进入区,#4是临界区,#5是退出区。在这种互斥算法下,无论是什么情况的进程调度,都可以保证只有一个进程进入临界区。

如果执行顺序是#123678...,经过#12后,flag==[true, false], turn == 1,p0不会卡在#3处,进程调度切换到p1,经过#67后,flag==[true, true], turn == 0,p1会卡在#8处,即p1是最后一次“让梨”,等进程切换到P0,P0不会卡在#3,直接进入临界区执行。

如果执行顺序是#1623…,经过#162后,flag==[true, true], turn == 1,此时P0让梨给P1,P0不会进入临界区而是忙等待,等到进程切换到P1,执行#7,这是P1又让梨给P0,P1就不会进入临界区,等到下次进程切换到P0,P0进入临界区,P0退出临界区后,进程切换到P1,P1就可以进入临界区。

对于任意的执行顺序,Peterson算法都可以有效地实现进程互斥,过程就如上面分析的。不过Peterson算法是靠CPU空转来实现等待,处理机属于忙等待状态,没有满足“让权等待”原则。

进程互斥的硬件实现方法

硬件实现的方法主要是让操作变成原子操作。

  1. 中断屏蔽法:

    在进入临界区前关闭中断,退出临界区后再打开中断,中断关闭后就不会发生进程切换,因而不会发生两个进程同时访问临界区。

    cli(); // 关中断
    临界区;
    sti(); // 开中断
    剩余区;
    

    缺点:不适用于多处理机;只适用于内核进程,不适用于用户进程。

  2. TestAndSet指令:
    或称为TestAndSetLock指令,简称TS/TSL指令。是硬件实现的指令,执行过程中不允许被中断,其逻辑用C语言描述如下:

    // 共享变量lock表示当前临界区是否被锁,true表示加锁
    bool TestAndSet(bool *lock) {
        // 返回lock旧值,并上锁
        bool old;
        old = *lock;
        *lock = true;
        return old;
    }
    // 以下是使用TSL指令实现互斥的算法逻辑
    while(TestAndSet(&lock)); // “上锁”并“检查”
    critical section; 
    lock = false; // 解锁
    remainder section;
    /* 如果原本临界区未上锁,则TestAndSet返回false并上锁,进入临界区,退出临界区后再解锁。
    如果原本就上了锁,会一直等待,直到另一个进程解锁,才会进入临界区。 */
    

    TS指令让“检查”和“上锁”过程变成了原子操作。
    优点:简单,不需要像软件实现那样检查逻辑漏洞;适用于多处理机环境
    缺点:不满足“让权等待”原则,导致忙等待

  3. Swap指令
    即Exchange(XCHG)指令,是硬件指令,执行过程不允许中断。其互斥算法逻辑与TS指令相同,优缺点与TS指令也相同。

    // swap指令实现互斥的算法逻辑,
    // lock表示当前临界区是否加锁
    bool old = true;
    while(old == true)    // 检查并上锁
        Swap(&lock, &old);  
    critical section;
    lock = false;         // 解锁
    remainder section;
    

信号量机制

上面不管是软件实现还是硬件实现进程互斥,都多多少少有些缺陷,因此现在更多地是使用信号量机制来实现进程同步、互斥。1965年,荷兰学者Dijkstra提出了信号量机制,以实现进程互斥、同步。信号量用来表示某种资源的数量。

用户进程可以通过操作系统提供的一对原语来对信号量进行操作。原语是一种特殊的程序段,其执行只能一气呵成,不可被中断。这一对原语是P(s)/wait(s)V(s)/signal(s)

  • 记录型信号量(用的最多)
typedef struct {
    int value;   // 记录资源个数,小于0表示资源不足
    PCB *queue;  // 记录等待在该信号量上的进程
} semaphore;
// P来源荷兰语,意思是test,或称为wait
P(semaphore s) { // 使用资源/申请资源 
    s.value--;
    if(s.value < 0) // 如果资源不足,就自我阻塞
        block(s.queue); // 把自己加到等待队列中
} 
// V来自荷兰语,意思是increment,或者函数名为signal
V(semaphore s) { // 释放(增加)资源
	s.value++; 
    if(s.value <= 0) // 增多了一个资源,如果等待队列还有进程
        wakeup(s.queue); // 就唤醒进程来使用增加的这个资源
}

s.value<0时,|s.value|表示等待队列中的进程个数。当一个进程P资源时,如资源不足,就把自己加入到等待队列中。该机制遵循了“让权等待”原则。

  • 整型信号量
// s是资源量,是整型
void wait(s) {  // 消耗资源 即P
    while(s <= 0) ; // 相当于阻塞
    s--;
}
void signal(s) { // 释放(产生)资源 即V
    s++;
}

缺点:不满足“让权等待”,发生忙等待。

信号量的保护

信号量机制运行的过程:

  1. 用原语对信号量进行保护
  2. 用信号量解决同步、互斥问题

上面说过,P和V操作是一对原语,这实际上是对信号量的保护信号量是一种被多个进程共享的资源,也是一种临界资源,因此对信号量的修改(PV操作)必须是互斥的,同一时间只能有一个进程对信号量进行修改,这就是对信号量的保护,也就是对信号量的修改必须是原语。让PV操作变成原语(对信号量的保护)可以采用互斥的软/硬件实现方法。最简单的方法就是开关中断(仅适用于单核CPU)或者TS指令(信号量给临界资源上锁,TS指令再给信号量上锁),使信号量的修改变成原语。

信号量-同步:初值为0

对于一个资源的使用和释放,不同的进程之间有明确先后关系,比如生产者产生数据,消费者才能使用数据。具体如下:

semaphore s = 0;
// 执行顺序: producer -> consumer
producer() {
	// ...
    // 产生数据的代码
    V(s);
}
consumer() {
    // ...
    // 使用数据的代码
    P(s);
}

P和V组成一对,分别在不同的进程中,先执行的进程为P,后执行的进程为V。

考虑进程同步主要依靠以下步骤:

  1. 找出一前一后的同步关系(画前驱图,每条线是一个信号量)
  2. 设置同步信号量的初始值为0
  3. 释放资源用V,消耗资源用P

信号量-互斥:初值为1

对于一个共享资源的读写,不同进程间是互斥的,这时使用初值为1的互斥信号量(也称为互斥锁),实现进程互斥。

这里可以将临界区看做是数量为1的资源,因此初值为1。

semaphore mutex = 1;
p1() {
    P(mutex); // 测一下有没有进程在使用这个信号量
    // ... 读写资源
    V(mutex); // 释放该信号量
}
p2() {
    P(mutex);
    // ... 
    V(mutex);
}

PV组成一对,在同一个进程中,要读写共享资源前先P一下,看是否有人用。读写完再V一下释放这个资源。P和V来包裹读写资源的操作,读写临界资源的代码放在临界区。

思考步骤:

  1. 分析问题,确定临界区
  2. 设置互斥信号量,初始值为1
  3. 临界区前后P V夹紧

信号量的应用

1 生产者-消费者问题

问题描述:生产者-消费者模型是有一个有上限的共享数据区(缓冲区)存放数据,有一个生产者进程生产数据,有一个消费者进程消费数据,目标是让生产者和消费者能够协同地生产数据和消费数据,即生产者和消费者合作。

:对生产者来说,其资源是空闲缓冲区。如果存在空闲缓冲区,则生产者工作;如果不存在空闲缓冲区,则生产者等待。而对消费者来说,其资源是非空闲缓冲区。对缓冲区的操作应当是互斥的

生产者工作前P一下自己的资源,看看有没有空闲缓冲区,工作完成后V一下消费者的资源(唤醒消费者)。消费者工作前P一下自己的资源,工作完成V一下生产者(唤醒生产者)。由于要操作PCB,因此P和V要做成系统调用,在内核态运行。

这里采用文件当做共享缓冲区,定义一个互斥信号量mutex。要操作文件时,若mutex==1,就可以操作,而mutex==0,就不能操作。

// 用文件定义共享缓冲区
int fd = open("buffer.txt");
write(fd, 0, sizeof(int)); // 写in
write(fd, 0, sizeof(int)); // 写out
// 信号量的定义和初始化
semaphore full = 0; // 非空闲缓冲区
semaphore empty = BUFFER_SIZE; // 空闲缓冲区
semaphore mutex = 1; 

Producer(item) {
    P(empty);
    P(mutex);
    // 读入in,将item写到in位置上;
    // ...
    V(mutex);
    V(full);
}

Consumer() {
    P(full);
    P(mutex);
    // 读入out;将out位置读出到item;处理item
    // ...
    V(mutex);
    V(empty);
}

2 吸烟者问题

问题描述:假设一个进程有三个吸烟者进程和一个供应者进程。每个吸烟者不停地卷烟并吸掉它,但是卷烟需要三种材料:a,b,c。三个吸烟者中,第一个拥有无限的b和c,第二个拥有无限的a和c,第三个拥有无限的a和b。供应者无限地提供三个材料中的一种,并把材料放在桌子上,缺少该种材料的吸烟者会拿起材料并卷起来吸掉它,并告诉供应者完成了该次吸烟,供应者就会再在桌子上放一种材料,这个过程一直重复且三个吸烟者轮流吸烟

:进程顺序:供应者->吸烟者1->供应者->吸烟者2->供应者->吸烟者3->供应者->…

每个箭头都应当是一个信号量,三个吸烟者需要的资源是三种不同的材料,用三个信号量表示缓冲区中的材料是哪种。吸烟者用完缓冲区的材料后通知供应者,即供应者的资源是空的缓冲区,或者定义成是否吸烟完成。桌子(缓冲区)的读写应当是互斥的,用一个互斥信号量mutex

semaphore mutex = 1;
semaphore offer_a = 0;
semaphore offer_b = 0;
semaphore offer_c = 0;
semaphore empty = 1; 
// 或者 semaphore finish = 1; 1表示吸烟完成,0表示未完成
int i = 0; // 记录供应到第几个人
void Provider() {
    while(1) {
        P(empty); // P(finish);
        if(i == 0) {
            P(mutex);
            // 将材料a放到桌子上
            V(mutex);
            V(offer_a);
        } else if(i == 1) {
            P(mutex);
            // 将材料b放到桌子上
            V(mutex);
            V(offer_b);
        } else if(i == 2) {
            P(mutex);
            // 将材料b放到桌子上
            V(mutex);
            V(offer_c);
        }
        i = (i + 1) % 3;
    }
}
void Smoker_m() { // m = a, b, c 三个吸烟者进程
    while(1) {
        P(offer_m);
        P(mutex);
        // 拿走材料、卷烟、吸烟
        V(mutex);
        V(empty);
    }
}

刚开始empty==1,供应者运行一次,empty变为0,吸烟者运行,假如吸烟者进程还没结束就切换到供应者,此时empty==0,当前供应者进程P(empty)后,empty==-1,并把自己加入到empty的阻塞队列中。当进程切换回供应者,供应者V(empty)empty==0,就会唤醒empty阻塞队列中的进程,继续工作下去。

另外可以发现,任何时刻都只有一个进程修改桌子(缓冲区),缓冲区的互斥锁其实是可以省略的。

3 读者-写者问题

问题描述:有读者和写者两组并发进程,共享一个文件。要求:

  1. 允许多个读者同时对文件进行读操作;
  2. 只允许一个写者对文件执行写操作;
  3. 任意写者完成写操作前不允许其他读者或写者工作;
  4. 写者进行操作前,应让已有的读者和写者全部退出。

:进程间没有先后顺序,读者进程之间没有限制,写者进程之间是互斥的,读者进程和写者进程是互斥的。关键就在于读者进程和写者进程互斥的处理。

写者进程可以用一个互斥锁来实现互斥;写者和读者之间就不能用互斥锁解决,如果用互斥锁,读者和读者之间就没办法同时读了。可以用一个整数变量来记录一个文件有几个读者进程,如果这个变量大于零,就不能写,另外这个变量要用一个锁来保护,虽然读写这个变量的操作不是原子的,但是可以保证一个进程读写过程中其他进程不能读写,这样进程调度时就不会出现问题。

semaphore mutex1 = 1; // 写者进程的互斥锁
semaphore mutex2 = 1; // cnt的锁
int cnt = 0;          // 记录有几个进程在读文件

void reader() {
    P(mutex2); // 看有没有进程在读文件,如果有就不用考虑mutex1
	if(cnt == 0)
        P(mutex1);
    cnt++;
    V(mutex2);
    do_read();
	P(mutex2);
    cnt--;
    if(cnt == 0)
        V(mutex1); // 别忘了解锁
    V(mutex2);
}

void writer() {
    P(mutex1); // 看看有没有其他写者
    do_write();
    V(mutex1);
}

分析:如果进程的到来顺序是如下情况:

  1. 读者1->写者1
    正常执行,读者1上锁mutex1,写者1不能执行,进入阻塞队列,读者1完成后唤醒写者1执行
  2. 读者1->读者2
    正常执行,读者1上锁mutex1,读者2解锁mutex2。注意:最先到来的读者上锁mutex1,最后结束的读者解锁mutex1
  3. 读者1->写者1->读者2->读者3
    写者进程饥饿,读者1上锁mutex1,写者1到来后进入阻塞队列,读者2进程到来后并不会阻塞(就算发生阻塞,也是阻塞在mutex2的队列中,比mutex1的阻塞队列先唤醒),读者3进程到来也是这样,然后三个读者中最后执行完的进程解锁mutex1,然后写者1才执行。可以发现,这时出现了写者饥饿,原因是只要有读者在执行,写者就会一直阻塞。

改进:在上述情况中,读者的优先级是高于写者的,根本原因是在于不管有没有写者,读者都不会被写者阻塞,因此添加一个写者对读者的锁,让有写者进程等待时,新来的读者要等待在写者后面。

semaphore mutex1 = 1; // 写者进程的互斥锁
semaphore mutex2 = 1; // cnt的锁
semaphore w = 1;
int cnt = 0;          // 记录有几个进程在读文件

void reader() {
    P(w);
    P(mutex2); // 看有没有进程在读文件,如果有就不用考虑mutex1
	if(cnt == 0)
        P(mutex1);
    cnt++;
    V(mutex2);
    V(w);
    do_read();
	P(mutex2);
    cnt--;
    if(cnt == 0)
        V(mutex1); // 别忘了解锁
    V(mutex2);
}

void writer() {
    P(w);
    P(mutex1); // 看看有没有其他写者
    do_write();
    V(mutex1);
    V(w);
}

当没有写者时,w对读者没有阻塞作用,而当有写者进程时,w就会阻塞新来的读者进程。

4 哲学家进餐问题

问题描述:一张圆桌坐着5名哲学家,每两名哲学家之间摆放一根筷子,哲学家只处于思考和饥饿两种状态。当哲学家饥饿时,会试图拿起左右两根筷子(一根一根地拿起)。如果筷子在别人手上,就需要等待。只要饥饿的哲学家拿起两根筷子才可以开始吃饭,吃完后放下筷子继续思考。

:五个哲学家进程和五根筷子资源,每两个哲学家对其中间的筷子的访问是互斥的,每个哲学家只能访问到相邻的两根筷子,所以定义5个信号量表示筷子的占用。

semaphore m[5] = {1, 1, 1, 1, 1};
void pi() { // i号哲学家进程
    while(1) {
        P(m[i]); // 左边筷子
        P(m[(i+1)%5]); // 右边筷子
        eat();
        V(m[(i+1)%5]); 
        V(m[i]);
        think();
    }
}

分析:上面程序是有问题的,假如恰好每个哲学家进程执行完P(m[i])后发生调度,每个哲学家都拿起了左边的筷子,此时桌子上一根筷子都没有,所有哲学家都在等待右边的人放下筷子,而所有人又不会主动放下自己手中的筷子,所有哲学家都处于阻塞,这时就发生了“死锁”,整个程序进入了“死循环”而且不能自己解决。

改进:如何防止死锁的发生,有以下几种方案:

  1. 对进程加限制,比如最多允许四个哲学家进餐,这样保证了至少一个哲学家可以同时拿到左右两根筷子;

  2. 奇数号哲学家先拿左边的筷子,再拿右边的筷子,偶数号哲学家刚好相反,这样相邻的哲学家都想吃饭时,一个可以拿起筷子,另一个直接阻塞,不再占用资源;

  3. 对拿筷子这个行为加上互斥锁,只有一个人拿到两根筷子后,才允许下一个人拿筷子;

    semaphore mutex = 1;
    semaphore m[5] = {1, 1, 1, 1, 1};
    void pi() { // i号哲学家进程
        while(1) {
            P(mutex);
            P(m[i]); // 左边筷子
            P(m[(i+1)%5]); // 右边筷子
            V(mutex);
            eat();
            V(m[(i+1)%5]); 
            V(m[i]);
            think();
        }
    }
    

与之前的问题不同的是,哲学家问题中,一个进程需要同时持有两个以上临界资源,因此就有“死锁”的隐患。当遇到这种情况,可以参考上面三种方案。

死锁

死锁是指:在并发环境下,各进程因竞争资源而造成的相互等待对方手中的资源(你等待我,我等待你),导致各进程都阻塞,都无法向前推进的现象。发生死锁后若无外力干扰,这些进程都无法向前推进。

要注意,使用PV操作时,如果顺序不对,很容易造成死锁。

死锁和饥饿的区别

死锁一定是“循环等待对方手里的资源”导致的,如果发生死锁,一定是两个或两个以上的进程同时发生死锁,死锁进程一定处于阻塞态。

**饥饿是长期得不到想要资源而进程无法向前推进的情况,发生饥饿的进程可能只有一个,且可能处于阻塞态,也可能处于就绪态。**饥饿与资源分配策略有关,比如读者-写者问题中读者比写者优先导致写者饥饿,再比如短作业优先(SPF)算法中,短作业进程源源不断到来导致长作业进程一直得不到处理机而发生饥饿。

死锁产生的必要条件

产生死锁必须同时满足以下四个必要条件。满足必要条件并且进程调度产生某种会发生死锁的进程执行顺序时,才会发生死锁。

  1. 互斥条件:只有对必须**互斥使用的资源(包括信号量)**的争抢才会导致死锁(如哲学家的筷子、打印机设备)。像内存、扬声器这样可以同时让多个进程使用的资源是不会导致死锁的(因为进程不用阻塞等待资源)。
  2. 不剥夺条件:进程所获得的资源在未使用完之前,不能由其他进程强行夺走,只能主动释放。
  3. 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求
  4. 循环等待条件:存在一种资源的循环等待链,链中每个进程已获得的资源被下一个进程所请求。发生死锁时一定有循环等待,但发生循环等待时未必死锁(比如加入新的资源,阻塞进程又可以开始工作)。

死锁的处理策略

  1. 预防死锁。用不同的方法破坏死锁产生的四种必要条件。
  2. 避免死锁。用算法阻止系统进入不安全状态(运行下去一定产生死锁),每次申请资源时调用银行家算法 O ( m n 2 ) O(mn^2) O(mn2)计算系统是否会进入不安全状态,如果会进入不安全状态就不会给该次申请分配资源。每次申请资源都要执行银行家算法,效率低下。
  3. 死锁的检测和解除。定时检测或者发现资源利用率低时检测,检测到死锁时回滚进程,让出资源。检测算法容易实现,但回滚进程比较复杂,要考虑很多方面,比如哪些进程回滚?优先级?如何实现回滚?已经修改的文件怎么办?
  4. 死锁忽略。如果是普通的PC机,直接重启。

每种策略的具体方法就不具体展开了,死锁的发生概率并不高并且不容易处理,前三种方法都有弊端。许多通用操作系统如PC机的Windows和Linux都采用死锁忽略的方法,直接重启。

管程(monitor)

信号量机制编写程序困难、易出错,因此就有了管程。管程保证同一时刻只有一个进程在管程内活动(由编译器实现),并且需要设置condition变量来决定进程执行顺序,让进入管程而无法继续执行的进程阻塞自己。

管程可以视为一个线程安全的数据结构,其内部提供了互斥与同步操作,向外提供访问共享数据专用接口(称为管程的过程),通过管程提供的接口即可达成共享数据的保护与线程间同步。

使用管程,可以简化线程间互斥、同步的编码复杂度(否则需自己控制互斥、同步机制,并保证正确),可以集中分散的互斥、同步操作代码,更容易验证、查错,也更可读(反之,信号量的PV操作可能分散到各个地方,验证、阅读相对麻烦),这个层面来说管程是一种封装

管程包含以下部分:

  • 共享数据结构的说明;
  • 对该数据结构进行操作的一组过程(接口函数);
  • 对共享数据设置初值的语句;
  • 管程有一个名字。

管程的特点

  • 共享数据仅能被管程的过程访问
  • 线程通过调用管程的过程进入管程
  • 任何时候仅能有一个线程在管程中执行,其他阻塞直到可用
  • 管程内共享数据不可用时,
    • 需要共享数据的线程将阻塞并释放管程
    • 其他线程可进入管程构造数据可用条件,并通知阻塞线程
  • 管程内共享数据可用时, 被阻塞线程将在合适时间重新进入管程

生产者-消费者问题

用管程解决生产者-消费者问题。要注意,管程不是简单的对函数进行封装,是要编译器负责实现进程互斥地进入管程。

monitor ProducerConsumer
    Item buffer[N];	 // 共享数据
    condition 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();
    }
end monitor;

// 生产者进程
void producer() {
    while(1) {
        item = 生产一个产品;
        ProducerConsumer.insert(item);
    }
}
// 消费者进程
void consumer() {
    while(1) {
        item = ProducerConsumer.remove();
        消费产品item;
    }
}

Java中类似管程的机制

Java中,用关键字synchronized修饰函数,那么这个函数同一时间只能被一个进程调用。

static class monitor {
    private Item buffer[] = new Item[N];
    private int count = 0;
    
    public synchronized void insert(Item item) {
        // ...
    } // 每次只有一个线程进入insert函数,
    // 如果多个进程同时调用,则后来者需要排队等待
}

用C++实现管程可以参考这篇文章:
C++实现管程与同步队列 - 無雙 - 博客园 (cnblogs.com)

参考

操作系统(哈工大李治军老师)32讲(全)超清_哔哩哔哩_bilibili

王道计算机考研 操作系统_哔哩哔哩_bilibili

进程的同步、互斥、通信的区别,进程与线程同步的区别_jyh的博客-CSDN博客_进程同步

操作系统PV操作考研期末考试专用_哔哩哔哩_bilibili

死锁和饥饿的主要区别 - huyoo - 博客园 (cnblogs.com)

应该如何理解管程? - 知乎 (zhihu.com)

C++实现管程与同步队列 - 無雙 - 博客园 (cnblogs.com)

  • 2
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值