真正的、绝对的、数学意义上的时间,就其自身及其自身的自然属性而言,总是平稳平静地流动着,而与外界任何事物无关。——牛顿
同步与互斥
术语“同步”在不同的上下文之中有不同的具体含义,比如同步IO与异步IO中的“同步”指的是用户线程与内核线程之间的协作形式,而java Synchronized中的“同步”又隐含着将cache中的数据同步到内存这层语义。这里明确本文讨论的“同步”指的是并发程序片段的同步,他通常与互斥、锁等概念一起出现。
同步指的是对于同一片段程序不同可调度实体必须以先后顺序执行。互斥指的是某个程序片段不能同时被多个可调度实体执行,而不要求不同调度实体执行这个程序片段的顺序。可以看出同步对多程序片段的执行有更强的要求。
内存模型
内存模型决定了CPU怎样访问内存,以及并发情况下各CPU之间的影响,CPU是基于物理地址访问内存的,多CPU带来的问题是不同CPU从内读到的数据一致性问题,因此内存模型主要关心的是CPU和内存之间数据和物理地址的传输问题。
不同硬件系统有不同的内存模型的实现,差异在于内存看到的load和store的顺序是怎样的(内存可见性问题),通常的实现是改变load和store的执行顺序来提升性能。如下面一段代码:
load %r1,A
load %r2,B
add %r3,%r1,%r2
store %r3,c
在顺序内存模型下,上述指令的执行过程为:
-
将内存地址A中的值放入寄存器r1
-
将内存地址B中的值放入寄存器r2
-
将寄存器r1与r2的值想家,并放入寄存器r3中
-
将寄存器r3中的值写入到内存地址c中
在非顺序内存模型下,指令的执行顺序可能被改变,如B已经在cpu cache中,而A没有在,显然laod A需要很长的时间,CPU为了提升性能将在等待load A执行完成之前预先执行load B,因此对于内存来说CPU实际上是先执行load B再执行load A。
临界区互斥
虽然在顺序内存模型下程序片段的执行顺序是一致的,但是在多CPU环境下,不同可调度实体对同一个程序片段的执行顺序是不确定的,如下代码:
load %r1, counter
add %r1, 1
store %r1,counter
上面代码是高级语言i++的底层实现,我们来看下多CPU不对上面同一个程序片段的不同执行顺序对执行结果的影响。
CPU1 | CPU2 |
load %r1, counter | |
add %r1, 1 | |
store %r1,counter | |
load %r1, counter | |
add %r1, 1 | |
store %r1,counter |
上面的情况两个CPU对这段代码的执行时间是顺序的,因此结果是正确的。那么我们再看看下面一种场景:
CPU1 | CPU2 |
load %r1, counter | load %r1, counter |
add %r1, 1 | add %r1, 1 |
store %r1,counter | store %r1,counter |
如上执行顺序结果就不一样了,像这种会由于并发环境下执行顺序导致结果不一致的代码区域我们称之为临界区,被操作的数据叫做临界资源,如果要避免并发情况下程序执行结果不一致问题我们需要保护临界资源——互斥。
基于硬件的锁
如果我们能够解决读-改-写原子的操作问题,那么就能够解决互斥问题,那么临界区保护自然就没有问题了,当前硬件都提供了基于原子的read-modify-write操作,read-modify-write操作允许一个CPU读取一个值,修改这个值并将修改结果写回到内存的三个操作作为一个原子总线操作。对于怎样的modify操作每个实现标准都可能不同,如目前的CPU都支持TAS(test-and-set)原子指令,最基本的TAS实现就是swap-atomic操作,改操作仅仅将寄存器中的值与内存中的值进行交换,下面看看一段代码:
int test_and_set(volatile int* addr) {
int old_value;
old_value = swap_atomic(addr, 1);
if (old_value == 0) {
return 0;
}
return 1;
}
volatile修饰addr保证了if(old_value == 0)这段代码的正确性,因为swap_atomic是原子的,因此当多个CPU同时执行这段代码的时候,只有能执行成功。利用test_and_set我们得到如下代码:
volatile int locknum = 1;
int i = 0;
if (test_and_set(&locknum) == 1 ) {
i++;//临界区
}
如上代码,我们通过基于硬件支持的方式实现了临界资源的保护。那么我们是否可以认为并发情况下对临界资源的保护本质上都是基于硬件提供的原子读-改-写能力呢?如果没有硬件的支持我们能够实现锁吗?
基于软件的锁
下面我们尝试着用软件方式去实现一个锁,下面会介绍几种十分有趣的双线程锁算法。
锁的基本特性
下面形式化地描述一个好的锁应该满足如下特性:
-
互斥:不同线程的临界区之间没有重叠,这是锁的基本要求
-
无死锁:如果一个线程尝试获取一个锁,则总会成功地获取这个锁,如果无法获取这个锁,则一定是因为另外一个可调度实体卡在临界区之内无法返回了,而不是lock的设计有问题
-
无饥饿:每个试图获取锁的线程都能够最终执行成功,每个lock调用最终都能够返回
LockOne锁
LockOne是一种双线程锁算法,假设两个线程可以通过ThreadID.get()获取当前线程的表示,是0或1,我们来看看LockOne算法的实现:
class LockOne implements Lock {
private volatile boolean[] flag = new boolean[2];
public void lock() {
int i = ThreadID.get()
int j = 1 - i;
flag[i] = true;
while(flag[j]){}//等待
}
public void unlock() {
int i = ThreadID.get();
flag[i] = false;
}
}
上面LockOne的实现能够满足互斥,但是当两个线程都执行到falg[i]=true这里的时候会发生死锁,因此LoclOne算法有死锁问题,如果一个线程先执行完另外一个线程再执行的话是没有问题的。
LockTwo算法
下面看看LockTwo算法的实现:
class LockTwo implements Lock {
private volatile int victim;
public void lock() {
int i = ThreadID.get();
victim = i;
while(victim == i) {}
}
public void unlock() {}
}
两个线程同时执行上面代码,如果同时执行到victim = i和while之间,则只有一个线程进入临界区,因此LockTwo算法满足互斥,然而如果两个线程顺序地执行两个线程都会卡在lock中,因此LockTwo算法也有死锁问题。LockTwo和LockOne算法恰好是互补的,那么我们能否将LockOne算法和LockTwo算法结合起来呢?
Peterson算法
Peterson算法是LockOne算法和LockTwo算法的结合,其算法以作者命名,我们看看Peterson算法的实现:
class Peterson implements Lock {
private volatile boolean[] flag = new boolean[2];
private volatile int victim;
public void lock() {
int i = ThreadID.get();
int j = 1 - i;
flag[i] = true;
victim = i;
while(flag[j] && victim == i){}
}
public void unlock() {
int i = ThreadID.get();
flag[i] = false;
}
}
有意思的是Peterson算法真的是LockOne算法和LockTwo算法的完美结合,可以看出Peterson算法可以解决互斥、死锁、且无饥饿。那么我们是否可以认为没有硬件的支持我们也可以基于软件实现锁呢?问题是上面的锁算法都是双线程算法,都需要两个单元的存储空间。虽然有过滤锁算法和Bakery锁算法(由于篇幅问题这里就不详细介绍)可以解决多线程的并发问题,但是需要N的存储空间,我们可以认为基于软件实现锁是可行的,但是不实用的。
总结
这篇文章得出的结论是现实中我们使用的用于解决互斥问题的锁基本上都是基于硬件所支持的原子读-改-写指令(也可能是其他硬件指令)实现的,然而说所有的锁必须基于硬件支持这种说法是不严谨的,基于软件可以实现锁,只是不实用。
参考文献
《MySQL内核——InnoDB存储引擎》
《多处理器编程的艺术》
另外欢迎关注我的个人公众号:
-EOF-