并发与同步2
并发问题:多线程并发导致资源竞争
同步概念:协调多线程对共享数据的访问;任何时刻只能有一个线程执行临界区代码;
基本同步方法
最底层需要硬件支持,例如禁用中断,原子操作等;在此之上进行抽象,产生信号量,锁以及条件变量等概念;进而支持临界区和管程等高级抽象的实现;
1.信号量(semaphore)
信号量是操作系统提供的一种协调共享资源访问的方法,由Dijkstra在20世纪60年代提出。
信号是一种抽象数据类型,由一个整形 (sem)变量和两个原子操作组成:
P() sem减1,如sem<0, 进入等待, 否则继续;
V() sem加1,如sem≤0,唤醒一个等待进程;
信号量的特性
信号量是被保护的整数变量,初始化完成后,只能通过P()和V()操作修改,由操作系统保证,PV操作是原子操作;
P() 可能阻塞,V()不会阻塞;
通常假定信号量是“公平的”,线程不会被无限期阻塞在P()操作,假定信号量等待按先进先出排队;
信号量的实现
信号量主要由整形变量sem和等待队列组成,
对于P操作,sem--,如果当前sem<0,则该线程加入到等待队列,阻塞;
对于V操作,sem++,如果当前sem<=0,则说明当前由线程在等待队里阻塞等待,则唤醒其中一个线程执行;
信号量分类
二进制信号量:资源数目为0或1
资源信号量:资源数目为任何非负值
信号量的使用
互斥访问:临界区的互斥访问控制
条件同步:线程间的事件等待
用信号量实现临界区的互斥访问
每个临界区设置一个信号量,其初值为1,进入临界区之前对于信号量执行P操作,离开临界区时对于信号量执行V操作;
用信号量实现条件同步
每个条件同步设置一个信号量,其初值为0,由于信号量初始为零,因此线程A执行P操作会阻塞,只有等到线程B执行完V操作之后,线程A才会继续执行,即让线程A等待线程B的执行,实现同步。
用信号量解决生产者-消费者问题
任何时刻只能有一个线程操作缓冲区,缓冲区空时,消费者必须等待生产者,缓冲区满时,生产者必须等待消费者。
mutex为二进制信号量,用于缓冲区的互斥访问,
fullBuffers为缓冲区满信号量,emptyBuffers为缓冲区空信号量,用于条件同步
对于生产者,首先判断缓冲区是否有空闲,如果非空,则不能生成需要等待;如果缓冲区有空闲,则对于缓冲区加锁,生产一个元素,释放信号量mutex,此时对于fullBuffers执行V操作,说明当前缓冲区中有元素,如果有消费者等待,则可以执行消费操作。
对于消费者,首先判断缓冲区是否有元素,如果没有,则需要阻塞等待;如果缓冲区有元素,则对于缓冲区加锁,消费元素,释放锁;对于emptyBuffers执行V操作,如果此时有生产者正在等待生产,则可以继续生产,实现同步。
2.管程(Moniter)
管程是一种用于多线程互斥访问共享资源的程序结构,采用面向对象方法,简化了线程间的同步控制,任一时刻最多只有一个线程执行管程代码,正在管程中的线程可临时放弃管程的互斥访问,等待事件出现时恢复。
管程的组成
一个锁:控制管程代码的互斥访问
0或者多个条件变量:管理共享数据的并发访问
3.条件变量(Condition Variable)
条件变量是管程内的等待机制,进入管程的线程因资源被占用而进入等待状态,每个条件变量表示一种等待原因,对应一个等待队列。
Wait()操作 将自己阻塞在等待队列中,唤醒一个等待者或释放管程的互斥访问;
Signal()操作 将等待队列中的一个线程唤醒,如果等待队列为空,则等同空操作;
条件变量实现
条件变量由等待队列和numwaiting组成。
注意条件变量是对于锁来使用的,在执行wait操作之前,首先需要获取锁。
对于wait操作,等待数量加一,将线程加入到等待队列,然后释放锁,挑选另一个线程执行,再次加锁;
对于signal操作,如果当前有等待线程,则挑选一个等待线程,唤醒来执行,执行等待数量减一;
用管程解决生产者-消费者问题
一个锁用来互斥,两个条件变量,用来同步。
对于生产者,首先获取锁,判断缓冲区是否已满,如果已满,则执行等待,此时当前线程加入到等待队列,释放锁,操作系统执行其他线程;如果当前缓冲区非满,则生产一个元素,通知消费者,可以进行消费,最后释放锁。
对于消费者,首先获取锁,判断缓冲区是否已空,如果已空,则执行等待;如果非空,则消费一个元素,通知生产者可以进行生产。
管程条件变量的释放处理方式
对于管程来说,某一时刻,T1位于管程中,获取锁,执行wait操作,释放管程锁,进入等待过程;
T2进入管程,执行signal操作,此时T2在管程中,执行singal操作之后,T1会被唤醒,为避免管程中同时存在两个线程,因此需要控制T1恢复执行的时机,hansen方法是T2执行完毕,离开管程,T1才开始在管程中继续执行;hoare方法则T1立即恢复执行执行完毕之后,T2继续执行。hansen办法高效,hoare办法需要多执行一次上下文切换,比较低效。
Hansen管程:条件变量释放仅是一个提示,需要重新检查条件
Hoare管程:条件变量释放同时表示放弃管程访问,释放后条件变量的状态可用
4.哲学家就餐问题
方案一:
将叉子作为共享资源,利用5个信号量来表示。
哲学家编号为0--4,对于每个哲学家来说,首先思考,然后拿左右叉子,然后吃饭,再放下左右叉子。
该方案容易出现死锁问题,例如5位哲学家同时拿起左边叉子,下一步会同时等待拿右边叉子,此时会出现死锁问题。
方案二:
设置5个信号量来代表叉子资源,同时设置一个二值信号量来作为互斥锁,用来保证同时只有一位哲学家可以吃饭。
每位哲学家思考之后,同时来竞争互斥锁,只有一位哲学家可以吃饭,可以保证并发问题,但是效率不高。
方案三:
可以多人就餐
方案四:
指导原则;要么不拿,要么拿两把叉子
1.思考中
2.进入饥饿状态
3.如果左邻居或右邻居正在进餐,等待;否则,转4
4.拿起两把叉子
5.吃饭
6.放下左叉子 放下左叉子,可能要唤醒左邻居
7.放下右边叉子 放下右叉子,可能要唤醒右邻居
8.新循环开始,转1
哲学家的状态:思考,饥饿,就餐;
state来记录每个哲学家的状态
mutex互斥量,用来实现对于state读取赋值等操作的互斥性
s[N]信号量,用来唤醒邻居,实现同步操作
对于每个哲学家,思考,拿起两把叉子或者被阻塞,吃饭,放下两把叉子
由于需要操作state共享资源,因此首先加互斥锁,当前状态设为饥饿状态,试图拿起两把叉子,如果拿到两把叉子,则开始吃饭,如果拿不到便阻塞。
当前状态为饥饿状态,左右邻居非吃饭状态,则表示可以拿起两把叉子,当前状态设为吃饭状态,通知当前哲学家可以吃饭
放叉子,需要操作共享资源state,因此需要加锁,然后修改状态为思考状态,看左邻居和右邻居是否可以进餐。
5.读者-写者问题
读者:只读取数据,不修改;写者:读取和修改数据
同一时刻,允许有多个读者同时读;没有写者时读者才能读,没有读者时写者才能写;没有其他写者时写者才能写;
5.1读者优先----用信号量解决读者-写者问题
信号量WriteMutex:控制读写操作的互斥,初始化为1
读者计数Rcount:正在进行读操作的读者数目,初始化0
信号量CountMutex:控制对读者计数的互斥修改,初始化1
首先用WriteMutex来保证读者和写者的互斥性
如果当前只有一个读者,需要获取WriteMutex来保证读者和写者互斥性;如果有多个读者,则可以直接rount++,进行读操作;
读操作结束之后,rount--,如果当前已经没有读者,则需要唤醒可能存在的写者。
由于可能存在多个读者,即对于rount的操作必须保证互斥性,即通过CountMutex来保证互斥性
只要有读者正在读状态,后来的读者都能直接进入,如读者持续不断进入,则写者就处于饥饿。
5.2写者优先----用管程解决读者-写者问题
只要有写者就绪,写者应尽快执行写操作。
读者实现
记录活跃等待读者 活跃等待写者数量,互斥锁,两个条件变量用来同步。
对于读者,如果当前没有写者,则开始执行读操作;如果读完之后,则应该唤醒可能存在的写者。
由于需要对WR,AR共享变量操作,因此需要首先获取锁;如果当前有写者AW+WW>0,则该读者应该等待,WR++,等待;
结束读操作,AR--,如果当前没有活跃读者并且有等待写者,则通知等待写者可以写
写者实现
如果当前有活跃写者或读者,则写者应该等待
写结束之后,如果还有等待写者,则唤醒等待写者;如果还有等待读者,则唤醒所有等待读者进行读操作。
参考:清华大学 操作系统 陈渝 http://os.cs.tsinghua.edu.cn/oscourse/OS2015/