信号量和管程
1 背景
研究信号量和管程如何解决同步和互斥的问题
锁机制解决互斥
需要更高级的同步互斥语义,还可以借助硬件原子操作来实现更高层的同步互斥
进入临界区的进程或线程若只是做读操作,则没必要只限制1个线程或进程去执行,需要更高级的同步互斥手段去完成这一机制,所以引入信号量
2 信号量
信号量用一个整形来表示,
- P()操作:sem-1,
- 若sem<0,当前执行P操作的进程需要睡眠
- 若sem>=0,当前执行P操作的进程可以继续往下执行,进入临界区或其他的操作
- 相当于获取lock的操作
- V()操作: sem+1,P操作的反操作
- 若sem<=0,当前有些进程等在了信号量上面,唤醒一个等待的进程
信号量的值可以解释为某类资源的数量
信号量的铁路会有多条路(并发执行)
lock的铁路是只有1条路
图中那个信号量的例子,3要是也想通过这个分成2条的路,只能等1或2通过才行,如下图:
进入临界区执行P操作
出临界区执行V操作,会通知等待的去执行
- 若sem<=0,当前有些进程等在了信号量上面,唤醒一个等待的进程
3 信号量使用
P操作会使进程阻塞挂起
V操作会唤醒进程
唤醒哪个进程涉及到公平性的问题
唤醒1个进程会采取FIFO,等待的进程放到队尾,唤醒的进程从队头取
但是锁机制采取的是FIFO吗?
二进制信号量可以用来模拟锁机制
一般计数信号量:信号初始为1个大于0的数,就可以允许多个执行P操作的进程进入后续的操作,即临界区里可以有多个进程,使用锁的临界区里只能有1个进程
P操作可能会使进程挂起
V操作可能会使进程唤醒
3.1 用二进制信号量
3.1.1 实现互斥
初始值设为1
上述可以用来代替锁机制:P获得锁,V释放锁
注:P操作是先减1再判断大小
3.1.2 实现条件同步
初始值设为0
为什么初始值设成0?
线程A必须要等到线程B执行到某条语句后才能继续执行,为保证这一点,可以用信号量。上图可以看到,为实现同步,线程A要等到线程B执行完V()后才能执行P()(信号量初试为0,那么先执行A的话,P()后信号量=-1,A线程被阻塞,要等到B线程执行完V操作后,信号量=0,才可能去唤醒A,A线程才可以继续执行P操作后的代码)
3.2 更复杂的同步互斥不能用简单的二进制信号量来解决
- buffer有限
- 生产者写数据时,消费者不能取数据;允许多个生产者(消费者)向 buffer写(读)数据(Lock是只允许一个生产者\消费者向里写数据),以上2个可以表明的是互斥
- buffer为空时,消费者应该睡眠,等到生产者向buffer里写完数据后才唤醒消费者;当buffer为满时,生产者要等消费者取后才可以往里写(同步)
2个一般信号量用于实现同步
代码实现:
注:
- mutex初始化为1,用来实现互斥
- fullBuffers初始化为0,说明最开始时buffer里1个也没有
- emptyBuffers初始化为n,表示可以往buffer里塞多少个数据
- Depsdit() 生产者,向buffer里加
- Remove() 消费者,向buffer里减
增加mutex->PV操作保证互斥性,只能有1个进程访问(对buffer的操作保持互斥性)
增加empty和full那两个保证同步(看buffer满没满,可以有n个生产者进程执行Deposit;通知消费者和生产者是否可以进行操作)
若emptyBuffers->p这一步被阻塞(<0),则说明是生产者满了,要去等消费者取才能继续执行
fullBuffers->V() > 0这一步是可以通知消费者buffer已经有数据了
P、V操作换顺序可以吗?
V操作只是信号量+1,然后唤醒一个等在信号量上的进程
- 如生产者那2个V操作可以交换顺序,因为他只是唤醒
- 如果生产者的2个P操作互换顺序可能会造成死锁或其他问题。假设fullBuffer=n,此时再来一个生产者进程,先执行mutex->P,在到emptyBuffer->p此时已经满了,于是将这个进程挂到阻塞进程。但是再看消费者进程,先执行fullBuffers->p,到mutex->P这一步不能执行,因为上面那个生产者进程已经占了,而这种情况下,消费者也无法唤醒挂起了的生产者进程,造成死锁
4 信号量实现
P:先sem–,若减完后小于0,则把这个线程挂到等待队列,同时自己休眠
V:若sem>0,则之前所有的线程都没有被挂到等待队列,若sem<0,由于V先执行sem++,所以判断的是sem<=0,来看是否有进程等在等待队列上。由于采用的FIFO,所以会唤醒等的最久的线程
虽然信号量有很多问题,但操作系统内核中还是大量使用信号量实现同步互斥问题,
5 管程
抽象度更高,更容易使用
信号量一开始提出就是操作系统同步互斥机制的实现
管程最早提出是在高级语言实现同步互斥的
5.1 什么是管程
管程最早出现在语言中(java),针对的是语言的并发操作
管程(monitor)是包含了一系列共享变量以及针对这些变量的操作的函数的一个组合(模块)
- 需要1个锁:确保访问管程的只有1个线程,保证互斥性
- 需要多个条件变量(根据条件的个数来确定有多少个条件变量):会要访问大量的共享资源,有可能某些线程(进程)会因为不满足某些条件得不到共享资源,此时就要把这些线程(进程)挂起到相应的条件变量上,
- entry queue:往管程里送进程的队列,进入管程是互斥的,要先获得lock才能进去,取不到就只能在这个队列中
- 条件变量x,y:有2个等待队列,挂着需要等待x或y的线程。当某个线程需要等待x或y时,就要先释放锁,再挂到x或y的等待队列上去
- wait x:让进程去等待x(挂在x队列)
- signal x:唤醒x,使得挂在x上的进程有机会能继续执行
Lock:保证管程里的函数是互斥的,可以把那2个操作写在函数里,也可以通过语言级的机制保障
条件变量:
numWaiting:等待条件的线程的个数
sem:信号量的个数
PV操作一定会有加减操作,但是Signal不一定会做减操作
一个问题:Wait里为什么要先release再require锁?
- 后面会有解释:Deposit里先require之后才会有可能调用wait,此时如果wait里没有release锁,那么其他线程都不会进入到管程中
count:Buffer的空闲情况,count=0空,count=n满
lock->Acquire()、lock->Release()的位置不同于信号量的(信号量的是紧紧靠在buffer上下的):原因是管程的定义为只允许一个线程进入
(管程和信号量实现细节不同,但实现功能是相同的)
当一个线程在管程中执行的话,唤醒了另一个线程后,是先执行唤醒的还是先执行被唤醒的?有以下2种想法:
-
Hansen:虽然T1执行了signal,但还是继续执行T1,直到结束才执行被唤醒的T2(实现简单些,较常用)
-
Hoare:当T1执行了signal(唤醒)操作时,就去继续执行被唤醒的T2,T2完了之后再来执行T1(实现困难,需要更复杂的机制来实现有效性)
(这块老师和PPT说的好像是反的)
Hansen:当有线程做了signal操作时,会接着往下执行,有可能有多个等待在条件变量上的线程被唤醒,它们会去抢CPU,但只有1个CPU,当被唤醒的线程去执行时,可能count不为n了,所以要用while再做1次确认
Hoare:做完signal操作后就把CPU交给了被唤醒的线程,这时候只有1个线程被唤醒(不会有多个,因为只能唤醒1个),此时count一定不为n,因为count小于n时才做signal操作,使用被唤醒后,count<n这个操作依然满足,不会被破环
存在错误的不复现性,使用要好好的设计
6 经典同步问题
6.1 读者-写者问题
不允许多个写者同时操作(会导致数据的不一致性);允许多个读者同时操作
不允许读者和写者同时操作
当有读者读操作时,写者必须要等待,直到读者全部读完之后,写者才能去写
写者做写操作,读者和其他写者都必须要等待,直到写者做完写操作
读者优先:当有读者正在读时,来了一个写者,之后又来了一个读者,则后来的这个读者可以先跳过等待的写者。读操作对数据没有破环,则可以跳过等待的写者去完成相应的操作
Rcount:读者的个数(因为写的时候只能有1个写者操作,所以没必要记写者个数)
CountMutex:保证对Rcount的读或写是互斥操作
WriteMutex:写者去操作也是互斥的
读者优先
writer:保证只有1个写者可以进去操作
sem_wait() = P操作
sem_post() = 读操作
reader:保证一旦有写者再写,则读者不能进去读,一旦有读者再读,写者也不能进去写
Rcount记录的是读者的个数,如果Rcount=0,表明当前没有读者,读者在执行读前要进行一个操作sem_wait()看是否有写者,确保没有写者后就就开始执行读操作
Rcount != 0,表示当前有读者在读数据,就算有写者也进不来
然后开始执行读操作前Rcount++,执行读操作,Rcount–
如果操作结束后,若Rcount=0,则表明该读者是最后一个读者,如果外面还有写者在等待,则执行WriteMutex的V操作来唤醒1个写者
对Rcount++和Rcount–要进行互斥的保护,所以加CountMutex的PV操作
写者优先
只要有写者来,读者就不能读,要先写
读者在读之前,要确保没有写者,此种写者包括:1、正在执行写操作的写者,2、在等待队列中的写者。只要上述2种写者存在,读者就不能执行读操作
读完后唤醒在等待的读者
写者再写之前,要确保没有正在读的读者也没有正在写的写者,才可以去做写操作
写完后唤醒在等待的写者或读者
AR:正在读的读者的个数
AW:正在写的写者的个数
WR:正在等待的读者的个数
WW:正在等爱的写者的个数
同步变量 okToRead:该去读了;okToWrite:该去写了
互斥 lock
读者
lock.acquire()和lock.release()确保只有1个线程进入管程
能进入管程说明有运行的读者,即AR++
while代表有有writer存在reader要等待,先WR++,然后把它挂到okToRead等待队列上,一旦被唤醒后,就会执行WR–
但是怎么判断有writer?(即while循环的条件)利用AW和WW,确保没有等待的writer也没有正在执行的writer(+WW体现了写者优先)
startRead():允许多个reader去读
DoneRead():执行完读操作之后要做的事
AR==0 && WW>0 没有正在读的reader并且有等待的writer就去唤醒1个writer
写者
okToWrite.signal():只唤醒1个writer,因为只允许1个writer同时操作,所以只唤醒1个就可以了
okToRead.broadcast():唤醒多个reader,因为允许多个reader同时执行
没有writer才去唤醒writer,体现写者优先
6.2 哲学家就餐问题
5个哲学家围一圈,5双筷子,思考,饿了就吃饭,必须要拿2双筷子才能吃饭,如何协调好,让每个哲学家都能吃上饭?
5个叉子fork[5]使用的是信号量,初始化为1,拿起为P,放下为V
1个哲学家代表1个线程
方案1:
问题:
- 以上代码对于1个科学家是适用的,
- 但是,如果5个科学家都同时拿起了自己左手边的叉子,此时,谁也不可能放弃自己左手拿的叉子,导致死锁
方案2:拿起左手边的叉子后,如果能拿起右手边的叉子就拿起来,否则就放下左手的叉子,然后等待确定的事件
问题:当5个科学家均拿起左手边叉子后,发现右手边的叉子均不在,所以同时放下左手的叉子,等待相同的事件,再重复上述的操作
方案3:等待时间随机
问题:随机事件不能保证每个哲学家都能有机会吃上饭
方案4:加PV操作使得进入临界区的只能是1个哲学家
问题:不会出现死锁的情况,但是只允许1个人吃饭(实际上可以2个人同时吃饭)
方案5:
P(s[i]):若吃了,则已经在test_take_left_right_forks()执行过了V操作,即s[i]=1,此时再做P操作,则s[i]=0,不会把自己阻塞
只有自己饿了并且左右均没有在吃饭的时候才会吃饭
V(s[i]):s[i]初值为0,V操作后s[i]=s[i]+1=1通知自己可以吃饭了
在执行test_take_left_right_forks(LEFT)时如果左邻居准备好吃了,就会执行V操作,此操作对应着左邻居的take_forks()里的P(s[i])操作
state[i]会改变,还要对其他科学家的状态做判断,所以要做PV(mutex)操作把它保护起来
再看科学家函数
- 先思考
- 尝试拿2把叉子,如果左右邻居都没在吃,就可以把叉子拿起来吃饭,并且把state[i]置为eating,不弱条件不满足能够拿起2把叉子,就执行P操作使得自身被阻塞
- 一旦拿起了叉子(没有被阻塞),就eat()
- 吃完了,放回叉子,通知左邻右舍,如果左邻右舍state为hungry并且能够拿起2个叉子,就把能拿起2个叉子并且hungry的科学家状态设为eating,如果这个科学家处于睡眠,还要唤醒他,使得它可以去继续执行eat()
eat不需要进一步实现,eat操作时1个临界区
think操作需要进一步实现,需要把自身状态置为thinking,并且需要把这个操作用PV来包起来,确保他是一个临界资源,保证互斥
上述方法中的信号量也可以使用管程来实现