自己动手实现自旋锁
注:本文部分内容来源于<<操作系统概念>>第六版,[美]Abraham Silberschatz,Peter Baer Galvin,Greg Gagne著,郑扣根译。如有错误,还望大家批评指正,我先谢过大家了。
锁是为了解决某种资源(又有人称临界资源)互斥使用提出的一种机制。常用的有读写锁、互斥锁、自旋锁。接下来就谈谈这个自旋锁。自旋锁和互斥锁功在使用时差不多,每一时刻只能有一个执行单元占有锁,而占有锁的单元才能获得临界资源的使用权,从而达到了互斥的目的。
自旋锁与互斥锁的区别在于:自旋锁在执行单元在获取锁之前,如果发现有其他执行单元正在占用锁,则会不停的循环判断锁状态,直到锁被释放,期间并不会阻塞自己。由于在等待时不断的"自旋",这也是它为什么叫做自旋锁。所以自旋锁使用时,是非常消耗CPU资源的。而互斥锁在执行单元等待锁释放时,会把自己阻塞并放入到队列中。当锁被释放时,会唤醒队列上执行单元把其放入就绪队列中,并由调度算法进行调度并执行。所以互斥锁使用时会有进程的上下文切换,这可能是非长耗时的一个操作,但是等待锁期间不会浪费CPU资源。所以对两种锁的使用必须要酌情处理。
现在我们自己来实现自旋锁,即软件级别的自旋锁。
首先介绍几个概念:
进入区:实现锁请求的代码段(红色代码)
临界区:互斥执行的代码段
退出区:释放锁的代码段 (紫色代码)
剩余区:其他代码段
有N个进程{p0, p1, p2,......,pn}。
现在我们来说说最简单情况,当执行单元(即进程)数是2的时候如何做。以下用进程来表示独立的执行单元。首先想到可以用一个共享变量turn来表示当前由哪个进程执行。进程i的代码结构如下,其中j=1-i。
do{ while(turn != i); //进入区 //临界区...... turn = j; //退出区 //剩余区...... }while(1);
仔细观察这段代码会发现如下问题:turn的初始值决定了进程的执行顺序。如果turn初始值为0,那么进程1在进程0执行之前是不会获得机会执行的。所以假如进程0压根不想执行,那么即使进程1干着急也必须得等。turn=1时也有这样的问题。并且进程0与进程1严格交替执行,中间如果有谁不再执行,那么另一个将也不再执行。我们也说这样的实现不满足有限等待性。即一个进程会总也得不到机会执行。
接下来考虑另一个实现方案:设置共享变量 boolean flag[2], 初始值为false。进程i的代码结构如下,其中j=1-i。
do{ flag[i] = true; //进入区 while(flag[j]); //临界区...... flag[i] = false; //剩余区...... }while(1);
这段代码满足有限等待性的要求,即如果进程0不执行,进程1也可以执行。但是由于两个进程是并发执行,所以可能会有如下的执行过程:
p0: flag[0]=true;
p1: flag[1]=true;
p0: while(flag[1]);
p1: while(flag[0]);
这样的情况就是死锁,即p0等待flag[1]变成false,p1等待flag[0]变成false。我们说这段代码不满足前进性。以上这两段代码确保每次只有一个进程能够执行临界区内的代码,所以都满足互斥性。
要证明一个算法实现正确与否必须要证明代码是否满足一下三个性质:
1: 互斥性 //每次只有一个进程进入临界区
2: 前进性 //即不会出现死锁
3: 有限等待性 //即一个进程不会无限等待而得不到机会执行
第一个解决两进程互斥问题的正确的软件解决方法是由荷兰数学家T.Dekker提出来的,也叫做Dekker算法。Dekker算法中进程i的代码如下,其中j=1-i。两进程共享boolean flag[2] 和 turn,flag初始化为false,而turn为0或1。
1 do{ 2 flag[i]=ture; //开始竞争 3 while(flag[j]){ //pj正在竞争 4 if(turn == j){ //应该轮到pj执行 5 flag[i] = false; //主动放弃 6 while(turn == j); //pi等待pj释放锁 7 flag[i] = true; //重新开始竞争 8 } 9 } 10 //临界区...... 11 turn = j; //主动退让 12 flag[i] = false; //放弃竞争 13 //剩余区...... 14 }while(1);
互斥性:假设p0,p1都在临界区,则flag[0]与flag[1]都为false。flag[0]在turn=1时才为false,flag[1]在turn=0时才为false。而p0,p1在临界区之前都不会改变turn的值,所以turn在这之前只能有一个值,这说明turn即是0又是1,显然不可能。所以p0与p1只有一个会执行临界区代码。
前进性:初始化flag都为false,所以不参加竞争的进程不会影响参加竞争的进程。又由于turn每次只能有一个值,所以总会有一个进程主动放弃竞争,不会产生死锁,从而另一个进程得到执行。
有限等待:假设p0正在临界区而p1正在等待(第6行代码),则p0会在p1第7行代码执行结束之后最多执行一次,这样p0就会有机会执行。更有意思的是,该算法虽然满足有限等待,但是并不能精确计算出要等待另一个进程执行多少次之后才能执行。原因在于p1在第7行代码执行完之前p0可能执行了很多次。
我们看到这个算法虽然正确,但是要想证明却有些困难。其实,关于两进程最简单解法是有Peterson在1981年提出的。算法代码如下,其中j=1-i。
do{ flag[i] = true; //开始竞争 turn = j; //主动推让 while(flag[j]&&turn==j);//等待 //临界区...... flag[i]=false; //放弃竞争 //剩余区...... }while(1);
互斥性:假设p0,p1都在临界区,则turn即等于0又等于1,显然不可能。故只有一个进程会进入临界区。
前进性:因为turn每次只有一个值,故一定会有一个进程while循环不成立,不会死锁。
有限等待:假设p1进入临界区,p0正在等待且turn=1。p1执行完之后在下一次竞争之前会主动推让,从而p0有机会执行。p0最多等待p1执行一次之后就会执行。
现在讨论一下多个进程的解法,多进程的解法要比2进程解法复杂的多。Dijkstra在1965年给出了第一个有关n个进程互斥问题的解决方案,可是在这个方法里,没有给出一个进程在被允许进入临界区以前必须等待的次数上限。随后Knuth在1966年给出了第一个有限制的算法,它的限制是2的n次方。随后deBrujin改进了Knuth算法,将等待次数减少到n^2。后来Eisenberg和McGuire成功将次数减少到n-1。Lamport则开发了最著名的面包店算法,它的等待次数也是n-1。
下面我们说说这个面包店算法。面包店算法的基本思想为:要进入临界区的进程首先先要抽号,抽到最小号的进程进入临界区,若有两个进程抽到了相同的号,则进程编号最小的进程进入临界区。
比较时比较的是一个数对(number[i], i),若(number[i], i) < (number[j], j),则pi进入临界区。
(number[i], i) < (number[j], j) 等价于 (number[i] < number[j] || number[i] == number[j] && i < j)
N个进程共享 boolean choosing[N] , int number[N],初始化choosing为false, number为0。进程pi的代码如下:
1:do{ 2: choosing[i] = true; //开始抽号 3: number[i] = max(number[0], number[1],......, number[N-1]); //抽号 4: choosing[i] = false; //抽号完毕 5: for(j = 0; j < N; ++j){ 6: while(choosing[j]); //如果有正在抽号的,则等待 7: while((number[j]!=0)&&((number[j], j)<(number[i],i))); //抽完号的进程如果有比自己小的,则等待 8: } 9: //临界区...... 10: number[i]=0; //抽到的号清0,下次重新抽号 11: //剩余区...... 12:}while(1);
可以看到每个进程在进入临界区之前必须先抽号,抽完号了并不能立刻进入临界区,而是要和所有已经抽完号的进程进行比较,如果有比自己小的则自己通过while循环等待。
互斥性:假设pi, pk同时进入了临界区。那么此时number[i]和number[k]都不等于0。那么此时(number[i], i)与(number[k],k)必能比较大小,且一定是一大一小。若(number[i], i)小,则pk陷入循环,否则pi陷入循环,这与两个进程同时在临界区矛盾。故互斥性成立。
前进性:由于(number[i], i) != (number[j], j) 当i!=j时,也就是说任意时刻,只有一个进程最小,从而保证至少有一个进程能够执行,故不会出现死锁。
有限等待:首先可以确定,不参加竞争的进程不会影响参加竞争的进程。并且假设首轮抽签抉择出了进程的执行顺序。
p0, p1, p2,......p(n-1),当p0执行完之后,进行下一次抽号时,抽到的号一定会比已经抽完号的进程抽到的号大,故会在正在等待的进程执行结束之后才执行。
如下所示,p0, p1, p2 ,......., 代表初始的执行顺序。每次一个进程执行结束之后重新抽号,那么位置排序会放到右面。
p0, p1, p2, ......, p(n-1)| p0正在执行 p1, p2, ......, p(n-1)|p0 p0执行完毕并重新抽号,p1正在执行 p2, ......, p(n-1)|p0, p1 p1执行完毕并重新抽号,p2正在执行 p3,..., p(n-1)|p0, p1, p2 p2执行完毕并重新抽号,p3正在执行 . | . . | . 第一选择阶段 第二选择阶段
第二选择阶段抽到的号普遍比第一选择阶段抽到的号大,故第一选择阶段的每个进程必都会有机会执行。而且最多等待n-1个进程执行完毕之后执行。所以有限等待性成立。
下面仔细说说这个算法的每行代码的意义:choosing初始化为false,number初始化为0
第6,7行代码保证了:不争夺临界区的进程不影响其它进程的竞争。
第3行代码保证了: 先抽号的进程抽到的号码小,后抽号的进程抽到的号码大,同时抽号的进程抽到的号码一样大。
第6行代码保证了: 每次决策都是在所有竞争进程抽完号时进行的,保证公平。
第7行代码保证了: 运行到临界区的进程pi,必定(number[i], i)是最小的。
通过面向对象的封装,很容易实现一个自旋锁对象(spinlock),lock()即是进入区代码, unlock()即是退出区代码,具体细节大家自己设计思考,这个这里就不多说了。
相关文献:
Dijkstra[1965]:E.W.Dijstra, "Cooperating Sequential Processes", Technical Report, Technological University. Eindhoven,the Netherlands,1965, pages 43~112.
Peterson[1981]:G.L.Peterson, "Myths About the Mutual Exclusion Problem", Information Processing Letters, Volume 12,Number 3,1981.
Knuth[1966]:D.E.Knuth, "Additional Comments on a Problem in Concurrent Programming Control",Communications of the ACM,Volume 9, Number 5, 1966, pages 321~322.
deBruijn[1967]:N.G.deBruijn,"Additional Comments on a Problem in Concurrent Programming and Control", Communicaitonsof the ACM, Volume 10, Number 3, 1967, pages 137~138.
Eisenberg and McGuire[1972]M.A.Eisenberg and M.R.McGuire,"Future Comments on Dijkstra's Concurrent Programming ControlProblem", Communications of the ACM, Volume 15, Number 11, 1972, pages 999.
Lamport[1974]:L.Lamport, "A New Solution of Dijstra's Concurrent Programming Problem", Communication of the ACM, Volume
17, Number 8, 1974, pages 453~455.