Mutex, Semaphore and Monitor (1)
虽然此前在多个项目中做过多线程方面的工作,但是对于Mutex(互斥锁)、Semaphore(信号量)以及Monitor(监视器/管程)的理解不是很透彻。在看了一些资料,做了些验证之后,总算有了些初步的认识。把这些认识放在这里,供大家参考,有错误还请指正!
首先谈一下Mutex,这应该是在初学多线程开发时碰到最多也是最基本的同步方式。在Java中,当我们想对多线程共享的数据进行读写操作的时候,为了线程安全,我们一般都会在涉及该共享数据的读写方法上加上synchronized关键字,或者使用直接Lock对象。这样,只有首先进入synchronized或者Lock保护的代码块的线程,也就是获得了该对象锁的线程能够执行,其余试图执行该代码块的线程都会进入阻塞状态(JVM可能会有做一些SpinLock的优化,这里不考虑),直到获得锁的线程离开该代码块,释放锁。这就是Mutex在Java中的体现形式。而在C语言标准中,对于多线程编程来说,并没有提供原生的Mutex机制,但可以使用pthread库来完成,原理上来说应该是一样的。
再来说说Semaphore。在大学的基础操作系统课中,讲到进程同步,Semaphore是无法绕过的一节。在我用的教材里,进程同步的三个经典问题:生产者与消费者问题、哲学家进餐问题、读者与写者问题,都是用Semaphore解决的,反倒Monitor是一页带过,跟我实际的项目经验恰恰相反。那么什么是Semaphore呢?其实Semaphore就是一个共享的计数器,在这个计数器上定义了两种操作:
- P操作。它是一个原子操作,等待直到这个计数器的值大于0,将其减1。
- V操作。它同样是一个原子操作,它直接将计数器的值加1。
从它的定义可以看出,如果我们用计数器来表示可用资源的数目,那么它很适合对一组资源进行同步控制,比如经典的生产者消费者问题。而且,如果Semaphore如果是一个二元Semaphore(通常你可以设置计数器初值为1),那么它就是一个Mutex。
下面我们以生产者消费者问题为例来理解下Semaphore的原理和使用方式。先描述下生产者消费者问题:生产者往缓冲区放一个物品,如果满了就等待;消费者从同一缓冲区取一个物品,如果空了就等待。
Semaphore empty = N;
Semaphore full = 0;
Semaphore mutex = 1;
Producer() {
int item; while (1) { item = Bake(); P(empty); P(mutex); Insert(item); V(mutex); V(full); } } |
Consumer() {
int item; while (1) { P(full); P(mutex); item = Delete(); V(mutex); V(empty); Eat(item); } } |
在这个问题中我们使用了3个信号量。首先要理解,缓冲区的空和满都是共享资源,因而设置了empty和full这两个信号量。empty初始值为N,full初始值为0。生产者每次会把empty减1,对应的把full加1;而消费者刚好相反。比较易忽略的是mutex信号量,它是一个2元信号量,其实也就是一个mutex锁,它的作用是保证缓冲区的读写进(线)程安全。这是什么意思呢?比如说有多个Producer,如果没有P(mutex)的保护,它们可能会同时执行Insert(item)。这会造成一个问题,比如缓冲区是一个数组的话,那么Insert的实现方式可能就是,找到一个合适的位置i,执行buffer[i] = item,如果两个Producer找到的是同一个i,那么有一个Producer的Item就被覆盖了,所以需要通过mutex保证缓冲区的读写安全性。
由此看来,信号量似乎很好地解决了同步问题了。但是,虽然对于消费者/生产者这种简单的模型,信号量似乎已经足够方便了,但是当问题变得稍微复杂一点,你会发现信号量就比较麻烦了。记得,我在学操作系统课,使用信号量解决读者/写者问题时,就已经有点糊涂了。你会发现,对于一个看似简单的问题,会搞出许多信号量来,而且其PV操作并不是成对的。信号量好像一个只能容纳有限人数的黑屋子,放了指定人数的人进去后,就关上门,什么也不管了。事情还是能做,但就是麻烦,且容易出错。说到底,还是因为信号量对于线程间的通知机制的描述不够。
不过还好有Monitor。在谈Monitor之前,需要先了解下条件变量(CV,Condition Variable),因为Monitor是基于CV的。先来介绍CV的定义:
一个条件变量是指这样一种条件:一个线程能够在改条件上等待,直到满足该条件;当条件满足时能够唤醒等待在该条件上的其他线程。
在条件变量上定义了3种操作:
- Wait() -- 阻塞调用线程直到有其他线程调用Signal或者Broadcast去唤醒它;
- Signal() -- 唤醒在该CV上等待的一个线程;
- Broadcast() -- 唤醒在该CV上等待的所有线程;
如果要在C中使用条件变量,可以使用pthread中的类型pthread_cond_t,其接口如下所示:
pthread_cond_init() -- 初始化一个条件变量;
pthread_cond_wait(&theCV, &someMutex) -- 在init产生的CV上等待;
pthread_cond_signal(&theCV) -- 唤醒在CV上等待的某个线程;
pthread_cond_broadcast(&theCV) --唤醒在CV上等待的所有线程;
可以看到,使用CV时,需要与一个mutex配合使用,而且每个CV的操作,都需要在获得该mutex互斥锁的情况下进行。我们来看下条件变量的一个应用例子:
这个代码的意思是,A一直处于等待状态,直到B将Counter增为10时,唤醒A。如果不使用mutex(图中的myLock),对于A来说,可能会出现B已经将counter增为10,而A仍然认为counter小于10的情况,进而进入等待状态,背离了代码的本意。而且,会使得线程B的自增操作不是原子操作,导致出现一系列的问题,因此这个mutex是必须的。当然,这只是一个例子,不过,从更广泛的范围看,这个mutex都是不可或缺的。这也是pthread的接口,在调用wait时有个mutex参数的原因。
我们再来看看,在进行CV操作的时候,其内部都发生了哪些事情,我们还是以上面那段代码为例,通过描述其具体过程,来了解CV操作。
① 假设A执行pthread_mutex_lock,拿到了锁,B进入阻塞状态。然后由于此时counter<10成立,A执行pthread_cond_wait,这会导致两个结果:一是A会在CV上等待,二是A会释放锁。
② B由阻塞变为可执行状态,执行pthread_mutex_lock获得锁,将counter自增为1,由于counter >=10不成立,因此执行pthread_mutex_unlock释放锁。
③ B循环执行,重新获得锁,自增,释放锁,直到counter == 10,执行pthread_cond_signal 唤醒线程A。这里需要注意的是,signal并不会释放锁,而 wait需要重新拿回锁才能返回,因而只有当B执行pthread_mutex_unlock后,A才有可能(因为B在循环执行,他可能会继续拿到锁)拿到锁,恢复执行。
所以CV的要点有三个:
- 所有CV操作需要在拿到互斥锁的情况下执行;
- 执行wait会等待并释放锁,直到有其他线程唤醒它,且它能重新拿到锁,才能恢复执行;
- 执行signal或者broadcast会唤醒线程,但并不会释放锁。
那么CV和Monitor又有什么关系呢?我会在下一篇予以详细描述。