并发编程不彻底指北(一)

原文发于公众号“百川海的小记”,一个菜鸟的自留地,欢迎关注讨论java


说在前面:这篇文章的内容沿用整理自本人作的一次内部技术分享,半讲稿性质,因此我会用口语的形式表述,并配以一些“编注”进行补充。由于技术分享时间有限,加之本人并不是对本文中说起的全部内容都有深入研究,所以文中部份内容的说明点到为止。不够深刻之处,有兴趣的同窗请自行查阅相关资料。程序员

本篇的内容太长(打字拼不过说话速度啊),分红多期编写,本期为第一期redis


###################正题#######################算法


并发编程是编程中的难点,这一次分享主要但愿将近半年到一年以来的一些学习思考的内容拿出来与各位同窗探讨。观点仅做抛砖引玉之用。编程


首先,分享的标题是并发编程“不彻底指北”,这里有个双关:既然是“指北”,各位就不要当作指南,不能做为金科玉律,可是这个“指北”也不彻底,由于里面我以为仍是有些有价值的内容;另外,做为并发编程的一次主题分享,并发编程的问题之复杂,我不能逐一说明,因此内容是不彻底的。缓存

进入正题,分享主要分为五块:安全

  1. 并发编程的背景;多线程

  2. 并发编程的问题与处理方案;架构

  3. 进程/线程的协做关系与协做模型;(编注:由于叙述的主要是Java的实践,所以下文中部分文字不强调进程,对于Java直接相关的,好比JVM的说明等,讨论范围为严格线程,而在模型和思路的讨论中,进程/线程实质上影响不大,意会便可)并发

  4. 陷阱——也就是一些性能上的问题或者实际编程里面会遇到的坑——与优化;

  5. 一个实战例子。


(编注:加插提供一份本文的思惟导图)


目录的下面写了一句话:“面向接口编程,面向思路设计”,这句话是我以前合做过的一位架构师说的,我以为特别有意思。由于引领实践做用的只有思路,具体实现是没法给出指导的。因此我也不太打算去讲并发的工具,而是讲一些思路性的内容。

背景:并发编程存在的意义

第一个部分,咱们聊聊背景。

第一个问题,咱们为何要用并发编程?我给出的结论是:由于咱们如今的计算机结构里面,CPU、内存、IO设备的速度是不平衡的,CPU的速度远高于其余硬件的,尤为是IO设备,对于CPU的高速来讲简直是慢如蜗牛。为了压榨CPU的性能,因此工程师想出了多线程的方式,充分利用CPU的速度。换而言之,若是有一天,计算机的全部硬件均可以达到和CPU一致的高速率,其实咱们就不须要考虑什么并发编程了,由于逐个线程顺序执行的性能也能达到整个计算机系统的最高性能。

下一个问题,咱们为何不要用并发编程?其实很简单,由于它很复杂,它是反直觉而且难以控制的。往高级点说,它是违反结构化编程准则的。人的思惟是线性的,因此违反线性的程序都是反直觉的,而反直觉的都是容易出错的,这就是并发编程难以精通的缘由。而另外一个方面,难以控制的特色,使得测试与异常捕捉也变得困难。结构化编程之父Dijkstra提出了顺序、选择、循环三种结构化编程的程序结构,其特色是输入输出呈线性结构;他同时指出goto语句这类非结构式的编程语句是“毒药”。其实从这个角度来讲,并发编程可能形成的随机性结果,危害性比goto语句更甚。

可是,现实很骨感,没有一个程序员能够抵抗性能提升的诱惑,因此,我以为并发编程能够类比为饮鸩止渴。这实属无奈,咱们不得不作出妥协,可是咱们应该意识到并发编程从某程度来讲,是有害的。


并发问题与处理方案

接下来咱们讨论并发问题和它们的处理方案。不过在聊并发问题以前,咱们先要讨论是什么致使问题出现的。若是可以从前提条件入手处理,问题也就不会发生。

条件一:状态可变性

