深入理解互斥锁的实现
在实际的软件编程中,经常会遇到资源的争用,比如下面的例子:
class Counter
{
private:
int value;
public:
int GetAndIncrement()
{
int temp = value; //9.进入危险区
value = temp + 1; //10.离开危险区
return value;
}
}
这种实现在单线程系统中能够正常工作,但是在多线程系统则有可能出错。
比如有2个线程,初始状态value=0。第一个线程运行完第9行,这时temp=0。突然一个中断来了,切换到第二个线程运行了,第二个线程运行完第9行也是temp=0,然后执行第10行赋值value=1。然后回到第一个线程继续运行第10行对value进行写覆盖,结果value=1,而正确的情况应该是value=2了。
为什么会产生这样的情况呢?这时因为两个线程同时对一个资源value进行争用产生了冲突。为了避免上述情况,我们可以将这两行置入临界区内:某个时刻内仅能被一个线程执行的代码段。从而实现互斥。对Counter类的增加对临界区的互斥访问:
class Counter
{
private:
int value;
lock lock;
public:
Counter(int c) { value = c; }
int GetAndIncrement()
{
lock.lock(); //获取锁
int temp = value; //进入临界区
value = temp + 1; //离开临界区
lock.unlock(); //释放锁
return value;
}
}
通过在程序中为了使用lock域来保证对象的互斥特性,必须对称的调用lock()和unlock()。需要满足如下条件:
- 一个临界区之和一个lock对关联
- 线程进入临界区前调用lock()
- 线程离开临界区后调用unlock()
编程的框架如下:
- lock()
- 临界区
- unlock()
打个不是十分妥帖的比喻,就像是有一个仓库资源,但是有多个人想去仓库做点事情。这时候仓库只需要一把锁(锁多了纯粹是浪费_),初始状态仓库上的锁是打开的。每个人进去之前先把锁锁住(避免别的人进来),然后自己在仓库里捣弄,离开时再把仓库的锁打开,让别人可以进来。
接下来更加深入的是如何实现互斥锁呢?也就是lock()和unlock()方法。
class Lock
{
public:
virtual void lock() = 0; //进入临界区前
virtual void unlock() = 0; //离开临界区后
};
互斥锁需要满足三个条件:
- 互斥 不同线程的临界区没有重叠
- 无死锁 如果一个线程正在尝试获得一个锁,那么总会成功地获得这个锁。若线程A调用lock()但是无法获得锁,则一定存在其他线程正在无穷次地执行临界区。
- 无饥饿 每一个试图获得锁的线程最终都能成功。
LockOne类
这个类有一个标志数组flag,继续来个比喻,这个flag就相当于一个旗帜。LockOne类遵循这样的协议:
- 如果线程想进入临界区,首先把自己的旗帜升起来(flag相应位置1),表示感兴趣。然后等对方的旗帜降下来就可以进入临界区了。
- 如果线程离开临界区,则把自己的旗帜降下来。
class LockOne: public Lock
{
private:
bool flag[2];
public:
void lock()
{
int i = ThreadID.get();
int j = 1-i;
flag[i] = true;
while(flag[j]);
}
void unlock()
{
int i = ThreadID.get();
flag[i] = false;
}
};
LockOne类的协议看起来挺朴实的,但是存在一个问题:当两个线程都把旗帜升起来,然后等待对方的旗帜降下来就会出现死锁的状态(两个线程都在那傻乎乎的等待对方的旗帜降下来,直到天荒地老)。
LockTwo类
观察LockOne类存在的问题,就是在两个线程同时升起旗帜的时候,需要有一个线程妥协吧,这样就需要指定一个牺牲品,因此LockTwo类横空出世。
class LockTwo: public Lock
{
private:
int victim;
public:
void lock()
{
int i = ThreadID.get();
victim = i; //让别人先走,暂时牺牲自己
while(victim == i);
}
void unlock(){}
}
当两个线程进行竞争的时候,总有一个牺牲品(较晚对victim赋值的线程),因此可以避免死锁。但是,当没有竞争的时候就杯具了,如果只有一个线程想进入临界区,那么牺牲品一直是自己,直到等待别人来替换自己才行。
Perterson锁
通过上面两个类可以发现,LockOne类适合没有竞争的场景,LockTwo类适合有竞争的场景。那么将LockOne类和LockTwo类结合起来,就可以构造出一种很好的锁算法。该算法无疑是最简洁、最完美的双线程互斥算法,按照其发明者的名字被命名为”Peterson算法”。
class Peterson: public Lock
{
private:
bool flag[2];
int victim;
public:
void lock()
{
int i = ThreadID.get();
int j = 1-i;
flag[i] = true;
victim = i;
while(flag[j] && victim==i);
}
void unlock()
{
int i = ThreadID.get();
flag[i] = false;
}
}
Perterson锁是满足互斥特性的。通过反证法来说明,如果两个线程都想进入临界区,但是都成功进入了。因为两个线程都想进入,则说明flag对应位均为1,然后因为都能lock()成功,说明victim均不是自己。这和victim是其中之一矛盾。
但是,实际中线程不可能只有2个,接下来需要看看支持n线程的互斥协议。
Barkey锁
有一种协议称为Bakery锁,是一种最简单也最为人们锁熟知的n线程锁算法。下面看看到底是神马情况。思想很简单,还是打个简单的比喻来说明器协议:
- 每个线程想进入临界区之前都会升起自己的旗帜,并得到一个序号。然后升起旗帜的线程中序号最小的线程才能进入临界区。
- 每个线程离开临界区的时候降下自己的旗帜。
class Bakery: public Lock
{
private:
bool flag[];
Label label[];
public:
Bakery(int n)
{
flag = new bool[n];
label = new Label[n];
for(int i=0; i<n; i++)
{
flag[i] = flase;
label[i] = 0;
}
}
void Lock()
{
int i = ThreadID.get();
flag[i] = true;
label[i] = max(label[0], ..., label[n-1]) +1;
while((exist k!=i)(flag[k] && (label[k],k)<<(label[i], i))
}
void unlock()
{
flag[TheadID.get()] = false;
}
}
首先,Barkey算法是无死锁的。因为正在等待的线程中(类似于所有升起旗帜flag的线程中),必定存在一个最小的序号label。该线程可以进入临界区。
其次,Barkey算法是先来先服务的。因此先来的线程,分到的label比较小。
最后,Barkey算法是互斥的。如果两个线程同时位于临界区,则两个线程都已经升起旗帜,同时label都是最小的,矛盾。
很重要的点是要实现一个n线程的互斥锁,必须至少使用n个存储单元。因为若此刻有某个线程正在临界区内,而锁的状态却与一个没有线程在临界区或正在临界区的全局状态相符,则状态不一致。即每个线程共有2个状态,则n个线程共有2^n个状态,共需要n个存储器记录全局状态。