走进原子性

二次信任 提交于 2020-01-29 13:48:27

十五年前,多核处理器是特别的系统,需要花费成百上千的美元。今天,多核处理器系统又便宜又丰富,几乎每一个主要的微处理器都对并发有内部支持。

为了利用好多核的优势,软件也架构在多线程上。但是,仅仅将一个工作简单地划分为几个线程并不能发挥出硬件的优势-----你必须要确保你的线程大部分时间是在工作,而不是在等待工作,或者是在共享数据结构上等锁。

问题:线程之间的协作

几乎没有task能够真正地并发运行,不需要线程之间的协作。想象一个线程池,那里tasks是互相独立地运行的。假如一个线程池要为一个queue工作,那么为queue加入或者删除元素都必须是线程安全的,那意味着在head,tail,或者内部node的引用之间的协作。正是这样的协作导致了所有的问题。

标准解决方法:上锁

对于共享字段的协作获取的传统做法是通过synchronize,它确保所有对共享字段的获取都是拿着锁的状态。使用synchronize,你可以确保不管哪个保护着一些变量的线程取得锁,它都会对这些变量有着独享,而对这些变量的改变又会变成可见的,当其他线程接下来获取锁时。缺点是当锁被激烈竞争时(当一个线程已经取得锁,其他线程频繁地想要获取锁),吞吐量就会受到影响,因为竞争激烈的同步性非常昂贵。

另一基于锁的算法的问题是如果获取锁的线程延迟了(因为一个页面错误还是其他的原因),那么想要获取该锁的线程就什么也做不了。

volatile变量也可以用来存储共享变量,而且比同步代价更小,但是它们有局限。因为对volatile变量的写会立马对别的线程可见,那么就无法去渲染一个读-改-写的原子操作的序列,打个比方,volatile变量不能用来可靠地实现一个互斥锁或者计数器。

实现一个计数器和互斥锁

考虑开发一个线程安全的计数器类,它暴露出get(),increment(),decrement()操作。

package com.company.concurrency_in_practice;

public class SynchronizationCounter {
    private int value;

    public synchronized int getValue(){
        return value;
    }
    public synchronized int increment(){
        return ++value;
    }

    public synchronized int decrement(){
        return --value;
    }
}

increment()和decrement()操作是原子性的读-改-写操作-----要安全地增加计数,你必须先取出当前的值,然后给它加一,然后把新值写出去,每一个都是单一的操作,不受其他线程影响。不然的话,如果两个线程想同时操作加一,不幸的话,一个数会被加两次。

这种读-改-写混合操作在很多并发算法中都有用到。下面就是一个简单的互斥锁。acquire()方法就是一个读-改-写方法。要拿到互斥锁,你就必须先确保没有人拿到它(curOwner==null),然后记录下你拿到这个锁了(curOwner=Thread.currentThread()),所有的这一切都是为了避免另一个线程中途进来然后修改curOwner这个属性。

package com.company.concurrency_in_practice;

public class SynchronizedMutex {
    private Thread curOwner = null;

    public synchronized void acquire() throws InterruptedException{
        if(Thread.interrupted()) throw new InterruptedException();
        while (curOwner != null){
            wait();
        }
        curOwner = Thread.currentThread();
    }
    
    public synchronized void release(){
        if(curOwner == Thread.currentThread()){
            curOwner = null;
            notify();
        }else{
            throw new IllegalStateException("not owner of mutex");
        }
    }
}

在竞争小的情况下,counter会工作得很好,但是在大竞争下,性能就会大大下降。JVM会花大量时间处理线程安排、等待的队列,然后花很少时间去做真正的事情,比如加一的操作。

locking的问题

用locking,如果一个线程想去获取已经被另一个线程占用的锁,那么这个线程就会阻塞,直到可以拿到锁。这样的方式有一些明显的缺点,包括这个线程在等的时候,它其他什么事情都做不了。这样的场景会是一个灾难,如果这个等待的线程是个很重要的任务的话。

用锁还有其他问题,比如碰到了死锁。即使没有这样的情况,锁相对是一种粗颗粒的协作机制,对一个加一的counter或者是更新谁拿到了互斥锁来说是相当“重量级的”。如果有细颗粒的机制的话,那就太好了。在如今的处理器中,有。

硬件同步的早期

现代处理器有对指令集的加强,来支持多线程的特殊需求。特别的,几乎每一个现代处理器都对更新共享变量有指令,以此来探测和保护来自其他处理器的并发进入。

Compare and Swap(CAS)

第一个支持并发的处理器提供了原子的test-and-set操作,它只操作在一个比特上。当下处理器采取的大多数方法,包括Intel和Sparc处理器,是来实现一个早期的算法,叫做compare-and-swap,即CAS。

一个CAS操作包括三部分:一个内存地址(V),期望的旧值(A),一个新值(B),处理器会原子性地把位置的值更新为新值,如果那个位置的值与期待的旧值匹配的话,要不然的话它什么也不会做。不管哪种情况,它会在CAS指令前返回那个位置的值(有些CAS算法会简单地返回一个CAS是否成功,而不是去取当前的值)。CAS会有效率地说:“我认为V位置应该是A值,如果是,把B值放在那里,如果不是,不要改,但请告诉我那里此时的值。”

使用CAS的自然方法是从地址V读取值A,然后执行一系列的步骤去得到B值,然后用CAS去把值从A改成B。如果与此同时V处的值没有被改变过,CAS就会成功。

像CAS这样的指令会让算法无忧地执行读-改-写操作,不用担心其他线程中途修改变量。因为如果另外一个线程中途修改了变量,CAS会探测到它(然后失败),然后算法再次尝试操作。下面的代码展示了CAS行为:

package cas;

public class SimulatedCAS {
    private int value;