并发问题的条件一,是状态的可变性。状态可变性指一个状态在建立以后能够被修改的特性。咱们知道,读取一个状态,不管如何都不会对它形成变化的,状态都是惟一的、安全的,因此没有变化,就没有伤害。因而从这个角度避免并发问题的办法就很简单了,只要保证对象的不变性便可。Java里面构造一个不可变对象,基本能够分为如下几个步骤:

  1. 识别成员对象中的可变对象与不可变对象

  2. 对象以private修饰,可变对象以final修饰(建议)

  3. 提供不可变对象的getter方法,返回其引用

  4. 提供可变对象的getter方法,返回其深复制副本

尤为须要注意的是对可变对象的访问,对外提供的必须是深复制的副本,浅复制的副本是可变的。构造完成后,不可变对象不对外提供任何修改状态的办法,这是不可变对象的基础。

可是不可变对象就真的彻底不可变了么?起码在Java里面这个仍是不必定的,由于Java的反射机制仍是能够对不可变对象进行修改操做的,因此对于反射这么一把双刃剑,咱们要随时保持警戒。

条件二:状态共享性

并发问题的条件二,是状态的共享性。共享性指一个数据状态能够被多个进程/线程访问的特性。若是一个状态只被单线程访问,对它的操做就是串行的,天然不会出现并发的问题。根据这个思路,咱们尽量地将可变对象封闭起来,避免其共享,就能够避免并发问题的发生。这又衍生出来两个手段:

  • 可变状态隔离,即让可变状态只面向单一线程,实践的例子不少,好比Java中的局部变量、ThreadLocal、Netty中的EventLoop机制、Actor模型(JVM的实践如Akka)等。顺带一提,Actor模型是一个颇有意思的解决并发编程的模型,有兴趣请查阅一下相关资料,这里不展开叙述。

  • 可变状态封装。这个主要是面向编程而言的,做为一种代码组织的手段。将关联的可变状态封装,这是面向对象编程的自然优点所在,很契合OOP的基本思路,并且进行封装之后,就有了状态同步的基础。

下面是一个半成品的例子。

/** * 限定:下界不大于上界,上界不小于下界 */
public class LimitedMem {
    private AtomicInteger lowerLimit;   // 下界
    private AtomicInteger upperLimit;   // 上界
    private Vector content; // 数据存储结构

    public void setLowerLimit(int limit) {
        if (limit <= upperLimit.get()) {
            lowerLimit.compareAndSet(lowerLimit.get(), limit);
        }
    }

    public void setUpperLimit(int limit) {
        if (limit >= lowerLimit.get()) {
            upperLimit.compareAndSet(upperLimit.get(), limit);
        }
    }

    // TODO add, get, ...
}
复制代码

咱们假设这是一个具备上界和下界约束的存储对象,约束条件是“下界不大于上界,上界不小于下界”。若是将下界、上界两个变量游离出去,咱们是没有办法安全地完成这个约束的,所以咱们将其放到同一个对象中。可是这个代码还缺了一步,就是约束的断定和实际的操做是分离的,中间可能由于线程切换而中断,这就是所谓原子性问题,后面会作进一步讨论。在这个例子中,咱们只须要为两个方法加上synchronize同步锁便可,由于咱们已经有了LimitedMem这个对象自己做为锁的同步对象了。若是没有状态的封装,这一点是作不到的。


并发三大问题

说完了两个必要条件,正式来讲说并发的三大问题,分别是可见性、有序性、原子性。

问题一:可见性

可见性指当一个线程修改了共享变量后,其余线程可以当即得知这个修改。强调三点:一是必须有修改操做,二是变量必须是共享的,这两点和上面两个前提条件是对应的,三是其余线程得到通知是当即的,不存在延时的。

要理解可见,就要先了解不可见的缘由,这要追溯到硬件结构。以前提到CPU的运行速度是明显快过内存的,所以为了平衡二者的速度差,硬件工程师为CPU加上了被称为“高速缓存”的存储硬件,它的速度介于CPU与内存之间,并且不仅一个,所缓存的数据也相互独立。当程序运行时,为了更快速地处理数据,CPU会从主存将数据加载到高速缓存,而后直接与高速缓存进行交互,而非直接与主存交互。高速缓存的数据回写到主存的时机不是固定的,所以极可能出现缓存中的数据变动后,主存的数据没有及时刷新,这时候其余线程进行数据读取,读取到的就是旧的值,出现了数据的“时差”。

