同步的唯一问题就是共享变量的竞态,我们使用锁来保护操作共享变量的代码段,从而保证操作共享变量时候不会被中断。
锁的实现的硬件基础就是cas和tas,所有锁(信号量,互斥锁)都是通过cas和tas实现的。
一个进程在它指令流上的任意一点都会可能会被中断,并且转而执行其他进程。
临界区:
多个线程访问的数据结构的代码区域。
赋值操作,需要将值取到寄存器中,然后修改,再放回。被打断会有问题。
所以在临界区,不同进程的转换,因为非原子操作的中断转而执行其他进程,会导致竞争冲突。
单线程非抢占式内核的数据结构基本不会存在竞态问题,而抢占式的数据结构需要设计避免。
抢占式的优点:因为不会有进程运行任意长的时间。
临界区问题的解决方案必须满足下面三个条件:
(1)进程互斥。
(2)进步(需要选出谁来进入临界区)
(3)有限等待:没有进程一直想进入但无限等待。
忙等待:即自旋锁,一直会请求获得锁(一个while)
同步方案
经典的基于软件的临界区的解决方案:Peterson解决方案
用到的数据结构包括(i=0,j=1):
int turn;//i为i进入,j为j进入。
boolean flag[2];//flag[i]为i能否进入,flag[j]为j能否进入。
do{//这是线程i想要进入临界区
flag[i]=true;
turn=j;//表示轮到j
while(flag[j]&&turn==j);
//如果flag[j]为真,表示j也有进入的打算,并且如果trun为j,那么表示线程i设置turn在线程j设置trun后实现的,那么线程i进入忙等待。当线程 j执行完成后,flag[j]为false,忙等待结束,开始执行线程i。
临界区
flag[i]=false;
}while(true)
硬件同步
对于单处理器环境,临界区的问题只要在修改共享变量时候,禁止中断就行了。
但是多处理不行,因为性能下降很大。
原子指令:通过硬件实现的原子式操作,指令执行中不能中断
就是tas和cas
(1)test_and_set(boolean target)
如果target为true,则能set。
(2)compare_and_swap(int* old_value,int expected,int new_value)
如果old_value为expected,那么old_value=new_value;
互斥锁
使用硬件实现的软件工具来解决方案,最简单的工具就是互斥锁,就是使用互斥锁来保护临界区。
do{
acquire_mutex();
临界区;
release_mutex();
剩余区;
}while()
acquire是通过自旋实现的,会浪费一个进程。
优点:如果使用锁的时间很短,将不需要上下文切换。
信号量
包装硬件指令实现的软件工具。其作用是保证临界区代码只由一个线程执行。
(1)一个信号量,除了初始化,只有两个原子操作wait()和signal()来访问。
wait是一个自旋的–操作
wait(s){
while(s<=0)
{};
s--
}
signal(s){
s++
}
(2)计数信号量和二进制信号量(只能为1或者0)
计数信号量可以用来控制访问具有多个实例的各种资源,信号量的初始值为可用资源数,使用一个就减去1。
二进制信号量可以当作互斥锁。
(3)通过等待队列实现计数阻塞,而不是自旋
只需要一个链表实现的等待队列,wait如果s<0,那么将pcb添加到等待对列中。
signal()会wakeup()唤醒队列进程。
死锁和饥饿
具有等待队列信号量会导致这种情况:两个或多个进程无限等待一个事件,而该事件只能由等待对列中的事件释放。
这就是死锁。
饥饿是由于优先级LIFO队列导致的。
经典问题
(1)读者-作者问题(读写锁)
(2)哲学家就餐问题
(3)有界缓冲问题
管程
因为直接操作信号量可能会因为用户的使用错误而导致时许错误,管程是一种(Abstract Data Type,ADT)封装了数据以及操作,保证不会因为使用而错误。
管程的实现是通过信号量的操作的封装来实现的。
管程确保只有一个进程在管程内部处于活动。进程进入管程前执行wait(mutex),离开后执行signal();
条件变量:信号量。对于信号量只有wait()和signal()可以操作。
设计细节:当有多个进程wait(),而有一个进程signal(),那么谁来获得执行权呢?
可以使用一个FCFS队列。
也可以使用优先级,在wait(优先级),优先级考前的先执行。
synchromized就是一个管程的实现。