引言
上一篇文章,我们讲了总线锁和缓存锁定,通过在硬件指令前面加上LOCK前缀,可以实现整个硬件指令的原子性。但是我们可能还不知道我们能通过这些东西实现什么。这篇文章,我们来看两个重要的机器指令,它们都可以原子执行,我们来看一下我们能通过这两个指令做什么。
XCHG指令
在讲总线锁定的时候,我们知道,XCHG这个指令自动就有LOCK语义,不需要加LOCK前缀。这个指令的用处很大,所有的Intel x86 CPU在低层同步中都用到了XCHG指令。
我们先来看下XCHG命令的语义(《Intel指令参考手册》中的解释):
XCHG指令交换两个操作数的内容。例如:
XCHG %rsp 4(%rax)
就是交换%rsp这个寄存器和%rax这个寄存器值加4代表的内存位置的值。如果它的两个操作数中有内存操作数,就像上面的那个,处理器的LOCK信号就会自动断言来保证操作的原子性。也就是说整个交换过程是原子性的。
这样的原子交换指令有什么意义呢?假设某个内存位置(这里用LOCK表示)最开始的值是0,每个线程都将1与LOCK这个内存位置中的数据交换,就像下面的这个指令(%rsp这个寄存器中的值是1):
XCHG %rsp,lock
交换之后,寄存器中的值变为原来LOCK内存位置的值,LOCK位置的值变为1(注意一个线程执行交换操作时其他线程是肯定不能执行交换操作的),此时我们检查寄存器中的值,如果是0,就表示现在是可以进行下一步操作的(例如进入临界区),或者说我们拿到了锁(哈哈),如果是1,就说明此时这个锁被别的线程持有。拿到锁的线程或者说执行交换操作之后得到0的线程,就可以执行临界区里面的代码了,为什么得到0就表示拿到了锁得到1就表示没拿到锁,这个过程用文字描述就不太容易了,但是实际上是很简单的,你可以自己脑海里模拟一下这个过程。
这里面有几点比较重要:
- 交换操作是原子性的。
- LOCK位置的初始值是0。
- 寄存器中的值非零(为了简单,这里都设置为1)。
这样,我们就能通过这个原子操作来决定哪个线程能访问临界区了。你必须要理解的一点是,软件或者操作系统使用硬件提供的原子性XCHG指令,实现的是任意时刻只有一个线程能拿到锁(互斥地拿到锁)这一目的,XCHG指令的原子性并不是指它保证了临界区的原子性,它仅仅是硬件提供的一个原子性的替换操作而已,而是我们利用这个指令的原子性和功能,构造出来一个互斥锁的逻辑,然后通过这个逻辑上的互斥锁来实现对临界区的互斥访问,你需要将指令本身的原子性和临界区的原子性区分开。
我们接着想,没拿到锁也就是交换操作得到的结果是1的线程,因为不能执行临界区里面的代码,它接下来要做什么呢?一个很经典的解决方法就是一直循环这个取锁的过程,也就是一直循环XCHG这个操作,直到拿到锁为止,这个就是我们常说的忙等待,没有拿到锁的线程并没有阻塞,而是一直占用cpu时间来获取锁。《现代操作系统》上面给出了一个这样的示例:
我们注意到除了XCHG命令,它还有JNE(跳转)命令,它的作用就是跳转到取锁循环的代码处也就是我们上面说的让线程循环,还有leave_region里面,MOVE指令将LOCK内存位置重新设置为0,这是获取锁的线程离开临界区之后需要做的,相当于释放锁,这点也很重要,我们必须在离开临界区时释放锁,这样其他线程才能继续获取锁,另外,释放锁操作是不需要同步的。
那么再看引言中的问题,我们通过XCHG实现了什么?实现了逻辑上的互斥锁,有了XCHG之后,我们就可以让多个线程互斥地进入临界区了。
考虑一个i++操作,我们想保证i++的线程安全,就可以使用XCHG这个指令,某个线程执行完交换操作发现得到的是0,就可以进入i++这个临界区来执行自增操作,其他线程则循环继续获取锁。
但是随着硬件指令的发展,对于i++这样的操作,我们有了另外的选择,那就是CMPXCHG指令。
CMPXCHG指令
CMPXCHG指令就是java中实现JDK并发包原子包装类的底层支持了。我们来看它的语义:
CMPXCHG(compare and change)在《Intel指令参考手册》中是这样解释的:CMPXCHG用来在使用多个处理器的系统上进行同步。它需要三个操作数:一个在寄存器中的源操作数,一个在EAX寄存器中的源操作数以及一个目的操作数(一个内存位置)。如果目的操作数(内存位置)中包含的值与EAX寄存器中源操作数的值相等,那么目的操作数就会被设置成另一个源操作数(不是EAX的那个寄存器)的值,否则,目的操作数中的值就会被加载到EAX寄存器中。
通俗的解释可以参考《深入理解java虚拟机》中的描述,CAS指令需要三个操作数,分别是内存位置(V)旧的预期值(A)和新值(B),当CAS指令执行时,当且仅当V符合旧的预期值A时,处理器用新值B更新V的值,否则不执行更新,但是无论是否更新了V的值, 都会返回V的旧值。
这个指令并不是原子指令,它需要加上LOCK前缀才能保证原子性。《Intel指令参考手册》中有这样的描述:对于多核处理器,CMPXCHG可以结合LOCK前缀来原子性地执行比较和替换操作:
For multiple processor systems, CMPXCHG can be combined with the LOCK prefix to
perform the compare and exchange operation atomically.
理解了它的语义之后,我们来看它能实现什么。首先,它能完成跟XCHG一样的事情,作为锁来保证多个线程之间互斥地访问临界区。多个线程执行LOCK CMPXCHG命令,如果返回的值与预期值相同,说明可以进行操作,也就是获取到了锁,如果返回的值与预期值不同,说明锁被其他线程持有。这样,它能以与XCHG一样的原理来保证对临界区(例如i++)访问的线程安全。
如果LOCK CMPXCHG只是跟XCHG指令实现一样的功能,那么我这里介绍它就没有太大意义了。实际上除了作为锁实现互斥之外,CMPXCHG还有更巧妙的用法,就是直接利用它的原子性和功能来实现类似i++这类操作的原子性而不是将其作为锁。怎么理解呢?我们来看它的操作数中的目的操作数,也就是那个内存位置,它其实是直接就可以作为我们程序中的共享变量的,说的更直白一些,它直接可以是i++中的i,而旧的预期值是i的当前值,新值就是i+1的值,这样的话,LOCK CMPXCHG命令的语义就是将内存位置i的值加一了,由于LOCK语义已经保证了这条指令的原子性,所以我们不需要再去考虑任何互斥的问题,或者说,此时的LOCK CMPXCHG已经不再作为锁来使用了,它的功能和原子语义让它能直接保证程序中共享内存的原子更新。它的这种使用方式是JDK中原子包装类的实现基础,我们会在后面的系列中介绍。
小结
XCHG和CMPXCHG是两个很经典的可以原子执行的硬件指令,前者能帮助我们理解硬件的原子指令怎样作为锁来实现互斥,后者功能上的特点加上原子性能够直接实现类似i++操作的原子性并且不需要将其作为锁。
硬件实现原子操作的系列文章就到这里,下个系列,我们来看jdk中的原子包装类,它们是在LOCK CMPXCHG的基础上构建出来的。