互斥无法解决同步问题。所以引入信号量、管程的概念。
一、信号量(Semaphore)
信号量可以分为两种:一种是二进制信号量:资源数目为 0 或 1;另一种是资源信号量:资源数目为任何非负值。两者是等价的,基于一个可以实现另一个。
信号量是有符号整数,是被保护的变量,针对信号量有两个操作,P操作和 V 操作。信号量一般初始化为大于 0 的数,这样的话 P 操作才不会被阻塞。
信号量可用于临界区的互斥访问控制 和 线程间的事件等待、条件同步。
1.用信号量实现临界区的互斥访问
- 用二进制信号量实现互斥访问:
//每个临界区设置一个信号量,其初始值为 1
mutex=new Semaphore(1);
mutex->P();
Critical Section;
mutex->V();
P():
对 sem 减 1,如果 sem<0 ,就等待,否则继续往下执行。P 操作能够阻塞。
Y():
对 sem 加 1,如果 sem<=0,意味着有线程在等待,就会唤醒一个或多个等待的 P 线程。Y 操作不会阻塞。
必须成对使用 P() 操作和 V() 操作,P() 操作保证互斥访问临界资源,V() 在使用后释放邻居资源,PV 操作不难次序错误、重复 或 遗漏。
(经常是 FIFO 的形式。)
- 用二进制信号量实现调度约束——同步操作:线程 A 需要线程 B 执行完某条语义之后才继续执行。
//初始值设置为 0
condition=new Semaphore(0);
Thread A:
condition -> P();
Thread B:
conditon-> V();
👀例:有界缓冲区的生产者-消费者问题
要求:
一个或多个生产者将数据放在一个缓冲区里,的那个消费者每次从缓冲区取出数据,在任何时候只有一个生产者或消费者线程可以访问该缓冲区 。即 双方需要同时用到同步和互斥两种机制:
① 在任何时候只有一个生产者或消费者线程可以访问该缓冲区 。(互斥)
② 当缓冲区为空时,消费者必须等待生产者。(调度/同步约束)
③ 当缓存区满,生产者必须等待消费者。(调度/同步约束)
方案:
每个约束用一个单独的信号量。
使用二进制信号量mutex ;
一般信号量 fullBuffers;
一般信号量 emptyBuffers。
Class BoundedBuffer {
mutex = new Semaphore(1);
fullBuffers = new Semaphore(0);
//初始值设置为 n,这样就可以有 n 个进入或者说可以进入 n 次
emptyBuffers = new Semaphore(n);
}
生产者:
//生产者
BoundedBuffer::Deposit(c) {
emptyBuffers->P();
/*通过 前加 P 后加 V 实现互斥*/
mutex->P();
//向 Buffer 添加数据
Add c to the buffer;
mutex->V();
/*通过 前加 P 后加 V 实现互斥*/
fullBuffers->V();
}
消费者:
//消费者
BoundedBuffer::Remove(c) {
fullBuffers->P();
mutex->P();
//从 Buffer 取出数据
Remove c from buffer;
mutex->V();
emptyBuffers->V();
}
(其实这个和 Java里面的实现也蛮像的。)
2.信号量的实现
信号量本身是整型,所以需要一个整型记录信号量加加减减的值,P 操作时可能有进程要在信号量上进行等待,所以需要一个等待队列。
class Semaphore
{
int sem;
WaitQueue q;
}
P操作:
Semaphore: P()
{
sem--;
if(sem<0)
//将进程放入等待队列,并进入睡眠
Add this thread to q;
block(p);
}
V操作:
Semaphore: V()
{
sem++;
//判断是否有进程在睡眠
if(sem<=0)
//从等待队列取出一个(等待时间最久的)进程
Remove this thread from q;
wekeup(p);
}
信号量除了以上互斥之外,还可以用在 条件同步
,而且使用了等待队列,所以和 锁的 “忙等” 是有区别的,是可以睡眠的。而信号量存在一个问题——不难处理死锁。
二、管程(Moniter)
管程主要是针对语言的并发情况提出的。
管程是一种用于多线程互斥访问共享资源的程序结构,采用面向对象方法,简化了线程间的同步控制,任一时刻最多只有一个线程执行管程代码,正在管程中的线程可临时放弃管程的互斥访问,等待事件出现时恢复。
管程包含一个锁🔒(指定临界区,保证互斥) 和 0 或 多个条件变量 (将等待的线程进行挂起)。
(像极了 Java 里的 wait 和 notify ☕)
-
Lock 锁🔒
Lock :: Acquire() :等待直到锁可用,然后抢占锁。
Lock :: Release() :释放锁,唤醒等待者。 -
Condition Variable 条件变量:
Class Condition {
int numWaiting = 0;
WaitQueue q;
}
(1)Wait operation
释放锁,然后睡眠。
Condition::Wait(lock){
numWaiting++;
Add this thread t to q;
//还没获得呢,可先释放了。
release(lock);
//选择下一个线程
schedule(); //need mutex
require(lock);
}
这里先 release 再 require 了。
(2)Signal() operation (or backcast() operation)
当某些条件得到满足了,唤醒等待者。
Condition::Signal(){
if (numWaiting > 0) {
Remove a thread t from q;
//唤醒线程
wakeup(t); //need mutex
numWaiting--;
}
}
👀例:有界缓冲区的生产者-消费者问题
classBoundedBuffer {
…
Lock lock;
//记录 buffer 当前空闲情况,
//如果 count 为 0 表示 buffer 为空;
//如果 count 为 1 表示 buffer 为满。
int count = 0;
Condition notFull, notEmpty;
}
生产者:
BoundedBuffer::Deposit(c) {
/*进入管程的线程是唯一的互斥的*/
lock->Acquire();
while (count == n)
//count 增加到 n 表示 buffer 满啦
//进入睡眠
notFull.Wait(&lock);
Add c to the buffer;
count++;
notEmpty.Signal();
/*进入管程的线程是唯一的互斥的*/
lock->Release();
}
消费者:
BoundedBuffer::Remove(c) {
lock->Acquire();
while (count == 0)
notEmpty.Wait(&lock);
Remove c from buffer;
count--;
notFull.Signal();
lock->Release();
}
线程在管程中执行时,如果某个线程要执行某个条件变量的唤醒 signal 操作时,执行完之后是马上执行等待在条件变量上的线程,(Hoare)还是执行完 调用了 signal 操作的这个线程之后(Hansen),再去执行等待的线程。(因为调用了 signal 之后,实际上有两个线程是可以执行的,一个是等待着的,一个就是调用了 signal 的这个,到底选择哪个进程先执行,这 是个问题。感觉之前接触到的不会等这个线程执行完的,就直接去执行等待着的。😥)
Hansen 管程与 Hoare 管程
对比俩的代码(前面的例子里用的就是 Hansen-while ):
Hansen-style :Deposit(){
lock->acquire();
while (count == n) {
notFull.wait(&lock);
}
Add thing;
count++;
notEmpty.signal();
lock->release();
}
Hoare-style: Deposit(){
lock->acquire();
if (count == n) {
notFull.wait(&lock);
}
Add thing;
count++;
notEmpty.signal();
lock->release();
}
主要是用 while 还是 if 的区别,Hansen 执行完 signal 之后,并没有马上执行等待着的线程,必须要继续往下执行,count++、release 锁释放,然后,可能有多个等待在条件变量的线程也被唤醒 (???这块没懂) 有可能存在多个被唤醒的线程,大家都会去抢占 CPU,然后只能有一个线程被选中,所以线程被选中之后,count 不为 n 了,所以需要用 while 再确认,而用 Hoare 的话,调用了 signal 就去执行能够等待着的线程了,就唤醒了一个 ,count 值一定不为 n (因为做 signal 时是 count 小于 n 时)。