并发编程Bug起源:可见性、有序性和原子性问题
以前古老的DOS
操作系统,是单进行的系统。系统每次只能做一件事情,完成了一个任务才能继续下一个任务。每次只能做一件事情,比如在听歌的时候不能打开网页。所有的任务操作都按照串行的方式依次执行。
这类服务器缺点也很明显,等待操作的过长,无法同时操作多个任务,执行效率很差。
现在的操作系统都是多任务的操作系统,比如听歌的时候可以做打开网页,还能打开微信和朋友聊天。这几个任务可以同时进行,大大增加执行效率。
并发提高效率
一个完整服务器,都有CPU
、内存
、IO
,三者之间的运行速度存在明显的差异:
CPU
相关的操作,执行指令以及读取CPU
缓存等操作,基本都是纳秒
级别的。CPU
读取内存,耗时是CPU
相关操作的千倍,基本都是微秒
级别。CPU
和内存之间的速度差异。IO
操作基本是毫秒的级别,是内存操作的千倍,内存
和IO
之间存在速度的差异。
CPU -> 内存 -> SSD -> 磁盘 -> 网络
纳秒 -> 微秒 -> 毫秒 -> 毫秒 -> 秒
程序中大部分的语句都要访问内存,有些还要访问的IO
读写。为了合理的利用CPU
的高性能,高效的平衡三者的速度差异,操作系统、编译器主要做了以下改进:
CPU
增加了CPU缓存
,用来均衡CPU
和内存
的速度差异。- 操作系统增加了多进程、多线程,用来分时复用
CPU
,从而均衡CPU
与IO
设备之间的差异。 - 编译优化程序执行顺序,充分利用缓存。
做了以上操作之后,CPU
读取或者修改数据之后,将数据缓存在CPU缓存
中,CPU
不需要每次都从内存中获取数据,极大的提高了CPU
的运行速度。多线程是将时间段切成一个个小段,多个线程在上下文切换中,执行完任务,而不用等前面的线程都执行完毕之后再执行。比如做一个计算,CPU
耗时1
纳秒,而从内存读取数据要1
微秒,没有多线程的话,N
个线程要耗时N微秒
,此时CPU
高效性就无法体现出来。有了多线程之后,操作系统将CPU
时间段切成一个一个小段,多线程上下文切换,线程执行计算操作,无需等待内存读取操作
。
虽然并发可以提高程序的运行效率,但是凡事有利也有弊,并发程序也有很多诡异的bug
,根源有以下几个原因。
缓存导致可见性问题
一个线程对共享变量的修改,另外线程能立刻看到,称为可见性。
在单核时代,所有的线程都是在同一个CPU
上运行,所有的线程都是操作同一个线程的CPU缓存
,一个线程修改缓存,对另外一个线程来说一定是可见的。比如在下图中,线程A
和线程B
都是操作同一个CPU缓存
,所以线程A
更新了变量V
的值,线程B
再访问变量V
的值,获取的一定是V
的最新值。所以变量V
对线程都是可见的。
在多核CPU
下,每个CPU
都有自己的缓存。当多个线程执行在不同的CPU
时,这些线程的操作也是在对应的CPU缓存
上。这时候就会出现问题了,在下图中,线程A
运行在CPU_1
上,首先从CPU_1
缓存获取变量V
,获取不到就获取内存的值,然后操作变量V
。线程B
也是同样的方式在CPU_2
缓存中获取变量V
。
线程A
操作的是CPU_1
的缓存,线程B
操作的是CPU_2
的缓存,此时线程A
对变量V
的操作对于线程B
是不可见的。多核CPU一方面提高了运行速度,但是另一方面也可能会造成线程不安全的问题。
下面使用一段代码来测试多核场景下的可见性。首先创建一个累加的方法add10k
方法,循环10000
次count+=1
的操作。然后在test
方法里面创建两个线程,每个线程都调用add10k
方法,结果是多少呢?
public class VisibilityTest {
private static int count = 0;
private void add10k() {
int index = 0;
while (index++ < 10000) {
count += 1;
}
}
@Test
public void test() throws InterruptedException {
VisibilityTest test = new VisibilityTest();
Thread thread1 = new Thread(() -> test.add10k());
Thread thread2 = new Thread(() -> test.add10k());
// 启动两个线程
thread1.start();
thread2.start();
// 等待两个线程执行结束
thread1.join();
thread2.join();
System.out.println(count);
}
}
按照直觉来说结果是20000
,因为在每个线程累加10000
,两个线程就是20000
。但是实际结果是介于10000~20000
的之间,每次执行结果都是这个范围内的随机数。
因为线程A和线程B同时开始执行,第一次都会将count=0
缓存到自己的CPU缓存
中,执行完count += 1
之后,写入自己对应的CPU缓存
中,同时写入内存中,此时内存中的数是1
,而不是期望的2
。之后CPU
再取到自己的CPU缓存
再进行计算,最后计算出来的count
值都是小于20000
,这就是缓存的可见性问题。
线程切换带来的原子性问题
上面提到,由于CPU
、内存
、IO
之间的速度存在很大的差异,在单进程系统中,需要等速度最慢的IO
操作完成之后,才能接着完成下一个任务,CPU
的高性能也无法体现出来。但操作系统有了多进程之后,操作系统将CPU
切成一个一个小片段,在不同的时间片段内执行不同的进程的,而不需要等待速度慢的IO
操作,在单核或者多核的CPU
上可以一边的听歌,一边的聊天。
操作系统将时间切成很小片,比例20
毫秒,开始的20
毫秒执行一个进程,下一个20
毫秒切换执行另外一个线程,20
毫秒成为时间片
,如下图所示:
线程A
和线程B
来回的切换任务。
如果一个进行IO
操作,例如读取文件,这个时候该进程就把自己标记为休眠状态
并让出CPU
的使用权,等完成IO
操作之后,又需要使用CPU
时又会把休眠的进程唤醒,唤醒的进程就可以等待CPU
的调用了。让出CPU
的使用权之后,CPU
就可以对其他进程进行操作,这样CPU
的使用率就提高上了,系统整体的运行速度也快了很多。
并发程序大多数都是基于多线程的,也会涉及到线程上下文的切换,线程的切换都是在很短的时间片段内完成的。比如上面代码中count += 1
虽然有一行语句,但这里面就有三条CPU
指令。
- 指令 1:把变量V从内存加载到
CPU
寄存器中。 - 指令 2:在寄存器中执行
+1
操作。 - 指令 3:将结果写入内存(也可能是写入
CPU缓存中
)。
任何一条CPU
指令都可能发生线程切换
。如果线程A在指令1执行完后做线程切换,线程A和线程B按照下图顺序执行,那么我们会发现两个线程都执行count += 1
的操作,但是最后结果却是1
,而不是2
。
编译优化带来的有序性问题
有序性是指程序按照代码的先后顺序执行,编译器为了优化性能,在不影响程序的最终结果的情况下,编译器调整了语句的先后顺序,比如程序中:
a = 2;
b = 5;
编译器优化后可能变成:
b = 5;
a = 2;
虽然不影响程序的最后结果,但是也会引起一些意想不到的BUG。
在Java
中一个常见的例子就是利用双重检验创建单例对象,例如下面的代码:
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
在获取实例getInstance
方法中,首先判断instance
是否为空,如果为空,则锁定Singleton.class
并再次检查instance
是否为空,如果还为空就创建一个Singleton
实例。
假设两个线程,线程A
和线程B
同时调用getInstance
方法。此时instance == null
,同时对Singleton.class
加锁,JVM
保证只有一个线程能加锁成功,假设是线程A
加锁成功,另一个线程就会处于等待状态,线程A
会创建一个实例,然后释放锁,线程B
被唤醒,再次尝试加锁,此时成功加锁,而此时instance != null
,已经创建过实例,所以线程B
就不会创建实例了。
看起来没有什么问题,但实际上也有可能问题出现在new
操作上,本来new
操作应该是:
- 1、分配一块内存。
- 2、在内存上初始化对象。
- 3、内存的地址赋值给
instance
变量。
但实际优化后的执行顺序却是如下:
- 1、分配一块内存。
- 2、将内存地址赋值给
instance
变量。 - 3、在内存上初始化对象。
优化之后会发生什么问题呢?首先假设线程A
先执行getInstance
方法,也就是先执行new
操作,当执行完指令2
时发生了线程切换,切换到线程B
上,此时线程B执行getInstance
方法,执行判断时会发现instance != null
,所以就返回instance
,而此时的instance
是没有初始化的,如果这时访问instance
就可能会触发空指针异常。
总结
操作系统进入多核、多进程、多线程时代,这些升级会很大的提高程序的执行效率,但同时也会引发可见性
、原子性
、有序性
问题。
- 多核
CPU
,每个CPU都有各自的CPU缓存,每个线程更新变量会先同步在CPU缓存
中,而此时其他线程,无法获取最新的CPU
缓存值,这就是不可见性。 count += 1
含有多个CPU
指令。当发生线程切换,会导致原子问题。- 编译优化器会调整程序的执行顺序,导致在多线程环境,线程切换带来有序的问题。
开始学习并发,经常会看到volatile
、synchronized
等并发关键字,而了解并发编程的有序性、原子性、可见性等问题,就能更好的理解并发场景下的原理。
参考
并发编程Bug起源:可见性、有序性和原子性问题的更多相关文章
- 【Java并发基础】并发编程bug源头:可见性、原子性和有序性
前言 CPU .内存.I/O设备之间的速度差距十分大,为了提高CPU的利用率并且平衡它们的速度差异.计算机体系结构.操作系统和编译程序都做出了改进: CPU增加了缓存,用于平衡和内存之间的速度差异. ...
- 【转】可见性、原子性和有序性问题:并发编程Bug的源头
如果你细心观察的话,你会发现,不管是哪一门编程语言,并发类的知识都是在高级篇里.换句话说,这块知识点其实对于程序员来说,是比较进阶的知识.我自己这么多年学习过来,也确实觉得并发是比较难的,因为它会涉及 ...
- Java并发编程之验证volatile不能保证原子性
Java并发编程之验证volatile不能保证原子性 通过系列文章的学习,凯哥已经介绍了volatile的三大特性.1:保证可见性 2:不保证原子性 3:保证顺序.那么怎么来验证可见性呢?本文凯哥(凯 ...
- 01 | 可见性、原子性和有序性问题:并发编程Bug的源头
由于CPU.内存.I/O 设备的速度差异,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构.操作系统.编译程序都做出以下处理: 1. CPU 增加了缓存,以均衡与内存的速度差异: ...
- Java并发编程实战 03互斥锁 解决原子性问题
文章系列 Java并发编程实战 01并发编程的Bug源头 Java并发编程实战 02Java如何解决可见性和有序性问题 摘要 在上一篇文章02Java如何解决可见性和有序性问题当中,我们解决了可见性和 ...
- Java并发编程实战(chapter_1)(原子性、可见性)
混混噩噩看了很多多线程的书籍,一直认为自己还不够资格去阅读这本书.有种要高登大堂的感觉,被各种网络上.朋友.同事一顿外加一顿的宣传与传颂,多多少少再自我内心中产生了一种敬畏感.2月28好开始看了之后, ...
- java并发编程实战《一》可见性、原子性和有序性
可见性.原子性和有序性问题:并发编程Bug的源头 核心矛盾:CPU.IO.内存三者之间的速度差异. 为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构.操作系统.编译程序都做出了贡献 ...
- Java并发编程实战 01并发编程的Bug源头
摘要 编写正确的并发程序对我来说是一件极其困难的事情,由于知识不足,只知道synchronized这个修饰符进行同步. 本文为学习极客时间:Java并发编程实战 01的总结,文章取图也是来自于该文章 ...
- 【漫画】JAVA并发编程 如何解决原子性问题
原创声明:本文转载自公众号[胖滚猪学编程],转载务必注明出处! 在并发编程BUG源头文章中,我们初识了并发编程的三个bug源头:可见性.原子性.有序性.在如何解决可见性和原子性文章中我们大致了解了可见 ...
随机推荐
- php公立转农历
<?php function nongli($riqi) { //优化修改 20160807 FXL $nian=date('Y',strtotime($riqi)); $yue=date('m ...
- Tensor的创建和维度的查看
常见的Tensor创建方法 1,基础Tensor函数:torch.Tensor(2,2)32位浮点型 2,指定类型: torch.DoubleTensor(2,2)64位浮点型 3,使用python的 ...
- JDBC、ORM、JPA、Spring Data JPA,傻傻分不清楚?一文带你厘清个中曲直,给你个选择SpringDataJPA的理由!
序言 Spring Data JPA作为Spring Data中对于关系型数据库支持的一种框架技术,属于ORM的一种,通过得当的使用,可以大大简化开发过程中对于数据操作的复杂度. 本文档隶属于< ...
- 搭建uipath
我对windows也不太熟,也是第一次安装Uipath Orchestrator,希望有问题指出一起交流,可以留言,Uipath中文qq交流群:4656303241. 下载镜像 windows ser ...
- 在.NET 6.0上使用Kestrel配置和自定义HTTPS
大家好,我是张飞洪,感谢您的阅读,我会不定期和你分享学习心得,希望我的文章能成为你成长路上的垫脚石,让我们一起精进. 本章是<定制ASP NET 6.0框架系列文章>的第四篇.在本章,我们 ...
- 在docker中打开redis 客户端 cli
首先交互方式进入redis容器 docker exec -it redis /bin/bash 随后运行客户端 redis-cli
- 牛客SQL刷题第一趴——非技术入门基础篇
user_profile表: id device_id gender age university province 1 2138 male 21 北京大学 Beijing 2 3214 male ...
- VGA显示原理
VGA显示是图像处理的基础,是一开始广泛使用的显示器,大部分机器采用VGA接口驱动,所以后来的显示器需要用VGA-xxx转接口来匹配. 用FPGA来驱动VGA,并不适用于显示动态(如手机显示,GUI) ...
- 大数据--Hive的安装以及三种交互方式
1.3 Hive的安装(前提是:mysql和hadoop必须已经成功启动了) 在之前博客中我有记录安装JDK和Hadoop和Mysql的过程,如果还没有安装,请先进行安装配置好,对应的随笔我也提供了百 ...
- CF1007A Reorder the Array 题解
To CF 这道题是排序贪心,将原序列排序后统计答案即可. 但是直接统计会超时,因为排序后具有单调性,所以可以进行一点优化,这样,便可以通过此题. 而这道题的优化在于单调性,因为 \(a[i+1]\) ...