原文发于公众号“百川海的小记”,一个菜鸟的自留地,欢迎关注讨论
说在前面:这篇文章的内容沿用整理自本人做的一次内部技术分享,半讲稿性质,所以我会用口语的形式表述,并配以一些“编注”进行补充。因为技术分享时间有限,加之本人并非对本文中提及的所有内容都有深刻研究,因此文中部分内容的说明点到为止。不够深入之处,有兴趣的同学请自行查阅相关资料。
本篇的内容太长(打字拼不过说话速度啊),分成多期编写,本期为第一期
###################正题#######################
并发编程是编程中的难点,这一次分享主要希望将近半年到一年以来的一些学习思考的内容拿出来与各位同学探讨。观点仅作抛砖引玉之用。
首先,分享的标题是并发编程“不完全指北”,这里有个双关:既然是“指北”,各位就不要当做指南,不能作为金科玉律,但是这个“指北”也不完全,因为里面我觉得还是有些有价值的内容;另外,作为并发编程的一次主题分享,并发编程的问题之复杂,我不能逐一说明,所以内容是不完全的。
进入正题,分享主要分为五块:
并发编程的背景;
并发编程的问题与处理方案;
进程/线程的协作关系与协作模型;(编注:因为叙述的主要是Java的实践,因此下文中部分文字不强调进程,对于Java直接相关的,比如JVM的说明等,讨论范围为严格线程,而在模型和思路的讨论中,进程/线程实质上影响不大,意会即可)
陷阱——也就是一些性能上的问题或者实际编程里面会遇到的坑——与优化;
一个实战例子。
(编注:加插提供一份本文的思维导图)
目录的下面写了一句话:“面向接口编程,面向思路设计”,这句话是我之前合作过的一位架构师说的,我觉得特别有意思。因为引领实践作用的只有思路,具体实现是无法给出指导的。所以我也不太打算去讲并发的工具,而是讲一些思路性的内容。
背景:并发编程存在的意义
第一个部分,我们聊聊背景。
第一个问题,我们为什么要用并发编程?我给出的结论是:因为我们现在的计算机结构里面,CPU、内存、IO设备的速度是不平衡的,CPU的速度远高于其他硬件的,尤其是IO设备,对于CPU的高速来说简直是慢如蜗牛。为了压榨CPU的性能,所以工程师想出了多线程的方式,充分利用CPU的速度。换而言之,如果有一天,计算机的所有硬件都可以达到和CPU一致的高速率,其实我们就不需要考虑什么并发编程了,因为逐个线程顺序执行的性能也能达到整个计算机系统的最高性能。
下一个问题,我们为什么不要用并发编程?其实很简单,因为它很复杂,它是反直觉并且难以控制的。往高级点说,它是违反结构化编程准则的。人的思维是线性的,所以违反线性的程序都是反直觉的,而反直觉的都是容易出错的,这就是并发编程难以精通的原因。而另一个方面,难以控制的特点,使得测试与异常捕捉也变得困难。结构化编程之父Dijkstra提出了顺序、选择、循环三种结构化编程的程序结构,其特点是输入输出呈线性结构;他同时指出goto语句这类非结构式的编程语句是“毒药”。其实从这个角度来说,并发编程可能造成的随机性结果,危害性比goto语句更甚。
但是,现实很骨感,没有一个程序员可以抵抗性能提高的诱惑,所以,我觉得并发编程可以类比为饮鸩止渴。这实属无奈,我们不得不做出妥协,但是我们应该意识到并发编程从某程度来说,是有害的。
并发问题与处理方案
接下来我们讨论并发问题和它们的处理方案。不过在聊并发问题之前,我们先要讨论是什么导致问题出现的。如果能够从前提条件入手处理,问题也就不会发生。
条件一:状态可变性
并发问题的条件一,是状态的可变性。状态可变性指一个状态在创建之后可以被修改的特性。我们知道,读取一个状态,无论如何都不会对它造成变化的,状态都是唯一的、安全的,所以没有变化,就没有伤害。于是从这个角度避免并发问题的办法就很简单了,只要保证对象的不变性即可。Java里面构造一个不可变对象,基本可以分为以下几个步骤:
识别成员对象中的可变对象与不可变对象
对象以private修饰,可变对象以final修饰(建议)
提供不可变对象的getter方法,返回其引用
提供可变对象的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的并发执行后,1、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一个对象的正确顺序应该是:
开辟内存空间
在内存空间中初始化对象数据
将栈的指针指向内存空间
然而经过指令重排,实际的执行顺序可能变为:
开辟内存空间
将栈的指针指向内存空间
在内存空间中初始化对象数据
如果这种情况,一旦另外一个线程在第二步的时候执行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进行声明修饰。原子性问题的原因是由于线程切换,要解决这个问题,办法就比较多了,可以严格使用底层的原语,可以实现互斥,也可以避免线程的共享,这些在实际的项目中都是常见的。最后,请记得一个彻底解决办法的大招:串行化,让你的程序顺序执行,一个一个地。
未完待续……