1.背景
先回顾一下上一章的内容,上一章讲了并发会带来竞态条件的问题,我们用互斥可以保证一定的确定性。但是光有互斥是不够的的,我们有时希望多线程能够同时访问某些共享资源,这时候只有互斥是不够的,我们还需要实现同步,同样我们需要更高层的编程抽象(如:锁),需要从底层硬件支持编译来实现。
同步:我们把异步环境下的一组并发进程因直接制约而互相发送消息、进行互相合作、互相等待,使得各进程按一定的速度执行的过程称为进程间的同步。 比如说进程A需要从缓冲区读取进程B产生的信息,当缓冲区为空时,进程B因为读取不到信息而被阻塞。而当进程A产生信息放入缓冲区时,进程B才会被唤醒。
2.信号量(semaphore)
前面所说的方法主要用于实现互斥,在实现同步的时候乏力,我们可以使用信号量来实现同步和互斥。
信号量是操作系统提供的一种协调共享资源访问的方法,由Dijkstra在20世纪60年代提出,是早起操作系统的主要同步机制,现在很少用。用信号量表示系统资源的数量。
3.信号量的使用
信号量包含有以下特征:
- 信号量是整数:信号量是以整数的形式存在(代表资源的数量),并在初始化的时候初始化为大于0的数,使得P操作不会被阻塞。
- 信号量是被保护的变量:初始化完成后,唯一改变信号量的办法是通过P()和V(),操作必须是原子的。
- P()能够阻塞,V()不会阻塞:从占有和释放的角度思考比较容易理解。
- 假定信号量是“公平的”:没有线程会被无限期地阻塞在P()操作,信号量等待的进程是在FIFO的队列里排队。
有两种类型的信号量:
- 二进制信号量:可以是0或1
- 一般/计数信号量:可取任何非负值,体现在可以有多个进程访问共享资源
信号量可以用在两个方面:
- 互斥
- 条件同步(调度约束——一个线程等待另一个线程的事情发生)
用二进制信号量实现互斥
用二进制信号量实现条件同步
同步操作初始值就不一样了,作用就是线程A等待线程B的执行。只有在线程B执行完X部分的代码以后,线程A才有机会执行N部分的代码。
信号量用在同步和互斥的实现差别在于,互斥是在一个进程里实现一组信号量控制访问,同步是通过不同进程信号量的设置来控制特定的执行顺序。
生产者-消费者问题
我们还有一些更复杂的同步互斥情况,用简单的信号量可能无法有效解决,这时我们需要用到所说的一种条件同步的机制来完成。例如,生产者和消费者的问题。
问题描述:
这个过程会同时涉及到同步和互斥,通过问题阐述可以看出有以下正确性要求:
- 在任何一个时间只能有一个线程操作缓冲区(互斥)
- 当缓冲区为空,消费者必须等待生产者(调度/同步约束)
- 当缓存区满,生产者必须等待消费者(调度/同步约束)
通过分析,每条正确性要求用一个单独的信号量来解决:
- 二进制信号量互斥:二进制信号量满足任何一个时间只有一个进程操作缓冲区。
- 一般信号量fullBuffers:唤醒消费者取数据。
- 一般信号量emptyBuffer:唤醒生产者放数据。
信号量初始化:
互斥二进制信号量初始化为1;fullBuffers初始化为0代表此时buffer里没有可取资源;emptyBuffers初始化为n代表buffer中可放n个数据。完成初始化以后,就可以建立生产者(Deposite)和消费者(Remove)的对应函数。
最基础的就是Deposite函数执行Add 以及 Remove函数执行Remove,那我们首先要考虑满足互斥,在Add和Remove两个最基础的指令前后都加上mutex->P()和mutex->V(),然后再满足后两条正确性同步要求。
V操作交换顺序有没有问题?有可能出现问题
比如生产者的mutex->V和fullBuffers->V交换顺序:没问题,V操作不会产生堵塞,顺序执行
比如生产者的emptyBuffers->P和mutex->P交换顺序:有问题,比如生产的很快,buffer里已经有n个数据了,这时候左边又一个生产者执行完mutex->P后执行emptyBuffers->P发现被阻塞了,要等消费者拿走一个。这时来了一个消费者,执行fullBuffers->P,由于信号量满了,可以继续往下走,执行mutex->P发现被阻塞了,被阻塞的生产者等待消费者执行emptyBuffers->V,被阻塞的消费者等待生产者执行mutex->V,这就出现了死锁。
4.信号量的实现
不光要会用信号量,还要知道信号量实现的细节。我们想一下前面讲进程的时候,说如果进程等待某种资源的时候会进入sleep,资源满足了再被唤醒。我们也可以把信号量看做虚拟资源,当条件满足(值<0)的进程进入等待队列,等待信号量执行V操作以后再被唤醒的一个过程。实现如下图所示:
使用信号量的困难
使用信号量的困难主要体现在对开发人员的水平要求高以及难调试两方面,如果使用的信号量已经被另一个线程占用或者忘记释放信号量,就有可能出错。更倒霉遇到死锁问题,需要先心里明白正确的执行顺序,然后通过在合适的地方打印日志缩小出错范围,最后准确分析错误来源。
5.管程
知道了使用信号量的困难,我们想要一种比信号量更简单易用的同步互斥机制——管程。管程的抽象级其实比我们的同步互斥更高,也就意味着对于开发者更友好,更容易完成同步互斥。
管程最早产生的目的和信号量不同,它不是为了应用在操作系统领域,它是为了应用在编程语言中,来简化高级语言同步互斥操作。
管程的组成
什么是管程:
- 一个锁:用于指定临界区。
- 0或者多个条件变量:等待/通知信号量用于管理并发访问共享数据,通过条件的个数确定条件变量的个数。
条件变量实现
wait表示为进入管程的进程分配某种类型的资源,如果此时这种资源可用,那么进程使用,否则进程被阻塞,进入紧急队列。 signal表示进入管程的进程使用的某种资源要释放,此时进程会唤醒由于等待这种资源而进入紧急队列中的第一个进程。
用管程解决生产者-消费者问题
6.读者-写者问题
读者-写者问题出现的动机是共享数据的访问。
问题中有两种角色:
- 读者:不需要修改数据,只需要读
- 写者:读取和修改数据
问题的约束
- 允许同一时间有多个读者,但在任何时候只有一个写者
- 当没有写者时读者才能访问数据
- 当没有读者和写者时,写者才能访问数据
- 在任何时候只能有一个线程可以操作共享变量
- 读者优先: 在有读操作和写操作的时候,读操作可以跳过写操作优先执行
用信号量解决读者-写者问题(读者优先)
写者这一块要保持互斥,要有sem_wait(WriteMutex)P操作和sem_post(WriteMutex)V操作来保证写时只有一个进程进入,而读者这块也一样,通过sem_wait(WriteMutex)P操作和sem_post(WriteMutex)V操作来保证写时不读;Rcount记录读者个数,只有Rcount=0时才开始写,不然利用sem_wait(WriteMutex)P操作和sem_post(WriteMutex)V操作保证写进程是进不来的(读者优先)。Rcount是一个共享变量,所以在改变Rcount的值的时候需要有互斥锁sem_wait(countMutex)和sem_post(countMutex)包起来使得Rcount互斥保护。
基于读者优先的策略方法中,只要有一个读者处于活动状态,后来的读者都会被接纳。如果读者源源不断地出现的话,那么写者就始终处于阻塞状态。
基于写着优先的策略方法中,一旦写者就绪,那么写者会尽可能快地执行写操作。如果写者远远不断地出现的话,那么读者就是中处于阻塞状态。
用管程解决读者-写者问题(写者优先)
读者在执行读操作的时候要看看有没有写者,写者有两类,第一种是写者正在写,那么读者在外面等待;第二种是写者在等待队列里等待,读者要等待写者的进入与完成。只有这两类写者都不存在,读者才能进入。管程的状态变量有四种,表示如下图:
用到了整形的条件变量,还用到了Lock来表示。
整体设计是一个由粗到细的细化来完成整个过程:既然是管程中的一个函数,那我们有必要保证它是互斥的,因为它只有一个函数能进入管程其中执行,刚开始把Lock.Acquire()和Lock.Release()加在函数首尾保证互斥;利用AW+WW来判断有没有写者,如果>0就说明有写者,不然没有写者。如果AR==0且WW>0,我们唤醒等待的写者。startread和DoneRead联合完成了读操作的实现。AW+WW>0表明了写者优先。
同样,可以控制写者的开始和结束。
7.哲学家就餐问题
方案1
很自然的一种思考实现。一个哲学家没问题,五个哲学家有可能出现问题。
可能会出现死锁,每个哲学家同时拿左边叉子,都要等待右边叉子,进入死锁状态。
方案2
把拿叉子过程和吃饭过程互斥保护起来。
虽然通过互斥逃脱了死锁和饥饿,但效率很低,明明可以允许两个不相邻的哲学家同时就餐。它把就餐(而不是叉子)看成了必须互斥访问的临界资源,因此会造成(叉子)资源的浪费。
方案3
指导原则1(哲学家的思考角度):要么不拿,要么就拿两把叉子,因为只拿到一把叉子是没有意义的。
指导原则2(计算机的思考角度):不能浪费CPU时间,进程间相互通信。
依据两个指导原则编写程序,需要注意:
- 必须有数据结构,来描述每个哲学家当前的状态;
- 该状态是一个临界资源,各个哲学家对它的访问应该互斥——进程互斥
- 一个哲学家吃饱后,可能要唤醒它的左邻右舍,两者之间存在同步关系——进程同步
方案3最终的实现代码如下:(此图应该不对)