上章链接: 【操作系统】第九章:临界区的概念和互斥的理解.
为了克服竞态条件(引入不确定结果的情况),引入Lock机制。锁机制可以实现互斥访问,但是光互斥还是不够的,我们还需要一些同步的机制、在某个临界区内 允许多个进程/线程去执行,这种时候只有锁机制就不够了。所以我们需要更高层的同步互斥语义以及借助硬件的原子操作来实现更高层的同步互斥的功能。
有了锁机制,就可以确保临界区的操作是互斥的。
同步机制的实现:临界区内可以有多个进程或者线程来执行。某些进程/线程可能不做写操作,只是读操作,那么就没必要限制这些线程禁止执行。为了应对这种现象,我们可以通过信号来实现这种禁止。
信号量用一个整形表示,有两个针对信号量的操作。分别是P操作和V操作。
P操作:P类似于获取LOCK,如果在这被挡住则无法继续向下执行(临界区或者其他类型的操作)
对于p[i]
sem-1;
if (sem<0)
wait();
else
exec();
V操作:信号量值+1并判断阈值,小于等于0则会唤醒挂在信号量上的等待进程。唤醒一般唤醒一个,也可以唤醒多个
sem+1;
if (sem<=0)
awaken();
信号量类似信号灯。LOCK只允许有一个火车通过,而信号量不同,可以有多个(这里是两个)进程同时通过。每一个火车再进入临界区之前,执行一个P操作,离开时执行一个V操作。
1.这个整形是个有符号数。一开始一般会设置为大于0的数,也就意味着一开始不会被阻塞。但是因为P操作做减法,一旦信号量小于0,则意味着当前执行P操作的进程被挂起。只能等待其他进程执行V操作唤醒。也就说P能过阻塞而V绝不会阻塞。信号量唤醒,假设每次唤醒一个则经常使用FIFO处理。
之前忙等的锁能否公平的通过FIFO来实现唤醒?
【在非抢占系统中可以,抢占则可能被调度,不一定FIFO唤醒】
2.两种类型的信号量
锁机制的取值也是0或者1。那么我们可以设计一个二进制信号量来模拟LOCK,来完全实现LOCK的功能。同时,信号量的初值可以大于0,则我们称这种的为通用的计数信号量。通用计数信号量会把信号量初值设置为大于0的数,就允许多个执行P操作的进程来进入之后的过程。信号量除了应用于互斥还能用于条件同步。
这个操作与LOCK操作对应。获取锁(P)释放锁(V),为了模拟LOCK将初值设置为1.
在临界区之前做P操作,临界区之后做V操作。这是二进制最常用的用法,完全可以用来代替锁操作。而且除了完成互斥操作,还需要完成同步操作,同步操作的初值要设置为0。为什么设置成0?如下图
我们需要线程A必须要的等到线程B执行到某一条语句之后才可能继续执行,这是一个同步关系。我们用信号量来完成。
condition->P():进程A在需要等待地方执行P操作,sem-1,被挂起
condition->V():线程B在执行V操作,sem+1,线程A被唤醒
这是二进制信号量来完成的同步操作。
更复杂的同步互斥情况可能无法通过简单的二进制信号量解决,此时我们需要用到一些条件同步来完成。
下面上具体例子:
需要用到同步和互斥两种操作的过程。整个操作过程中的问题:
1.Buffer缓冲器是有限的。当生产者在往里面写数据的时候,消费者不能再做相应的操作,但是可以多个生产者都往里面写数据(这里与LOCK不同),也可以有多个消费者来取数据,这取决于信号量初值。
2.同步约束条件:当缓冲区为空,消费者取不出数据,所以消费者应该睡眠,直到生产者写入数据再次被唤醒。同理,缓存区满时,消费者无法再往缓冲区内写数据,直到消费者从BUFFER里取数据使得缓存区不满,才能继续写入数据。
这个例子里用到了互斥操作和同步操作。这么多约束条件用信号量来解决:
二进制信号量:确保添加和取出这个互斥操作。
一般(资源)信号量fullBuffers:当BUFFer内从无到有资源,则唤醒消费者。初值为0,意味着一开始是缓冲区内是空的
一般信号量:当BUFFer内资源从满到有空间时,唤醒生产者。emptyBuffers:BUFFer的容量,生产者可以往里面放多少数据
解析:
首先,因为对缓冲区的读写是互斥操作,所以要首先保障互斥性,即当生产者写入数据或者消费者取出数据时,这个操作只能由一个操作在执行(这里是一个进程,根据初值的不同可以是多个进程,总之操作是互斥的,读和写不可以同时操作)
所以要将P和V操作包住读和写BUFFer的语句。
对于生产者:首先要确定缓存区是否满了,满了则不向下执行。用emptybuffers的P操作来实现,因为生产者的信号量是n,也就说一个进程可以进入n次或者可以由n个进程同时进行写操作。当emptybuffers的值小于0时,被阻塞;添加完数据后,还会执行一个fullbuffers的V操作,是由于Fullbuffer的初值是0,也就意味着如果消费者一开始想取数据是取不到数据的,因为缓冲区为空,而这一步的操作是告诉消费者,此时缓存区非空。
同理对于消费者就是一个相反的过程,首先执行fullbuffers的P操作,检测如果生产者先执行,则FB的值是1,此时消费者可以继续往下走;但是如果是消费者先执行,由于FB的初值是0,所以消费者会被挂起。而最后一步的EB的V操作是假设生产者填入大量数据导致缓存区满而睡眠,通过V操作来唤醒生产者,通知其缓存区已经不满,可以执行写操作。
那么P操作和V操作的顺序有影响吗?
Re)互换V操作没有问题,但是互换P操作会产生死锁。因为V操作相当于扩大临界区,在临界区内执行的操作是互斥的不会被打断;而P操作的互换会导致在BUFF满或者空的时候出现死锁,甚至会导致同时进入临界区的问题。
特点:
信号量使用困难,为了让开发者较简单地来使用同步互斥手段,引出了管程的概念。
管程的抽象程度比信号量还要高,也就意味着给开发者提供了更抽象的机制来更容易地完成同步互斥的问题。
管程最早是用于编程语言而非操作系统,这些语言通过设计管程可以简化高级语言来完成同步互斥操作。也就意味着管程的整个实现是针对语言的并发机制来实现的。
管程(monitor):包含了一系列共享变量以及针对这些变量操作函数的组合模块。
管程包含了锁和条件变量。锁是为了确保所有要访问管程管理的函数只能有一个线程;因为会大量访问各种共享资源,访问过程中可能某个条件得不到满足,就需要挂起那些得不到的满足的线程,挂到条件变量。根据条件个数来确定需要多少个条件变量。
这两个机制的组合就实现了管程。
如图,首先进入管程就需要一个队列。因为进入管程是互斥的,首先要取得锁,进入管程空间后,线程就可以执行管程维护的一系列函数。函数执行过程中可能会针对某个共享资源的操作得不到满足,则需要等待。又因为访问是互斥的,所以需要挂起到条件变量并释放锁。条件变量里有两个等待队列,放着所有挂起的线程,条件满足时,会唤醒相应线程。
条件变量有wait和signal操作。wait是挂起线程到条件变量,signal操作是唤醒条件变量使得挂起的线程有机会继续执行。
和信号量类似。
Condition::Wait(lock)
1.wait操作执行,表明要去睡眠。
2.然后挂起
3.解锁,因为进入管程时已经加锁
4.调度,选择下一个线程去执行
5.加锁,给新入进程加锁保证互斥
Condition::Signal()
1.判断等待队列是否有线程或进程
2.将等待队列中的线程取出
3.唤醒线程
4.等待队列序号减少
#.队列中如果没有等待线程,则这个函数什么都不做,也就说不一定会做减操作
同样根据生产者和消费者的例子实现同步互斥:
1.buffer和count是共享变量。
2.为了完成互斥,加锁Lock。加锁的位置是头和尾,由管程定义决定,管程定义线程进入到管程时,只有一个线程可以进入。由于函数是管理共享变量的,所以一定要确保互斥性和唯一性。
3.同步的实现:缓存器满,生产者睡眠。缓存器空,消费者睡眠。
对于生产者:
count==n时,标识缓存已满。notFull. Wait(&lock)中notFull是一个条件变量,表明需要睡眠。这里的lock是让当前生产者释放掉这个锁,这使得其他线程才有可能进入线程去执行。一旦将来被唤醒,意味着再去完成一次Lock::Acquire();,一旦获得lock就可以跳出wait操作,下一步仍然是判断count和n是否相等。这里用while而不用if是因为防止多生产者和多消费者的虚假唤醒。
notEmpty.Signal();每当放入缓存一个程序,即使缓存为空也变为非空,此时提醒消费者可以取数据,会唤醒消费者
对于消费者:
一开始,缓存器为空,此时消费者无法取数据。notEmpty会判断count是否为0,如果为0则消费者被挂起到notEmpty这个条件变量。
因为count–,此时buffer即便是满的也已经不满了,Notfull.Signal()会起到提醒作用,如果notfull里有等待的生产者进程,就会被唤醒,两个正好配对。
当线程在管程内执行时,如果某线程要被唤醒时,是马上执行阻塞态的线程呢、还是先完成发出唤醒操作的线程再去完成被唤醒线程呢?这是不一样的。一旦发出signal操作意味着管程内有两个线程可以执行,一个是本身正在执行的和被唤醒的线程。
那么选择哪一个先执行:有两种方法
Hoare:
一旦发出signal操作,就应该让被唤醒线程执行,然后自身去睡眠。直到线程执行到release之后才会继续执行。如右图。这种方法实现起来比较困难。主要见于教材中
Hansen:
发出signal后,直到自己release之后才会交给被唤醒者执行。 如左图。实现起来比较简单。主要用于真实OS和java中
while操作和if操作是由于唤醒机制不同造成的。
对于Hansen执行完signal操作之后,并没有马上让被唤醒的线程执行,这种情况下可能会有多个等待着条件队列上的线程被唤醒,也就说唤醒的可能不止一个。线程会去抢占执行,也就说这个唤醒其他线程的线程当它能够被选中去执行时,count可能不为n了,所以需要一个while来确认。
对于Hoare的实现机制,signal操作之后会把控制权交给被唤醒的等待线程,每次只唤醒一个。那么被唤醒线程占用CPU执行后,count一定不为n,因为signal的执行条件是count
底层机制的支持才能完善高层抽象。同步互斥因为非确定性的交叉指令导致开发和调试非常困难。
概念:
规则:
1.写操作时,只能有一个写者对数据进行写操作,写操作执行时无法执行读操作。
2.因为读操作不会改变数据,所以允许多个读者同时读数据。
3.当读者在读数据时,写者必须等待读者读完后;同理写者在写数据,读者和其他写者都必须等待。
4.读者优先。如果一开始有几个读者在读数据,写者来到并等待。此时写者之后又来了一个读者,则该读者跳过等待的写者,优先执行。
同一时刻,首先要知道有多少读者,用Rcount来计数,写者只有一个。且CountMutex来保证Rcount的读是互斥操作。写本身因为也需要互斥,所以还需要一个WriteMutex来完成对写的互斥。
分析:
首先通过P和V操作来保证互斥,这里PV用具体函数来表示。
然后因为读者可以有多个,Rcount如果等于0则意味着没有读者,也就说它本身是第一个读者,但是又因为没读者可能有写者,所以首先执行写操作的P操作保证没有写者存在;如果此时Rcount不为0,意味着他不是第一个读者,所以接下来写者一定进不来,读者可以继续往下走。
读完之后,做一个减操作,此时需要判断是否还有读者,如果没有读者,则意味着当前读者是最后一个读者,它即将离开意味着如果有写者可以进入写操作,所以要做一个唤醒,也就是写操作的V操作。
Rcount是一个共享变量,因此当多个读者对其进程操作时,要保证互斥性,需要由读的PV操作包住Rcount的变化。
这里存在一个问题,那就是如果用这种方法的话 ,如果读者源源不断出现,则写者会被阻塞;即便改成写者优先的写法,如果写者源源不断则读者也会被堵塞。那么如何解决?
读者:
1.读者如果要执行读操作,首先要检测当前是否有写者。当前有一个写者正在执行,此时读者等待;如果等待队列里有写者,则读者需要等待,等待队列的写者优先级高于读者。
2.读数据库
3.检查当前是否有写者处于等待状态,如果有则唤醒
写者:
1.如果当前有正在执行的读者/写者,则等待。等待队列中的读者不用等
2.当临界区内无读者和写者,则写
3.检测其他的读者或者写者并唤醒
基于管程的状态标识:
AR:正在读的读者数
AW:正在写的写者数
WR:正在等待的读者数
WW:正在等待的写者数
Lock:管程内函数调用是互斥操作,所以需要锁
okToRead:条件变量,表示当前可读
okToWrite:条件变量,表示当前可写
读者:
开始读:StartRead
1.管程中函数,确保互斥,则需要加锁。也就说函数首尾被加上加锁和解锁。
2.因为已经开始读,所以正在读的读者数会增加。也就说AR会增加
3.也有可能需要等待, 需要等待写者。 while做一个判断,在某种情况下,如果有写者,则等待队列中的写者增加且此时不能执行读操作,必须等待写操作执行完毕,所以挂起到okToRead的条件变量上并释放锁。假设这个读者被唤醒,则会从wait语句中跳出,因为此时有一个写者已释放,所以写者会减少一个。
4.判定条件:没有写者,即AW或WW有一个大于0,可以用AW+WW>0表示
因为允许多个读者可以同时读操作且进入管程是互斥的,所以AR++放在最后。
结束读:DoneRead
1.读完后,释放读者,管程的函数特征的LOCK保证进入管程操作的唯一性。
2.当前有写者的情况下我们需要唤醒写者,也就说有等待的写者。而且还要要求正在读的读者为零,如果还有正在读的读者,则写者继续等待,该读者释放,写者需要等最后一个出去的读者来唤醒它。
StartRead和DoneRead完成了完整的读者实现。在StartRead中的while语句中体现了写者优先。
写者:
StartWrite需要判断当前没有读者也没有写者才能开始写。
1.因为是管程操作,所以一定会加锁。一旦开始写,则当前正在写的写者数量自然会增加。所以AW++
2.假设当前有正在读的读者或者正在写的写者,首先等待。WW++,并将自己挂起到条件变量中。
DoneWrite
1.LOCK
2.AW–因为此时做完了一个写操作,使得当前正在写的写者-1
3.唤醒等待队列的写者或读者。写者优先,所以优先考虑写者。当唤醒读者时,唤醒所有读者而不是一个,不同于写者只能有一个写者,读者必须写者全部处理完毕才会被唤醒。此时,处于等待队列的所有读者会被唤醒。
fork[5]表示叉子,未被拿起用1表示。P操作是拿起,放下是V操作。
哲学家代表进程或线程。个数是5,数组表示。
进程里的循环就是:先拿左边的叉子再拿右边的叉子;两个叉子拿到就吃饭,完事后先放下左边再放下右边。
死锁问题:
五个进程每一步都是同时做的,都拿左边的叉子,五个叉子都被拿完了,然后开始下一步,大家准备拿右边的叉子,发现没有叉子,但是还握着左边的叉子,就会进入死锁。
改进1
五个人同时拿,发现右边没有叉子,同时放下。然后等一会,再拿,再放,进入死循环。
改进2
改进一下,等待随机时间。 这样的话可行,但是取决于随机数的变化。我们希望进程的执行公平执行,这样会导致某些进程执行多某些进程执行少。虽然能克服死锁情况,但是不能得到确定保证。
改进3:互斥访问
把拿和放叉子的操作用PV包起来,实现互斥。
由于互斥访问,导致不会死锁和死循环。一定可以确保拿到两把叉子并放下两把叉子,这个过程别人不会打断,因为只有一个进程可以进入临界区执行。
问题:每次只允许一个人进餐。实际上五把叉子应该至少允许有两个哲学家同时进餐,但是这种解法最多只有一个哲学家可以进餐。应该令相邻的两个进程不可以同时就餐,但是不相邻的可以同时就餐。
改进4
管程邻居就餐情况,左邻居在就餐说明没有左边的叉子,右邻居进餐说明没有右边的叉子,那么这时候需要等待。
如果发现两边都没有进餐,说明条件允许,拿起两把叉子吃饭。吃完后放下左叉子,放下右叉子。
这里面叉子不再是临界资源,而是哲学家(进程)的状态。可以分为思考中/饥饿/正在吃/阻塞 四种状态。
其中思考并不重要,去除。也就说我们需要考虑的是进程的三个状态。每一个进程判断邻居的状态做出响应。
判断邻居的状态时是读操作,设置的时候是写。唤醒存在同步关系。
着重设计的应该是take和put函数。
对于拿叉子:
void take_forks(int i)//i取值0~N-1
{
P(mutex);//临界区保护
//状态的改变是需要互斥保护的,因为左邻右舍进程做状态判断时可能会去读它,会根据它的当前状态来判断下一步的行动
state[i]=HUNGRY;//第一步,状态改变,因为饿了所以才打算拿叉子
//不管拿不拿得到叉子,首先我饿了,状态改变
test_take_all_forks();//尝试去拿两把叉子
//拿叉子需要判断其他进程是否处于eating状态,如果处于eating则阻塞,所以需要互斥保护
V(mutex);
P(s[i]); //同步信号,没有叉子便阻塞
}
void test_take_all_forks(int i)
{
if(state[i]==HUNGRY&&state[LEFT]!=EATING&&state[RIGHT]!=EATING)
{
state[i]=EATING;//两把叉子到手,用状态表示
V(s[i]);//通知第i人可以吃饭了
//为什么通知自己可以吃饭了呢,因为take_forks()函数后面会有一个P操作
//这个P操作在不执行本函数时会阻塞,执行时则会+1-1=0使他不会被阻塞
}
对于放回叉子:
void put_forks(int i)
{
state[i]=THINKING;//吃完饭了,所以不会立刻饥饿
test_take_all_forks(LEFT);//看左邻居状态是否饥饿
test_take_all_forks(RIGHT);//看右邻居状态是否饥饿
}
void test_take_all_forks(int i)
{
if(state[i]==HUNGRY&&state[LEFT]!=EATING&&state[RIGHT]!=EATING)
{
state[i]=EATING;//两把叉子到手,用状态表示
V(s[i]);//唤醒邻居
}
这里对于邻居本身,其执行时由于test_take_all_forks执行不成功导致V操作没有执行,然后会在take_forks中被阻塞。直到现在的i进程检测它的状态是饥饿且邻居的邻居也没有吃饭时,会将其唤醒。此时V操作会正好令上一个P操作的-1消失。
eat()操作不需要同步互斥。thinking()在一开始需要做一个同步互斥, 一开始把所有人置成thinking态。因为他也是进程的状态,而状态在这个例子里是临界资源,所以我们需要将其保护起来。