多线程同步
一、竞争与协作
互斥
由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区,它是访问共享资源的代码片段,⼀定不能给多线程同时执行。所以我们希望这段代码是互斥的,也就说保证⼀个线程在临界区执行时,其他线程应该被组织进入临界区。
同步
所谓同步,就是并发进程/线程在⼀些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通 信息称为进程/线程同步。
互斥与同步
- 互斥:操作A和操作B不能在同⼀时刻执行
- 同步:操作A应在操作B之前执行,操作C必须在操作A和操作B都完成之后才能执行。
二、互斥与同步的使用
为了实现进程/线程间正确的协作,操作系统必须提供实现进程协作的措施和方法,主要的方法有两种:
- 锁:加锁、解锁操作
- 信号量:P、V 操作
这两个都可以方便地实现进程/线程互斥,而信号量比锁的功能更强⼀些,它还可以方便地实现进程/线程同步。
锁
任何想进⼊临界区的线程,必须先执⾏加锁操作。若加锁操作顺利通过,则线程可进⼊临界区;在完成后,对临界资源的访问后再执行解锁操作,以释放该临界资源。
根据锁的实现不同,可以分为忙等待锁和无忙等待锁。
- 忙等待锁:当获取不到锁时,线程就会⼀直wile循环,不做任何事情,所以就被称为忙等待锁,也被称为自旋锁。 这是最简单的⼀种锁,⼀直自旋,利用CPU 周期,直到锁可用。在单处理器上,需要抢占式的调度器(即不断通过时钟中断⼀个线程,运行其他线程)。否则,自旋锁在单 CPU上无法使用,因为⼀个自旋的线程永远不会放弃CPU。
- 无等待锁:等待锁顾明思议就是获取不到锁的时候,不用自旋。
既然不想自旋,那当没获取到锁的时候,就把当前线程放入到锁的等待队列,然后执行调度程序,把CPU让给其他线程执行。
信号量
信号量是操作系统提供的⼀种协调共享资源访问的方法。 通常信号量表示资源的数量,对应的变量是⼀个整型( sem )变量。 另外,还有两个原子操作的系统调用函数来控制信号量的,分别是:
- P 操作:将 sem 减 1 ,相减后,如果 sem < 0 ,则进程/线程进⼊阻塞等待,否则继续,表明 P 操作可能会阻塞;
- V 操作:将 sem 加 1 ,相加后,如果 sem <= 0 ,唤醒⼀个等待中的进程/线程,表明 V 操作不会阻塞;
P 操作是用在进⼊临界区之前,V 操作是⽤在离开临界区之后,这两个操作是必须成对出现的。
PV 操作的函数是由操作系统管理和实现的,所以操作系统已经使得执行PV 函数时是具有原子性的。
信号量不仅可以实现临界区的互斥访问控制,还可以线程间的事件同步。
我们先来说说如何使用信号量实现临界区的互斥访问。
对于两个并发线程,互斥信号量的值仅取 1、0 和 -1 三个值,分别表示
- 如果互斥信号量为 1,表示没有线程进⼊临界区;
- 如果互斥信号量为 0,表示有⼀个线程进⼊临界区;
- 如果互斥信号量为 -1,表示⼀个线程进⼊临界区,另⼀个线程等待进⼊。
同步的方式是设置⼀个信号量,其初值为 0。
三、经典问题
生产者-消费者问题
生产者-消费者问题描述:
- 生产者在生成数据后,放在⼀个缓冲区中
- 消费者从缓冲区取出数据处理
- 任何时刻,只能有⼀个生产者或消费者可以访问缓冲区
那么我们需要三个信号量,分别是
- 互斥信号量 mutex :用于互斥访问缓冲区,初始化值为 1
- 资源信号量 fullBuffers :用于消费者询问缓冲区是否有数据,有数据则读取数据,初始化值为 0 (表明缓冲区⼀开始为空)
- 资源信号量 emptyBuffers :用于生产者询问缓冲区是否有空位,有空位则生成数据,初始化值为 n (缓冲区大小)
如果消费者线程⼀开始执行P(fullBuffers) ,由于信号量 fullBuffers初始值为 0,则此时fullBuffers的值从0变为 -1,说明缓冲区里没有数据,消费者只能等待。接着,轮到生产者执行P(emptyBuffers) ,表示减少1个空槽,如果当前没有其他生产者线程在临界区执行代码,那么该生产者线程就可以把数据放到缓冲区,放完后,执行V(fullBuffers) ,信号量fullBuffers从-1变成0,表明有消费者线程正在阻塞等待数据,于是阻塞等待的消费者线程会被唤醒。 消费者线程被唤醒后,如果此时没有其他消费者线程在读数据,那么就可以直接进入临界区,从缓冲区读取数据。最后,离开临界区后,把空槽的个数 + 1。
哲学者就餐问题
先来看看哲学家就餐的问题描述
- 5个老大哥哲学家,闲着没事做,围绕着⼀张圆桌吃面
- 巧就巧在,这个桌子只有5支叉子,每两个哲学家之间放一支叉子
- 哲学家围在⼀起先思考,思考中途饿了就会想进餐
- 奇葩的是,这些哲学家要两支叉子才愿意吃面,也就是需要拿到左右两边的叉子才进餐
- 吃完后,会把两支叉子放回原处,继续思考
方案一:有叉子就用
假设五位哲学家同时拿起左边的叉子,桌面上就没有叉子了, 这样就没有人能够拿到他们右边的叉子,很明显这发生了死锁的现象。
方案二:只准一个人拿叉子
只要有一个哲学家进了临界区,也就是准备要拿叉子时, 其他哲学家都不能动,只有这位哲学家用完叉子了,才能轮到下一个哲学家进餐。
方案三:规定先拿的叉子
让偶数编号的哲学家先拿左边的叉子后拿右边的叉子,奇数编号的哲学家先拿右边的叉子后拿左边的叉子。
方案四:记录状态
我们用⼀个数组state来记录每⼀位哲学家在进程、思考还是饥饿状态(正在试图拿叉子)。 那么,一个哲学家只有在两个邻居都没有进餐时,才可以进入进餐状态。
读写者问题
读者-写者的问题描述:
- 读-读允许:同⼀时刻,允许多个读者同时读
- 读-写互斥:没有写者时读者才能读,没有读者时写者才能写
- 写-写互斥:没有其他写者时,写者才能写
方案一:读者优先的策略
- 信号量 wMutex :控制写操作的互斥信号量,初始值为 1
- 读者计数 rCount :正在进行读操作的读者个数,初始化为 0
- 信号量 rCountMutex :控制对 rCount 读者计数器的互斥修改,初始值为 1
是读者优先的策略,因为只要有读者正在读的状态,后来的读者都可以直接进入,如果读者持续不断进⼊,则写者会处于饥饿状态。
方案二:写者优先策略
- 信号量 rMutex :控制读者进⼊的互斥信号量,初始值为 1
- 信号量 wDataMutex :控制写者写操作的互斥信号量,初始值为 1
- 写者计数 wCount :记录写者数量,初始值为 0
- 信号量 wCountMutex :控制 wCount 互斥修改,初始值为 1
方案三:公平策略
- 优先级相同
- 写者、读者互斥访问
- 只能⼀个写者访问临界区
- 可以有多个读者同时访问临界资源