说到可见性,我以为应该补充说明一个常见的误解。Java里面有个Happens-Before规则的概念,有人根据字面意思将其理解为A早于B发生,即A Happens Before B,从而认为规则的意义只在于约束两个操做的前后执行顺序,这是对Happens-Before的误解。Happens-Before规则的真实含义应该是“A的操做结果必可见于B”,这并不仅意味着A发生早于B,并且A的修改操做结果,B是必然可见的。这里的“可见”,和“可见性”中的“可见”是同一个意思,便是A的操做结果,在其后执行的任意线程B都是能够从主存读取到的。具体的Happens-Before规则请自行搜索,在jdk1.5以后的语义已经至关清晰完备了,我就不念白直说了。

Java处理可见性问题的方案是经过volatile关键字进行修饰。它的其中一个语义是强制变量与主存交互读写,避免可见性问题。具体的操做是遵循Happens-Before规则,在指令中添加内存栅栏(编注:有些说法也称之为内存屏障),当指令到达内存栅栏的地方,就强制与主存交互,而且另其余高速缓存的数值无效,从而强制CPU在获取数据时向内存直接读取数据。Volatile还有另一个语义,与有序性相关,容后再谈,这里我先讨论一下有序性的问题。

问题二:有序性

所谓有序性,指机器指令的实际执行应该遵循代码逻辑顺序。这个逻辑顺序,一方面是咱们显式写在代码上的逻辑顺序,另外还有一个方面是一个单独语句隐含的语义,也要遵循逻辑顺序。通常状况下,这个好像是很瓜熟蒂落的,毋庸置疑的,可是对于JVM来讲,这个并非必然的。JVM为了提升自身运行性能,会在一些状况下对实际执行的程序指令进行从新的排序,咱们称之为“指令重排序”(编注:也有简称为“指令重排”或直接称为“重排序”的)。JVM对指令的重排不是随便排的,它是由一个自洽的规则的:指令重排后的运行结果不能与该程序在单线程下、不重排地串行执行获得的结果有差别,先后二者的结果必须保持一致。这个规则称为as-if-serial规则。

可是,JVM给出的这个保证,仅适用于单线程,到了多线程的状况下,有可能就不适用了。两个指令能够进行重排序的条件,亦即知足as-if-serial规则的必要条件,是两个指令间不存在相互依赖。可是在并发场景中,指令的依赖性是没法保障的。


举个例子,以下图所示:

// thread 1
x = 1;  // statement 1
y = 2;  // statement 2

// thread 2
if (x == 1)  
    function(y);  // statement 3
复制代码

对这个例子,thread 1在单线程状况下执行,statement 1 与 statement 2 是没有依赖关系的,能够随意乱序的;可是加入thread 2的并发执行后,一、2的乱序执行,就会直接对statement 3的结果形成直接的影响,这一点是JVM没法作出承诺的。

有序性还有一个具备迷惑性的地方,在于通常问题都出于一些语句的隐含语义当中。再举个例子,下面是一个有问题的单例写法。

public class Singleton {
    private Singleton singleton;

    private Singleton (){}

    public Singleton getSingleton() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                    Collections.synchronizedMap(new HashMap<>());
                }
            }
        }
        return singleton;
    }
}
复制代码

它的问题所在,在于Singleton的建立,是可能被重排序的。按照正常的语义,new一个对象的正确顺序应该是:

  1. 开辟内存空间

  2. 在内存空间中初始化对象数据

  3. 将栈的指针指向内存空间

然而通过指令重排,实际的执行顺序可能变为:

  1. 开辟内存空间

  2. 将栈的指针指向内存空间

  3. 在内存空间中初始化对象数据

若是这种状况,一旦另一个线程在第二步的时候执行null判断,由于栈的指针已经有了内存区域的指向,非空判断的结果为非空,因而直接返回,返回的结果则是一个未经初始化的空间对象。这也是指令重排序形成的问题。

上面提到volatile有另一个语义,就是对volatile修饰的变量操做禁止指令重排序。没了重排序,有序性问题天然从根源上解决了。在jdk1.5以前,例子中的这种双重检测锁的懒汉式单例在Java中是不安全的,直到1.5版本后,volatile的语义获得增强,这种写法才成立。处理很简单,在singleton前面加上volatile修饰就能够了。不过话分两头,指令重排在必定程度上是有利于执行性能的,因此禁止重排序是有损性能的。

