1、什么是无锁(Lock-Free)编程
当谈及 Lock-Free 编程时,我们常将其概念与 Mutex(互斥) 或 Lock(锁) 联系在一起,描述要在编程中尽量少使用这些锁结构,降低线程间互相阻塞的机会,以提高应用程序的性能。类同的概念还有 “Lockless” 和 “Non-Blocking” 等。实际上,这样的描述只涵盖了 Lock-Free编程的一部分内容。本质上说,Lock-Free 编程仅描述了代码所表述的性质,而没有限定或要求代码该如何编写。
基本上,如果程序中的某一部分符合下面的条件判定描述,则我们称这部分程序是符合 Lock-Free的。反过来说,如果某一部分程序不符合下面的条件描述,则称这部分程序是不符合 Lock-Free 的。
上面的英文翻译成中文就是很简单的:如果你的应用程序是多线程并且它们之间都有访问共享内存但是访问时并没有相互阻塞,那它就是lock-free编程。注意lock-free只是强调了编程概念并没指定其具体的实现形式,其强调的概念是「线程间访问共享内存时不会相互阻塞」。那如果没有lock或者Mutex就一定是lock-free编程了吗,看下面的代码片段:
x = 0; while(x == 0){ x = 1 - x; }
假设有线程T1,T2同时调用这段代码,T1,T2都判断x == 0,进行到循环。T1先执行 x = 1 – 0,此时 x = 1后 T2 执行 x = 1 – 1。x = 0。T1,T2此时判断x == 0,结果两者又进入了循环。。。线程T1,T2相互影响,两者都陷入了死循环,这种某种意义也算得上是相互阻塞使线程,所以这不算是lock-free编程。
ok,了解了lock-free编程的相关概念那要怎么实现呢。在开始说无锁队列之前,我们需要知道一个很重要的技术就是CAS操作——Compare & Set,或是 Compare & Swap,现在几乎所有的CPU指令都支持CAS的原子操作,X86下对应的是 CMPXCHG 汇编指令。有了这个原子操作,我们就可以用其来实现各种无锁(lock free)的数据结构。
这个操作用C语言来描述就是下面这个样子:意思就是说,看一看内存*reg里的值是不是oldval,如果是的话,则对其赋值newval。
1 2 3 4 5 6 7 | int compare_and_swap ( int * reg, int oldval, int newval) { int old_reg_val = *reg; if (old_reg_val == oldval) *reg = newval; return old_reg_val; } |
用JAVA语言则是:
public final boolean compareAndSet(int expect, int update) { return unsafe.compareAndSwapInt(this, valueOffset, expect, update); }
了解了CAS操作之后实现lock-free数据结构思路是怎样呢?这里就有篇论文讲述了思路:http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.53.8674&rep=rep1&type=pdf。其中里面就提到了如何用数组实现一个lock-free队列。有兴趣的朋友可以参考上面链接阅读里面的第5章节。现在说一下我自己具体的实现思路:
- 数组队列是一个循环数组,队列少用一个元素,当头等于尾标示队空,尾加1等于头标示队满。
- 数组的元素用EMPTY(无数据,标示可以入队)和FULL(有数据,标示可以出队)标记指示,数组一开始全部初始化成 EMPTY标示空队列。
- EnQue 操作:如果当前队尾位置为EMPTY,标示线程可以在当前位置入队,通过CAS原子操作把该位置设置为FULL,避免其它线程操作这个位置,操作完后修改队尾位置。各个线程竞争新的队尾位置。如下图所示:
下面是贴上具体的代码:
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReferenceArray; /** * 用数组实现无锁有界队列 */ public class LockFreeQueue { private AtomicReferenceArray atomicReferenceArray; //代表为空,没有元素 private static final Integer EMPTY = null; //头指针,尾指针 AtomicInteger head,tail; public LockFreeQueue(int size){ atomicReferenceArray = new AtomicReferenceArray(new Integer[size + 1]); head = new AtomicInteger(0); tail = new AtomicInteger(0); } /** * 入队 * @param element * @return */ public boolean add(Integer element){ int index = (tail.get() + 1) % atomicReferenceArray.length(); if( index == head.get() % atomicReferenceArray.length()){ System.out.println("当前队列已满,"+ element+"无法入队!"); return false; } while(!atomicReferenceArray.compareAndSet(index,EMPTY,element)){ return add(element); } tail.incrementAndGet(); //移动尾指针 System.out.println("入队成功!" + element); return true; } /** * 出队 * @return */ public Integer poll(){ if(head.get() == tail.get()){ System.out.println("当前队列为空"); return null; } int index = (head.get() + 1) % atomicReferenceArray.length(); Integer ele = (Integer) atomicReferenceArray.get(index); if(ele == null){ //有可能其它线程也在出队 return poll(); } while(!atomicReferenceArray.compareAndSet(index,ele,EMPTY)){ return poll(); } head.incrementAndGet(); System.out.println("出队成功!" + ele); return ele; } public void print(){ StringBuffer buffer = new StringBuffer("["); for(int i = 0; i < atomicReferenceArray.length() ; i++){ if(i == head.get() || atomicReferenceArray.get(i) == null){ continue; } buffer.append(atomicReferenceArray.get(i) + ","); } buffer.deleteCharAt(buffer.length() - 1); buffer.append("]"); System.out.println("队列内容:" +buffer.toString()); } }
代码很简单,相应的注释也写上了,相信大家都应该看得懂~。
这里说明一下JDK提供的CAS原子操作类都位于 java.util.concurrent.atomic下面。这里用到的是数组我用的是AtomicReferenceArray类,当然你也可以用AtomicIntegerArray。这里用到了两个原子类的作为指针head,tail,利用mod队列的长度来实现一个循环数组。
下面测试我们的代码:
import java.util.stream.IntStream; public class LockFreeDemo { public static void main(String[] args) { LockFreeQueue queue = new LockFreeQueue(5); IntStream.rangeClosed(1, 10).parallel().forEach( i -> { if (i % 2 == 0) { queue.add(i); } else { queue.poll(); } } ); queue.print(); } }
这里面用了JDK8的lambda并行流的特性,起了Ncpu线程去并发得入队和出队。运行结果如下:
入队成功!2 当前队列为空 当前队列为空 入队成功!6 当前队列为空 入队成功!8 出队成功!2 入队成功!10 出队成功!6 入队成功!4 队列内容:[4,8,10]
因为是并发打印,所以打出来的信息整体是无序的,但是对于同一个元素的操作,我们看到是相对有序的~