Skip to content

给面试官讲讲无锁编程(Lock-Free Programming)

入门无锁编程对程序员深入学习并发编程有很大的意义。特别一些重要岗位,现在也开始在面试中增加无锁编程的讨论。

每个新人开始接触编发编程,都会经历几个阶段:

  1. 什么都上锁
  2. 开始用数据结构,并开始研究数据结构
  3. 开始自己设计数据结构
  4. 灵活运用

上面几个阶段,最核心的是从「有锁」到「无锁」(Lock Free)的一种思维变化,慢慢抓住接近解决并发问题的本质。并不是说「有锁」就差,或者一定要用「无锁」,而是开始灵活运用。

无锁编程(Lock Free)我们在前面的课程中或多或少已经碰到了,只不过我们没有给出明确的定义。之所以再安排一堂课,是因为无锁编程是并发算法的一大类,在日后你继续进步前, 咋们把几个基本的类别分清楚,有助于你分类和思考并发问题。

本节课关联的面试题有:

  1. 什么是无锁编程(Lock Free)?
  2. 请举一个无锁编程的例子?
  3. 自旋锁是不是无锁编程的设计?
  4. Obstruction Free/Lock Free/Wait Free的区别?
  5. 请举一个Obstruction Free/Wait Free的例子?
  6. 如何用Lock-Free实现一个栈/队列?

什么是无锁编程(Lock Free Programming)

再给出明确的定义前,我想先请大家思考一个问题,下面这段程序安不安全?

a = a + 1

当然,不安全,需要上锁,这是作为并发编程初学者的第一个想法。但是对于一些深入学习了CAS 原子操作的朋友,可能会思考用下面的解法。下面试图用CAS循环解决a=a+1 的问题。

int a = 0
    
update() {

    while(true) {
    if( cas(&a, a, a+1) ) {     
      return "done";
    }
  }
}

对于更加优秀的一些朋友,可能会考虑到ABA问题,于是用下面的解法。下面解法中将a 看做了一个二元组,就是<value, version>的二元组。利用CAS 循环同时更新值(value)和版本(version)。这样就不会存在ABA问题。

Pair a = <0, 0>

while(true) {
  ver = a.version
  val = a.value
    if( cas(&a, <val, ver>, <val+1, ver+1>) ) {
        return "done"
    }
}

然后,我们来思考上面程序和锁的区别。比如下面这段程序,同样是解决了a=a+1 这个问题,但是区别在哪里呢?

synchronized(a) {
    a = a + 1;    
}

有两个非常显著的区别:

  1. CAS循环构成的不是排队结构,一个线程休眠不会影响其他线程;synchronized 构成了一种排队结构,如果拥有锁的线程休眠、置换等等,都会导致其他线程阻塞。
  2. CAS循环构成的算法中,同一时刻,总有线程可以进步;synchronized ,如果拥有锁的线程发生故障,可能导致没有线程可以继续运行。

Lock-Free的定义

在Lock-Free设计中,线程之间互相隔离(一个线程的延迟、阻塞、故障)不会影响其他线程;且同一时刻至少有一个线程可以进步。

Lock-Free核心是两个问题:

  1. 隔离:一个线程不会影响另一个线程进步
  2. 进步:达成计算目标(约束:至少有一个线程进步)

Lock-Free设计我们之前已经接触过了,在讲AQS的时候,我们讲过其中一个核心的算法实现——CLH队列。CLH的入队、出队操作都利用CAS Loop进行循环运算,是典型的Lock-Free设计。再比如,之前我们碰到的SynchrounousQueue内部双向队列、双向栈的实现,通过CAS实现的transfer操作,也是一个Lock Free的实现。

难点解析:自旋锁是不是Lock Free

有一个常见的误区,大家会认为使用了CAS操作即是Lock Free。那么我们通常会使用CAS操作实现自旋锁(spin lock),那么自旋锁是不是Lock Free设计呢?

下面是自旋锁的程序:

volatile int lock = 0;

spinlock(){
  while(!cas(&lock, 0, 1)) {
    Thread.onSpinWait();
  }
}

unlock(){
  while(!cas(&lock, 1, 0)) {
    Thread.onSpinWait();
  }
}

通过spinlockunlock 方法可以控制上锁和解锁,上锁的过程我们利用CAS Loop实现,解锁我们也用CAS Loop实现。 接下来,我们思考使用自旋锁实现i++

// 使用

spinlock();
i++;
unlock();

请你思考下,上面这段程序和下面这段程序的区别:

do{
  a = i
}while(!cas(&i, a, a+1))

最大的区别在于自旋锁还是上锁,本质是排队,对临界区保护。在同一时刻,拥有锁的线程阻塞没有拿到锁的线程。所以,如果通过spinlock 拿到锁的线程,如果因为突发问题阻塞、被切换——这个时候其他线程只有等待。而第2个程序不同,每个线程都在尝试用cas 进步,如果有一个线程阻塞,其他线程也可以继续工作。

因此,自旋锁不是Lock Free;但是直接利用cas 循环更新i 的值是Lock Free。

实战Lock Free

接下来我带你写一个Lock Free的链表,通过这段程序,实现一个Lock Free的Stack。

Stack采用一个链表实现,push操作采用头插法,在链表头部插入元素。pop操作采用头删法,每次移出头部元素。

如果你阅读下面程序感到吃力,也可以随着课程一起学习,我会在视频中给出Line By Line的讲解。

public class LockFreeStack<T> {

    static class Node<T> {

        Node<T> next;
        T value;

        public Node(T value){
            this.value = value;
            this.next = null;
        }
    }

    AtomicStampedReference<Node<T>> head;