另外,顺带一提,final修饰的变量,在初始化阶段也是禁止重排序的,为的就是确保避免上面出现的初始化阶段返回空值的问题。

问题三:原子性

最后一个问题,是原子性问题。原子性指一组操做的外部表现必须是完整的,不可中断的。网上不少说法,将原子性表述为“不可分割”的,这个在原子的字面意思上的确如此,可是在程序的角度去理解是不许确的。要确保一组操做具有原子性,其实并不必定须要真正意义上的“不可分割”,而只须要在未完成的状态下,外部的访问不能“看到”中间的结果就能够了,因此我将这个特性强调为“外部表现”的。


原子性问题的出现,根源在于线程/进程切换,而且中间状态对外暴露,那要解决这个问题,也就要从这两点入手。要阻止切换,手段就是互斥,这个话题在Java里面也有不少手段,咱们在下面再聊。另外能够考虑使用原语。原语能够简单理解为一个具备原子性的操做,若是底层提供了一个可用的原语,上层程序的调用底层原语(编注:在不引入其余任何操做的状况下),这个调用操做自己天然也是具有原子性的。而中间状态对外暴露的问题,本质就是共享性问题,和上面可变状态隔离的处理思路也一致,这里就不重复了。

这里有个例子,代码如图所示。

public class IllegalThreadCountIncrease {
    private static int num = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() ->{
            for (int i=0; i<10000; i++) {
                IllegalThreadCountIncrease.num++;
            }
        });
        Thread t2 = new Thread(() ->{
            for (int i=0; i<10000; i++) {
                IllegalThreadCountIncrease.num++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(IllegalThreadCountIncrease.num);
    }
}
复制代码

这是一个常见的例子,我曾经看过在网上的文章中用这个例子来讲明volatile的可见性。当时的文章在num前面加上了volatile修饰,就认为能够知足输出20000的需求。你们不妨一试,这么作依然只能得出一个随机值。缘由在于,volatile虽然解决了可见性问题,可是没有解决原子性问题。自增的这个操做,虽然只是一句代码,可是实际执行的时候分为3个指令:

  • 取数

  • 自增改数

  • 回写

而很不幸的是,这3个指令的执行并非原子的,试想这么一个场景:

A线程取数,值为0,而后中断,B线程开始执行取数,值也是为0,而后B线程中断,A线程继续执行自增与回写,主存中的结果变成1,可是此时对于B线程来讲,变量的值仍是0,具体来讲这个值是写在B线程的栈中的,因而B再对这个变量执行自增、回写,主存中的结果仍是1,问题就出现了。两种状况的过程时序如图所示。


可见性问题是高速缓存形成的,而这种对于线程自行维护的存储空间中的数据落后问题,或者说数据一致性的问题,咱们通常不理解为可见性问题,volatile的语义也并无扩展到能够解决这个范围的数据问题。所以,volatile不能解决原子性问题,也不能代替互斥,不能将其理解为“锁”。

回到这个例子,要得出正确的20000,办法只能是对num++的这个代码增长互斥锁,最简单的synchronize(IllegalThreadCountIncrease.class) 就能够了。


终极大招

说了好多关于三大问题的处理,最后不能遗漏一个处理并发问题的终极大招,那就是串行化。串行化完全地避免了并发操做的出现,对全部并发问题都是一个完全的解决方案。请不要由于我已经聊了半天并发,就以为觉得抹除并发操做自己毫无心义,回忆一下前面的一个基础现实:并发编程在某程度上来讲是有害的。好比咱们经常使用的redis,就是单进程单线程实现的范例,可是它的性能依然至关好。并发不等于好,串行也不必定很差,这是须要根据模型和算法具体考虑的。


三大问题的总结

总结一下,并发问题体现为三个问题:可见性问题、有序性问题、原子性问题。可见性问题的缘由是CPU高速缓存的读写,有序性问题的缘由是JVM的指令重排,这两个问题的解决方案,都是经过volatile进行声明修饰。原子性问题的缘由是因为线程切换,要解决这个问题,办法就比较多了,能够严格使用底层的原语,能够实现互斥,也能够避免线程的共享,这些在实际的项目中都是常见的。最后,请记得一个完全解决办法的大招:串行化,让你的程序顺序执行,一个一个地。


未完待续……

相关文章
相关标签/搜索