在多线程编程中,为了保证数据操作的一致性,操作系统引入了锁机制,用于保证临界区代码的安全。通过锁机制,能够保证在多核多线程环境中,在某一个时间点上,只能有一个线程进入临界区代码,从而保证临界区中操作数据的一致性。
锁机制的一个特点是它的同步原语都是原子操作。那么操作系统是如何保证这些同步原语的原子性呢?
操作系统之所以能构建锁之类的同步原语,是因为硬件已经为我们提供了一些原子操作,比如:中断禁止和启用(interrupt enable/disable),内存加载和存入(load/store)测试与设置(test and set)指令。禁止中断这个操作是一个硬件步骤,中间无法插入别的操作。同样,中断启用,测试与设置均为一个硬件步骤的指令。在这些硬件原子操作之上,我们便可以构建软件原子操作:锁,睡觉与叫醒,信号量等。
1.以中断启用和禁止来实现锁
要防止一段代码在执行过程中被别的进程插入,就要考虑在一个单处理器上,一个线程在执行途中被切换的途径。我们知道,要切换进程,必须要发生上下文切换,上下文切换只有两种可能:
①一个线程自愿放弃CPU而将控制权交给操作系统调度器(通过yield之类的操作系统调用来实现)
②一个线程被强制放弃CPU而失去控制权(通过中断来实现)
原语执行过程中,我们不会自动放弃CPU控制权,因此要防止进程切换,就要在原语执行过程中不能发生中断。所以采用禁止中断,且不自动调用让出CPU的系统调用,就可以防止进程切换,将一组操作变为原子操作。
lock之中断启用与禁止:
lock()
{
disable interrupt
while(value!=FREE)
{
enable interrupt //使其他线程可以抢占,从而改变value的值
disable interrunpt //只有在这两行语句之间,别的进程才拥有抢占时机
}
value=BUSY
enable interrupt
}
unlock之中断启用与禁止:
unlock()
{
disable interrupts
value=FREE
enable interrupts
}
2.以测试与设置指令来实现锁
原子操作:(设置操作)将1写入到指定内存单元,(读取操作)返回指定内存单元里原来的值,也即写入新值1之前的内容。
测试与设置指令:
test_and_set(x)
{
tmp=x
x=1
return (tmp)
}
test_and_set(x)的操作是将1写入到变量x里,并将写1之前x的值返回。
使用测试与设置指令实现lock:
(value初始值为0,代表锁是打开的。)
lock()
{
while(test_and_lock(value)==1) {} //每次执行完原子操作后都有可能会被抢占
}
如果锁是打开的,即value是0的话,则返回值是0,该指令将value设置为1,获得锁并退出循环。
如果锁是闭合的,即value是1的话,则返回值是1,循环继续。直至成功获得锁为止。
使用测试与设置指令实现unlock:
unlock()
{
value=0 //因为是赋值0,可以直接在总线上产生,不用中断包裹着也没有问题
}
3.以非繁忙等待,中断启用与禁止来实现锁
前面两种锁的实现方式都较为简单,但都有一个问题,就是存在繁忙等待。而繁忙等待浪费资源,我们想到对前两种方法进行改善。改善思路:不进行繁忙等待,在拿不到锁的时候去睡觉,等待别人的叫醒。
先看一种锁操作实现方式:
lock()
{
disable interrupt
if(value==free)
{
value=busy
}
else
{
添加到锁的等待队列
切换到下一个线程
}
enable interrupt
}
使用非繁忙等待中断禁止与启用来实现释放锁操作:
unlock()
{
disable interrupt
value=free
if(有线程在等待锁)
{
移到就绪队列
value=busy
}
enable interrupt
}
但是这种方式存在着问题:是因为切换到别的进程之后,该程序无法再执行,那么后面的中断启用指令就不能执行了。而我们是在中断处于禁止状态下切换到别的进程的,如果别的进程没有执行中断启用或者自动放弃CPU给另一个线程,系统将进入死锁状态。
解决办法是闭锁操作不启用中断,而是留给别的线程去启用中断。
也就是说,我们要求所有线程遵守下列约定:
①所有线程承诺在调用线程切换调用时将中断留在禁止状态。
②所有线程承诺在从切换返回时将中断重新启用。
因此,使用非繁忙等待中断禁止与启用来实现锁操作的正确方式如下:
这里注意,switch表示切换线程;中断的启用与禁止是在系统调用(lock和yield)里面实现的,即由操作系统实现。
4.以最少繁忙等待,测试与设置来实现锁
使用测试与设置来实现锁不可能完全避免繁忙等待,我们的目的就是尽可能降低等待的时间。
我们的中心思想就是:我们只用繁忙等待来执行闭锁的操作,如果不能这样做就放弃CPU。
使用一个额外的变量guard用来保证每次只有一个线程获得value并对其操作。
lock()
{
while(test_and_set(guard)){}
if(value==free)
{
value=busy
guard=0
}
else
{
添加到锁的等待队列
guard=0
切换线程
}
}
unlock()
{
while(test_and_set(guard)){}
value=free
if(有其他线程在等待锁)
{
移到就绪队列
value=busy
}
guard=0
}
我们了解了如何使用中断禁止,测试与设置两种硬件原语来实现软件的锁原语。这两种方式比较起来,显然测试与设置更加简单,也因此使用的更为普遍。此外,test and set还有一个优点,就是可以在多CPU环境下工作,而中断启用和禁止则不能。