    public LockFreeStack(){
        var headNode = new Node<T>(null);
        head = new AtomicStampedReference<Node<T>>(headNode, 0);
    }

    public void push(T v){
        var newNode = new Node<T>(v);
        while(true) {
            int stamp = head.getStamp();
            Node<T> ref = head.getReference();
            newNode.next = ref;
            if(head.compareAndSet(ref, newNode, stamp, stamp+1)){
                return;
            }
        }
    }

    public T pop(){

        while(true) {
            int stamp = head.getStamp();
            Node<T> ref = head.getReference();
            if(ref.next == null) {
                return null;
            }
            var next = ref.next;
            head.compareAndSet(ref, next, stamp, stamp+1);
            return ref.value;
        }
    }




    @Test
    public void testSingle(){

        var stack = new LockFreeStack<Integer>();

        for(int i = 0; i < 100; i++) {
            stack.push(i);
        }

        Integer j = null;
        Integer i = 99;
        while((j = stack.pop()) != null) {
            assertEquals(j+"", i-- +"");
        }
    }

    @Test
    public void testMultiThreads() throws InterruptedException {
        var stack = new LockFreeStack<Integer>();

        for(int i = 0; i < 16; i++) {
            var t = new Thread(() -> {

                for(int j = 0; j < 100; j++ ){
                    stack.push(j);
                }

            });

            t.start();
            t.join();
        }

        Integer c = 0;
        while(stack.pop() != null) {
            c ++;
        }

        assertEquals(c+"", "1600");


    }
}

并发设计思路总结

最后,我们一起总结下并发部分的设计思路。

当我们执行一个并发任务时,因为有共享资源需要访问,最容易想到的方法是互斥(锁、同步队列)都属于这种结构。

在使用锁的过程当中,要注意减少锁的范围。因为锁是一种同步排队的机制,锁的范围越小,相当于并发执行部分占比越大。

无阻碍(Obstruction Free)

当我们开始思考希望多个线程之间不要互相阻塞的时候,我们就开始进入一个新的世界,比如开始使用Lock Free设计数据结构。

其实还有一种比Lock Free更宽松的限制,叫做Obstruction Free设计。这种设计要求线程之间隔离,但不要求任何时刻都有线程进步。

比如下面这段程序:

Pair a = <0, 0>

while(true) {
  ver = a.version
  val = a.value
    if( cas(&a, <val, ver>, <val, ver+1>) ) {
    if( cas(&a, <val, ver+1>, <val+1, ver+1> ) ) {
       return "done"        
    }

    }
}

还是以a=a+1 为例,第一个CAS操作实际上是一种检验操作,它在尝试检当前线程是否可以拿到最新版本号,相当于少量的进步。第二个CAS操作才是我们真实的目的。

上面这种设计如果线程A通过第1个CAS,可能会被后来的线程B通过CAS的线程抢先1步拿到下个版号(ver)。而这个时刻,线程A的进步会被中断,线程B的进步还没有开始。这种设计我们称为Obstruction Free。线程B和线程A竞争进步,但是线程B如果阻塞、延迟、异常,不会影响线程A最终进步。这种设计,就是Obstruction Free。

Lock Free是Obstruction Free。Obstruction Free的要求比Lock Free更弱。Lock Free要求总是有线程在进步,因此Obstruction Free的算法更好实现一些。Obstruction Free的算法往往是多个CAS操作嵌套在执行,其中有的在进步,有的在检查。对于一个CAS Loop,多几步检查可以让线程停留在不同的步骤上,从而减少竞争的激烈程度。

无等待(Wait Free)

无等待(Wait Free)在Lock Free基础上,增加了更强的约束。是并发设计的最高境界。 所有线程都在进步,不需要等待。

举个例子,比如说Copy On Write模型。比如我们要更新对象o,需要3步:

  1. 拷贝o到一个新对象p
  2. 更新p
  3. 替换指向o的所有引用为p

在上述过程内,所有对o的读者(Reader)都不需要锁。所以从这个角度看,CopyOnWrite算法的所有读者是Wait Free的。

从约束关系上,Wait Free > Lock Free > Obstruction Free。 从性能角度,包括在内,4种设计不能说一定谁块谁慢。 但是Obstruction Free的算法,因为没有阻碍,不会出现死锁、饥饿等问题。要注意死锁,饥饿等问题的根源,还是资源的抢占伴随着阻塞造成的。

其实最核心的,是要为想要解决的问题,找到合适的算法。学习这几个概念最大的作用是,当你实现了一个并发控制算法的时候,可以将它归类,并且可以做这样的思考:能不能找到一个LockFree的算法?

关于LockLess

我们经常还会听到一个词汇,叫做LockLess——翻译过来也好像是无锁。其实LockLess指的是不用锁进行编程,不用synchronized 关键字,不用ReentrantLock 等等都是LockLess。由此可见,LockLess是个笼统概念,就是没有用锁。

信号量、阻塞队列、其他并发数据结构,包括Lock Free,只要没有直接用到锁,都算成LockLess。

总结

程序员开始针对自己遇到的场景开始设计数据结构,是读并发编程原理层面掌握的开始。往后深造,自己就可以开始设计并发的算法和库。并发的知识,千万不要孤立的看。并发,是为了更好的利用单机性能,解决更多的问题;也是为了在工作中提升自己系统的性能瓶颈。单机的处理学号,到了分布式领域,还是会基于单机处理的思路,继续扩展。比如解决分布式一致性的若干思路,和解决内存一致性是类似的。

这节课属于一个对之前所学的一个总结个拔高。下一节课,我们会从另一个角度,优化一个实际问题:哲学家就餐问题,帮助你继续提高。

文章来源于自己总结和网络转载,内容如有任何问题,请大佬斧正!联系我