    //获取内存值
    public synchronized int getValue(){

        return value;
    }

    public synchronized int compareAndSwap(int expectedValue, int newValue){
        //读内存值
        int oldValue = value;
        //如果内存值等于预估值
        if(oldValue == expectedValue){
            //将内存值替换成新值
            this.value = newValue;
        }
        //无论替换得成功还是不成功,都要将内存值返回出去
        return oldValue;
    }

}

用CAS来实现计数器

基于CAS的并发算法叫做lock-free,因为线程不用为一个锁去等待。不管CAS操作成不成功,它都在一定的时间内完成。如果CAS失败了,调用者可以重试CAS操作或者实行其他在它看来是合适的操作。

package cas;

public class CasCounter {
    private SimulatedCAS value = new SimulatedCAS();

    //封装一下
    public int getValue(){
        return value.getValue();
    }

    public int increment(){

        //获取期望值(或者叫预估值)
        int oldValue = getValue();
        //如果期望值和内存值不一样,也就是修改失败,那就再不断的获取预估值来进行compareAndSwap的操作
        while(value.compareAndSwap(oldValue,oldValue+1) != oldValue){
            oldValue = getValue();
        }
        //compareAndSwap成功的话,就set,加1成功.
        return oldValue+1;
    }
}

lock-free和wait-free算法

一个算法被认为是wait-free的,如果每一个线程都会继续取得进展即使面对其他线程的任意的延迟。相对的,一个lock-free的算法需要仅仅确保一些线程总是取得进展。

对于wait-free和lock-free算法的研究在过去15年持续深入(也被认为是非阻塞算法)。而且非阻塞算法也在许多常见数据结构中被发现。非阻塞算法广泛地运用在操作系统和JVM中,用于像线程这样的任务。因为他们更难实现,他们比基于锁的方式有更多的优势。像优先级倒置和死锁这样的灾难就会避免,竞争更加廉价,合作发生于更细颗粒度的级别,从而有更高级别的并发。

原子变量类

原子变量类可以认为是volatile变量的一个普遍化,它扩展了volatile变量的概念来支持原子情况的compare-and-set更新操作。原子变量的读和写和对于volatile变量的读和写有着一样的内存语义。

细颗粒度意味着轻量级

一个常用的用来调试并发程序的量级的方式是降低使用的被锁对象的颗粒度,以期更多的锁的获取从大竞争变成小竞争。从锁到原子变量的转变达成了这样的目的----转变到细颗粒度的合作机制后,更少的操作会有很大的竞争,于是提高了吞吐量。


大致的翻译至此结束。

我们首先看一下线程的原子性问题:

package cas;

public class JUCCas {

    public static void main(String[] args) {
        Counter counter = new Counter();
        for (int i = 0; i < 20; i++) {
           Thread thread = new Thread(counter);
           thread.start();
        }
    }
}

class Counter implements Runnable{

    private int serialNumber = 0;

    public int getSerialNumber() {
        return ++serialNumber;
    }

    @Override
    public void run() {
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + " is working..." + "after increment,the serialNumber is: " + getSerialNumber());
    }
}

测试结果:

Thread-12 is working...after increment,the serialNumber is: 1
Thread-2 is working...after increment,the serialNumber is: 3
Thread-14 is working...after increment,the serialNumber is: 2
Thread-10 is working...after increment,the serialNumber is: 1
Thread-0 is working...after increment,the serialNumber is: 4
Thread-3 is working...after increment,the serialNumber is: 8
Thread-4 is working...after increment,the serialNumber is: 9
Thread-11 is working...after increment,the serialNumber is: 7
Thread-8 is working...after increment,the serialNumber is: 10
Thread-19 is working...after increment,the serialNumber is: 11
Thread-18 is working...after increment,the serialNumber is: 6
Thread-15 is working...after increment,the serialNumber is: 13
Thread-7 is working...after increment,the serialNumber is: 14
Thread-6 is working...after increment,the serialNumber is: 5
Thread-16 is working...after increment,the serialNumber is: 12
Thread-5 is working...after increment,the serialNumber is: 15
Thread-13 is working...after increment,the serialNumber is: 19
Thread-17 is working...after increment,the serialNumber is: 18
Thread-9 is working...after increment,the serialNumber is: 17
Thread-1 is working...after increment,the serialNumber is: 16

这显然是有线程安全问题的。

一个thread拿到serialNumber正要改,还没有改成功,另一个thread也抢到了serialNumber的值,这样的话,计数器就不会出现一个我们期待的从小到大序列了。

问题在于,++serialNumber并非一个原子性操作,它是由三部分组成的:

int temp = serialNumber;
temp = temp+1;
serialNumber = temp;

这三小部才是原子性的。

所以读改写的过程中很容易被别的线程打断正确的修改。


我们会想到synchroniz关键字,这当然能解决问题,因为这可是jvm里最初的锁啊。

但正如文章所说的,它是重量级锁,需要调用操作系统函数,代价太大了。

现在我们就可以利用juc包下的原子变量类了,它们在硬件层面是上原子性的。

class Counter implements Runnable{

    private AtomicInteger atomicInteger = new AtomicInteger();


    @Override
    public void run() {
        try {
            Thread.sleep(300);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + " is working..." + "after increment,the serialNumber is: " + atomicInteger.incrementAndGet());
    }
}

这就保证线程安全了。


我们也可以测试一下文章中模拟cas的代码:

package cas;

public class TestCasCounter {
    public static void main(String[] args) {
        final CasCounter casCounter = new CasCounter();

        for (int i = 0; i < 20; i++) {

            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName() + " is working....  after increment " + casCounter.increment() );

                }
            }).start();
        }
    }
}

结果读者可以自己试一下。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!